**Date library reorganisation** (no new third-party dependency)
- New `src/lib/util/dias/` with `_vendor/` (Adobe Apache 2.0 vendored code,
ATTRIBUTION.md preserved) and a public facade split into `types`,
`values`, `calendars`, `queries`, `operations`, `parse`, `format`,
`index`. Consumers import only from `$lib/util/dias`.
- All date-related `Intl` consolidated in `dias/format.ts` —
`DateFormatter`, cache, `resolveDateOrder`, `resolveHourCycle`,
placeholders, defaults.
- `ext/dates` now a thin reactive wrapper delegating to `dias`.
- Aligned App-layer `HourCycle` to canonical numeric form `12 | 24`
(matches `Intl.DateTimeFormat`'s `hour12`). Removed string form
`'12h' | '24h'` from `ext/app/types`, `ext/dates`,
`ext/presentation/locale-defaults`, and their tests.
- Repointed every consumer: `soma/external/dates`,
`terra/external/dates`, eight `routes/test/*` pages.
- Legacy `src/lib/util/dates/` left untouched by request.
**Tier 3 components**
- `soma/components/calendar/` — 14 parts, single + multiple selection,
`MonthSelect`/`YearSelect`, ISO/simple week numbers,
`isDateHoliday` matcher, `data-weekend` flag, navigation live-
region, RTL flip, full ARIA, 27-item checklist verified.
- `soma/components/range-calendar/` — 14 parts, range selection with
live hover preview (novel vs ark/bits/radix/react-aria), `minDays`
/ `maxDays` / `allowSingleDay`, third-click deselects only the
clicked endpoint.
- Test pages at `/test/soma/calendar` and `/test/soma/range-calendar`
with seven demos each. Links added to the soma index under a new
"Dates" section.
- READMEs with anatomy, parts, props, ARIA, keyboard, data-attrs, and
a competitive comparison table.
**Command fixes**
- Resolved O(N^2) reactive cascade: introduced
`visibleSet: $derived<Set<string>>` on the root provider;
per-item `isVisible` is now an O(1) `has()` lookup instead of
calling back into the provider, which was re-running all items'
derivations on every `itemsVersion` bump.
- Added `dir` prop with fallback to `soma.presentation.getDir()`;
horizontal arrows flip in RTL via `getDirectionalKeys`; root
receives `dir` attribute.
**Doc updates (session outcomes)**
- `CLAUDE.md`: replaced obsolete `@/glob/lib` date references with
`$lib/util/dias`; added canonical date-layer section; reinforced
`Provider` export rule and `data-{component}[-{part}]` naming
(never `data-soma-*`); clarified translation convention.
- `src/uix/soma/COMPONENT_GUIDE.md`: new checklist items 26
(component README required) and 27 (grep for data-attr
consistency); new Common Mistakes 11 (`data-soma-*` prefix), 12
(wrong date module), 13 (string `HourCycle`).
- `src/uix/soma/SOMA_ARCHITECTURE.md`: clarified data-attr naming
(canonical emitter is `createAttrs`; validator checks enum
values only, not names); new section covering date types and
formatting through `$soma/external/dates` → `$lib/util/dias`;
documented numeric `HourCycle`.
**Infrastructure**
- `PrimitiveHeaderAttributes` and `PrimitiveSelectAttributes` added
to `$soma/types`.
- `getLastFirstDayOfWeek`, `getNextLastDayOfWeek` re-exported from
`$soma/external/dates`.
- `calendar.*` and `range-calendar.*` translation blocks added to
`$soma/core/langs.ts`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
semantuix
parent
ca768f068a
commit
69a8e8d4b5
@ -1,18 +1,9 @@
|
||||
import type { DateOrder, HourCycle } from '../app/types';
|
||||
/**
|
||||
* Thin re-export of locale-default helpers from `$lib/util/dias`. Exists for
|
||||
* historical reasons — `ext/dates` used to convert between App-layer string
|
||||
* hour-cycle (`'12h'|'24h'`) and the canonical numeric form. Since both
|
||||
* layers now use the same numeric form, this file is a passthrough and can
|
||||
* be removed; consumers should import directly from `$lib/util/dias`.
|
||||
*/
|
||||
|
||||
const MDY_LOCALES = new Set(['en-US', 'en-PH', 'en-BZ', 'en-MH', 'en-FM']);
|
||||
const YMD_BASES = new Set(['zh', 'ja', 'ko', 'hu', 'lt', 'fa']);
|
||||
const H12_BASES = new Set(['en', 'ko', 'hi', 'bn']);
|
||||
|
||||
export function resolveDateOrder(locale: string): DateOrder {
|
||||
if (MDY_LOCALES.has(locale)) return 'MDY';
|
||||
const base = locale.split('-')[0].toLowerCase();
|
||||
if (YMD_BASES.has(base)) return 'YMD';
|
||||
return 'DMY';
|
||||
}
|
||||
|
||||
export function resolveHourCycle(locale: string): HourCycle {
|
||||
if (locale === 'en-GB') return '24h';
|
||||
const base = locale.split('-')[0].toLowerCase();
|
||||
return H12_BASES.has(base) ? '12h' : '24h';
|
||||
}
|
||||
export { resolveDateOrder, resolveHourCycle } from '$lib/util/dias';
|
||||
|
||||
@ -1,26 +1,18 @@
|
||||
import type { Direction, DateOrder, HourCycle } from '../app/types';
|
||||
import type { Direction } from '../app/types';
|
||||
|
||||
const RTL_LOCALES = new Set(['ar', 'he', 'fa', 'ur', 'ps', 'ku', 'yi', 'dv']);
|
||||
|
||||
/**
|
||||
* Best-effort direction inferred from a BCP 47 locale.
|
||||
*/
|
||||
export function resolveDir(locale: string): Direction {
|
||||
const base = locale.split('-')[0].toLowerCase();
|
||||
return RTL_LOCALES.has(base) ? 'rtl' : 'ltr';
|
||||
}
|
||||
|
||||
const MDY_LOCALES = new Set(['en-US', 'en-PH', 'en-BZ', 'en-MH', 'en-FM']);
|
||||
|
||||
export function resolveDateOrder(locale: string): DateOrder {
|
||||
if (MDY_LOCALES.has(locale)) return 'MDY';
|
||||
const base = locale.split('-')[0].toLowerCase();
|
||||
// Chinese, Japanese, Korean, Hungarian use YMD
|
||||
if (['zh', 'ja', 'ko', 'hu', 'lt'].includes(base)) return 'YMD';
|
||||
return 'DMY';
|
||||
}
|
||||
|
||||
export function resolveHourCycle(locale: string): HourCycle {
|
||||
const base = locale.split('-')[0].toLowerCase();
|
||||
// US, Philippines, and a few others use 12h by default
|
||||
const h12 = new Set(['en', 'ko', 'hi', 'bn']);
|
||||
if (locale === 'en-GB') return '24h';
|
||||
return h12.has(base) ? '12h' : '24h';
|
||||
}
|
||||
/**
|
||||
* Legacy re-exports. The date-related defaults live in `$lib/util/dias`;
|
||||
* they were duplicated here in an earlier iteration and are kept as
|
||||
* passthroughs to preserve any lingering imports.
|
||||
*/
|
||||
export { resolveDateOrder, resolveHourCycle } from '$lib/util/dias';
|
||||
|
||||
@ -0,0 +1,38 @@
|
||||
# _vendor — third-party attribution
|
||||
|
||||
The files in this directory are derived work from Adobe's
|
||||
[`@internationalized/date`](https://github.com/adobe/react-spectrum/tree/main/packages/%40internationalized/date)
|
||||
package, distributed under the Apache License, Version 2.0.
|
||||
|
||||
Original copyright: Copyright 2020 Adobe. All rights reserved.
|
||||
|
||||
License: Apache License, Version 2.0
|
||||
https://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
## Scope
|
||||
|
||||
Every file under `_vendor/` carries the original Apache 2.0 header. This
|
||||
directory is the library's internal implementation of calendar math, parsing
|
||||
and formatting. Consumers of the `dias` public API **must not** import from
|
||||
`_vendor/` directly — use the facade at `src/lib/util/dias/index.ts` instead.
|
||||
|
||||
## Changes from upstream
|
||||
|
||||
- Folder layout has been reorganised around the public `dias` facade.
|
||||
- Type imports use relative paths suited to this project's structure.
|
||||
- Any future modifications to the vendored source will be noted here with
|
||||
a short description and the date of the change.
|
||||
|
||||
## What is NOT vendored
|
||||
|
||||
The `dias` facade files (everything outside `_vendor/`) are original work
|
||||
authored for this framework: `types.ts`, `values.ts`, `calendars.ts`,
|
||||
`queries.ts`, `operations.ts`, `parse.ts`, `format.ts`, `defaults.ts`,
|
||||
`index.ts`. Those files are distributed under the framework's own license
|
||||
and consist of re-exports and thin compositions over the vendored code.
|
||||
|
||||
## Removing or replacing the vendor
|
||||
|
||||
If the vendored implementation is ever replaced by a clean-room rewrite or
|
||||
a different upstream library, delete this directory and repoint the facade
|
||||
files. Consumers of the public `dias` API are unaffected.
|
||||
@ -0,0 +1,59 @@
|
||||
/*
|
||||
* Copyright 2020 Adobe. All rights reserved.
|
||||
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License. You may obtain a copy
|
||||
* of the License at http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software distributed under
|
||||
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
|
||||
* OF ANY KIND, either express or implied. See the License for the specific language
|
||||
* governing permissions and limitations under the License.
|
||||
*/
|
||||
|
||||
// Portions of the code in this file are based on code from ICU.
|
||||
// Original licensing can be found in the NOTICE file in the root directory of this source tree.
|
||||
|
||||
import type { AnyCalendarDate, CalendarIdentifier } from '../core/types';
|
||||
import { CalendarDate } from '../core/CalendarDate';
|
||||
import { fromExtendedYear, getExtendedYear, GregorianCalendar } from './GregorianCalendar';
|
||||
|
||||
const BUDDHIST_ERA_START = -543;
|
||||
|
||||
/**
|
||||
* The Buddhist calendar is the same as the Gregorian calendar, but counts years
|
||||
* starting from the birth of Buddha in 543 BC (Gregorian). It supports only one
|
||||
* era, identified as 'BE'.
|
||||
*/
|
||||
export class BuddhistCalendar extends GregorianCalendar {
|
||||
identifier: CalendarIdentifier = 'buddhist';
|
||||
|
||||
fromJulianDay(jd: number): CalendarDate {
|
||||
let gregorianDate = super.fromJulianDay(jd);
|
||||
let year = getExtendedYear(gregorianDate.era, gregorianDate.year);
|
||||
return new CalendarDate(
|
||||
this,
|
||||
year - BUDDHIST_ERA_START,
|
||||
gregorianDate.month,
|
||||
gregorianDate.day
|
||||
);
|
||||
}
|
||||
|
||||
toJulianDay(date: AnyCalendarDate): number {
|
||||
return super.toJulianDay(toGregorian(date));
|
||||
}
|
||||
|
||||
getEras(): string[] {
|
||||
return ['BE'];
|
||||
}
|
||||
|
||||
getDaysInMonth(date: AnyCalendarDate): number {
|
||||
return super.getDaysInMonth(toGregorian(date));
|
||||
}
|
||||
|
||||
balanceDate(): void {}
|
||||
}
|
||||
|
||||
function toGregorian(date: AnyCalendarDate) {
|
||||
let [era, year] = fromExtendedYear(date.year + BUDDHIST_ERA_START);
|
||||
return new CalendarDate(era, year, date.month, date.day);
|
||||
}
|
||||
@ -0,0 +1,205 @@
|
||||
/*
|
||||
* Copyright 2020 Adobe. All rights reserved.
|
||||
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License. You may obtain a copy
|
||||
* of the License at http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software distributed under
|
||||
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
|
||||
* OF ANY KIND, either express or implied. See the License for the specific language
|
||||
* governing permissions and limitations under the License.
|
||||
*/
|
||||
|
||||
// Portions of the code in this file are based on code from ICU.
|
||||
// Original licensing can be found in the NOTICE file in the root directory of this source tree.
|
||||
|
||||
import type { AnyCalendarDate, Calendar, CalendarIdentifier } from '../core/types';
|
||||
import { CalendarDate } from '../core/CalendarDate';
|
||||
import type { Mutable } from '$lib/util/objs';
|
||||
|
||||
const ETHIOPIC_EPOCH = 1723856;
|
||||
const COPTIC_EPOCH = 1824665;
|
||||
|
||||
// The delta between Amete Alem 1 and Amete Mihret 1
|
||||
// AA 5501 = AM 1
|
||||
const AMETE_MIHRET_DELTA = 5500;
|
||||
|
||||
function ceToJulianDay(epoch: number, year: number, month: number, day: number): number {
|
||||
return (
|
||||
epoch + // difference from Julian epoch to 1,1,1
|
||||
365 * year + // number of days from years
|
||||
Math.floor(year / 4) + // extra day of leap year
|
||||
30 * (month - 1) + // number of days from months (1 based)
|
||||
day -
|
||||
1 // number of days for present month (1 based)
|
||||
);
|
||||
}
|
||||
|
||||
function julianDayToCE(epoch: number, jd: number) {
|
||||
let year = Math.floor((4 * (jd - epoch)) / 1461);
|
||||
let month = 1 + Math.floor((jd - ceToJulianDay(epoch, year, 1, 1)) / 30);
|
||||
let day = jd + 1 - ceToJulianDay(epoch, year, month, 1);
|
||||
return [year, month, day];
|
||||
}
|
||||
|
||||
function getLeapDay(year: number) {
|
||||
return Math.floor((year % 4) / 3);
|
||||
}
|
||||
|
||||
function getDaysInMonth(year: number, month: number) {
|
||||
// The Ethiopian and Coptic calendars have 13 months, 12 of 30 days each and
|
||||
// an intercalary month at the end of the year of 5 or 6 days, depending whether
|
||||
// the year is a leap year or not. The Leap Year follows the same rules as the
|
||||
// Julian Calendar so that the extra month always has six days in the year before
|
||||
// a Julian Leap Year.
|
||||
if (month % 13 !== 0) {
|
||||
// not intercalary month
|
||||
return 30;
|
||||
} else {
|
||||
// intercalary month 5 days + possible leap day
|
||||
return getLeapDay(year) + 5;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Ethiopic calendar system is the official calendar used in Ethiopia.
|
||||
* It includes 12 months of 30 days each, plus 5 or 6 intercalary days depending
|
||||
* on whether it is a leap year. Two eras are supported: 'AA' and 'AM'.
|
||||
*/
|
||||
export class EthiopicCalendar implements Calendar {
|
||||
identifier: CalendarIdentifier = 'ethiopic';
|
||||
|
||||
fromJulianDay(jd: number): CalendarDate {
|
||||
let [year, month, day] = julianDayToCE(ETHIOPIC_EPOCH, jd);
|
||||
let era = 'AM';
|
||||
if (year <= 0) {
|
||||
era = 'AA';
|
||||
year += AMETE_MIHRET_DELTA;
|
||||
}
|
||||
|
||||
return new CalendarDate(this, era, year, month, day);
|
||||
}
|
||||
|
||||
toJulianDay(date: AnyCalendarDate): number {
|
||||
let year = date.year;
|
||||
if (date.era === 'AA') {
|
||||
year -= AMETE_MIHRET_DELTA;
|
||||
}
|
||||
|
||||
return ceToJulianDay(ETHIOPIC_EPOCH, year, date.month, date.day);
|
||||
}
|
||||
|
||||
getDaysInMonth(date: AnyCalendarDate): number {
|
||||
return getDaysInMonth(date.year, date.month);
|
||||
}
|
||||
|
||||
getMonthsInYear(): number {
|
||||
return 13;
|
||||
}
|
||||
|
||||
getDaysInYear(date: AnyCalendarDate): number {
|
||||
return 365 + getLeapDay(date.year);
|
||||
}
|
||||
|
||||
getMaximumMonthsInYear(): number {
|
||||
return 13;
|
||||
}
|
||||
|
||||
getMaximumDaysInMonth(): number {
|
||||
return 30;
|
||||
}
|
||||
|
||||
getYearsInEra(date: AnyCalendarDate): number {
|
||||
// 9999-12-31 gregorian is 9992-20-02 ethiopic.
|
||||
// Round down to 9991 for the last full year.
|
||||
// AA 9999-01-01 ethiopic is 4506-09-30 gregorian.
|
||||
return date.era === 'AA' ? 9999 : 9991;
|
||||
}
|
||||
|
||||
getEras(): string[] {
|
||||
return ['AA', 'AM'];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Ethiopic (Amete Alem) calendar is the same as the modern Ethiopic calendar,
|
||||
* except years were measured from a different epoch. Only one era is supported: 'AA'.
|
||||
*/
|
||||
export class EthiopicAmeteAlemCalendar extends EthiopicCalendar {
|
||||
identifier: CalendarIdentifier = 'ethioaa'; // also known as 'ethiopic-amete-alem' in ICU
|
||||
|
||||
fromJulianDay(jd: number): CalendarDate {
|
||||
let [year, month, day] = julianDayToCE(ETHIOPIC_EPOCH, jd);
|
||||
year += AMETE_MIHRET_DELTA;
|
||||
return new CalendarDate(this, 'AA', year, month, day);
|
||||
}
|
||||
|
||||
getEras(): string[] {
|
||||
return ['AA'];
|
||||
}
|
||||
|
||||
getYearsInEra(): number {
|
||||
// 9999-13-04 ethioaa is the maximum date, which is equivalent to 4506-09-29 gregorian.
|
||||
return 9999;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Coptic calendar is similar to the Ethiopic calendar.
|
||||
* It includes 12 months of 30 days each, plus 5 or 6 intercalary days depending
|
||||
* on whether it is a leap year. Two eras are supported: 'BCE' and 'CE'.
|
||||
*/
|
||||
export class CopticCalendar extends EthiopicCalendar {
|
||||
identifier: CalendarIdentifier = 'coptic';
|
||||
|
||||
fromJulianDay(jd: number): CalendarDate {
|
||||
let [year, month, day] = julianDayToCE(COPTIC_EPOCH, jd);
|
||||
let era = 'CE';
|
||||
if (year <= 0) {
|
||||
era = 'BCE';
|
||||
year = 1 - year;
|
||||
}
|
||||
|
||||
return new CalendarDate(this, era, year, month, day);
|
||||
}
|
||||
|
||||
toJulianDay(date: AnyCalendarDate): number {
|
||||
let year = date.year;
|
||||
if (date.era === 'BCE') {
|
||||
year = 1 - year;
|
||||
}
|
||||
|
||||
return ceToJulianDay(COPTIC_EPOCH, year, date.month, date.day);
|
||||
}
|
||||
|
||||
getDaysInMonth(date: AnyCalendarDate): number {
|
||||
let year = date.year;
|
||||
if (date.era === 'BCE') {
|
||||
year = 1 - year;
|
||||
}
|
||||
|
||||
return getDaysInMonth(year, date.month);
|
||||
}
|
||||
|
||||
isInverseEra(date: AnyCalendarDate): boolean {
|
||||
return date.era === 'BCE';
|
||||
}
|
||||
|
||||
balanceDate(date: Mutable<AnyCalendarDate>): void {
|
||||
if (date.year <= 0) {
|
||||
date.era = date.era === 'BCE' ? 'CE' : 'BCE';
|
||||
date.year = 1 - date.year;
|
||||
}
|
||||
}
|
||||
|
||||
getEras(): string[] {
|
||||
return ['BCE', 'CE'];
|
||||
}
|
||||
|
||||
getYearsInEra(date: AnyCalendarDate): number {
|
||||
// 9999-12-30 gregorian is 9716-02-20 coptic.
|
||||
// Round down to 9715 for the last full year.
|
||||
// BCE 9999-01-01 coptic is BC 9716-06-15 gregorian.
|
||||
return date.era === 'BCE' ? 9999 : 9715;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,150 @@
|
||||
/*
|
||||
* Copyright 2020 Adobe. All rights reserved.
|
||||
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License. You may obtain a copy
|
||||
* of the License at http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software distributed under
|
||||
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
|
||||
* OF ANY KIND, either express or implied. See the License for the specific language
|
||||
* governing permissions and limitations under the License.
|
||||
*/
|
||||
|
||||
// Portions of the code in this file are based on code from ICU.
|
||||
// Original licensing can be found in the NOTICE file in the root directory of this source tree.
|
||||
|
||||
import type { AnyCalendarDate, Calendar, CalendarIdentifier } from '../core/types';
|
||||
import { CalendarDate } from '../core/CalendarDate';
|
||||
import { mod } from '$lib/util/numbers';
|
||||
import type { Mutable } from '$lib/util/objs';
|
||||
|
||||
const EPOCH = 1721426; // 001/01/03 Julian C.E.
|
||||
export function gregorianToJulianDay(
|
||||
era: string,
|
||||
year: number,
|
||||
month: number,
|
||||
day: number
|
||||
): number {
|
||||
year = getExtendedYear(era, year);
|
||||
|
||||
let y1 = year - 1;
|
||||
let monthOffset = -2;
|
||||
if (month <= 2) {
|
||||
monthOffset = 0;
|
||||
} else if (isLeapYear(year)) {
|
||||
monthOffset = -1;
|
||||
}
|
||||
|
||||
return (
|
||||
EPOCH -
|
||||
1 +
|
||||
365 * y1 +
|
||||
Math.floor(y1 / 4) -
|
||||
Math.floor(y1 / 100) +
|
||||
Math.floor(y1 / 400) +
|
||||
Math.floor((367 * month - 362) / 12 + monthOffset + day)
|
||||
);
|
||||
}
|
||||
|
||||
export function isLeapYear(year: number): boolean {
|
||||
return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
|
||||
}
|
||||
|
||||
export function getExtendedYear(era: string, year: number): number {
|
||||
return era === 'BC' ? 1 - year : year;
|
||||
}
|
||||
|
||||
export function fromExtendedYear(year: number): [string, number] {
|
||||
let era = 'AD';
|
||||
if (year <= 0) {
|
||||
era = 'BC';
|
||||
year = 1 - year;
|
||||
}
|
||||
|
||||
return [era, year];
|
||||
}
|
||||
|
||||
const daysInMonth = {
|
||||
standard: [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31],
|
||||
leapyear: [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
|
||||
};
|
||||
|
||||
/**
|
||||
* The Gregorian calendar is the most commonly used calendar system in the world. It supports two eras: BC, and AD.
|
||||
* Years always contain 12 months, and 365 or 366 days depending on whether it is a leap year.
|
||||
*/
|
||||
export class GregorianCalendar implements Calendar {
|
||||
identifier: CalendarIdentifier = 'gregory';
|
||||
|
||||
fromJulianDay(jd: number): CalendarDate {
|
||||
let jd0 = jd;
|
||||
let depoch = jd0 - EPOCH;
|
||||
let quadricent = Math.floor(depoch / 146097);
|
||||
let dqc = mod(depoch, 146097);
|
||||
let cent = Math.floor(dqc / 36524);
|
||||
let dcent = mod(dqc, 36524);
|
||||
let quad = Math.floor(dcent / 1461);
|
||||
let dquad = mod(dcent, 1461);
|
||||
let yindex = Math.floor(dquad / 365);
|
||||
|
||||
let extendedYear =
|
||||
quadricent * 400 + cent * 100 + quad * 4 + yindex + (cent !== 4 && yindex !== 4 ? 1 : 0);
|
||||
let [era, year] = fromExtendedYear(extendedYear);
|
||||
let yearDay = jd0 - gregorianToJulianDay(era, year, 1, 1);
|
||||
let leapAdj = 2;
|
||||
if (jd0 < gregorianToJulianDay(era, year, 3, 1)) {
|
||||
leapAdj = 0;
|
||||
} else if (isLeapYear(year)) {
|
||||
leapAdj = 1;
|
||||
}
|
||||
let month = Math.floor(((yearDay + leapAdj) * 12 + 373) / 367);
|
||||
let day = jd0 - gregorianToJulianDay(era, year, month, 1) + 1;
|
||||
|
||||
return new CalendarDate(era, year, month, day);
|
||||
}
|
||||
|
||||
toJulianDay(date: AnyCalendarDate): number {
|
||||
return gregorianToJulianDay(date.era, date.year, date.month, date.day);
|
||||
}
|
||||
|
||||
getDaysInMonth(date: AnyCalendarDate): number {
|
||||
return daysInMonth[isLeapYear(date.year) ? 'leapyear' : 'standard'][date.month - 1];
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
getMonthsInYear(date: AnyCalendarDate): number {
|
||||
return 12;
|
||||
}
|
||||
|
||||
getDaysInYear(date: AnyCalendarDate): number {
|
||||
return isLeapYear(date.year) ? 366 : 365;
|
||||
}
|
||||
|
||||
getMaximumMonthsInYear(): number {
|
||||
return 12;
|
||||
}
|
||||
|
||||
getMaximumDaysInMonth(): number {
|
||||
return 31;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
getYearsInEra(date: AnyCalendarDate): number {
|
||||
return 9999;
|
||||
}
|
||||
|
||||
getEras(): string[] {
|
||||
return ['BC', 'AD'];
|
||||
}
|
||||
|
||||
isInverseEra(date: AnyCalendarDate): boolean {
|
||||
return date.era === 'BC';
|
||||
}
|
||||
|
||||
balanceDate(date: Mutable<AnyCalendarDate>): void {
|
||||
if (date.year <= 0) {
|
||||
date.era = date.era === 'BC' ? 'AD' : 'BC';
|
||||
date.year = 1 - date.year;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,215 @@
|
||||
/*
|
||||
* Copyright 2020 Adobe. All rights reserved.
|
||||
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License. You may obtain a copy
|
||||
* of the License at http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software distributed under
|
||||
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
|
||||
* OF ANY KIND, either express or implied. See the License for the specific language
|
||||
* governing permissions and limitations under the License.
|
||||
*/
|
||||
|
||||
// Portions of the code in this file are based on code from ICU.
|
||||
// Original licensing can be found in the NOTICE file in the root directory of this source tree.
|
||||
|
||||
import type { AnyCalendarDate, Calendar, CalendarIdentifier } from '../core/types';
|
||||
import { CalendarDate } from '../core/CalendarDate';
|
||||
import { mod } from '$lib/util/numbers';
|
||||
import type { Mutable } from '$lib/util/objs';
|
||||
|
||||
const HEBREW_EPOCH = 347997;
|
||||
|
||||
// Hebrew date calculations are performed in terms of days, hours, and
|
||||
// "parts" (or halakim), which are 1/1080 of an hour, or 3 1/3 seconds.
|
||||
const HOUR_PARTS = 1080;
|
||||
const DAY_PARTS = 24 * HOUR_PARTS;
|
||||
|
||||
// An approximate value for the length of a lunar month.
|
||||
// It is used to calculate the approximate year and month of a given
|
||||
// absolute date.
|
||||
const MONTH_DAYS = 29;
|
||||
const MONTH_FRACT = 12 * HOUR_PARTS + 793;
|
||||
const MONTH_PARTS = MONTH_DAYS * DAY_PARTS + MONTH_FRACT;
|
||||
|
||||
function isLeapYear(year: number) {
|
||||
return mod(year * 7 + 1, 19) < 7;
|
||||
}
|
||||
|
||||
// Test for delay of start of new year and to avoid
|
||||
// Sunday, Wednesday, and Friday as start of the new year.
|
||||
function hebrewDelay1(year: number) {
|
||||
let months = Math.floor((235 * year - 234) / 19);
|
||||
let parts = 12084 + 13753 * months;
|
||||
let day = months * 29 + Math.floor(parts / 25920);
|
||||
|
||||
if (mod(3 * (day + 1), 7) < 3) {
|
||||
day += 1;
|
||||
}
|
||||
|
||||
return day;
|
||||
}
|
||||
|
||||
// Check for delay in start of new year due to length of adjacent years
|
||||
function hebrewDelay2(year: number) {
|
||||
let last = hebrewDelay1(year - 1);
|
||||
let present = hebrewDelay1(year);
|
||||
let next = hebrewDelay1(year + 1);
|
||||
|
||||
if (next - present === 356) {
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (present - last === 382) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function startOfYear(year: number) {
|
||||
return hebrewDelay1(year) + hebrewDelay2(year);
|
||||
}
|
||||
|
||||
function getDaysInYear(year: number) {
|
||||
return startOfYear(year + 1) - startOfYear(year);
|
||||
}
|
||||
|
||||
function getYearType(year: number) {
|
||||
let yearLength = getDaysInYear(year);
|
||||
|
||||
if (yearLength > 380) {
|
||||
yearLength -= 30; // Subtract length of leap month.
|
||||
}
|
||||
|
||||
switch (yearLength) {
|
||||
case 353:
|
||||
return 0; // deficient
|
||||
case 354:
|
||||
return 1; // normal
|
||||
case 355:
|
||||
return 2; // complete
|
||||
}
|
||||
}
|
||||
|
||||
function getDaysInMonth(year: number, month: number): number {
|
||||
// Normalize month numbers from 1 - 13, even on non-leap years
|
||||
if (month >= 6 && !isLeapYear(year)) {
|
||||
month++;
|
||||
}
|
||||
|
||||
// First of all, dispose of fixed-length 29 day months
|
||||
if (month === 4 || month === 7 || month === 9 || month === 11 || month === 13) {
|
||||
return 29;
|
||||
}
|
||||
|
||||
let yearType = getYearType(year);
|
||||
|
||||
// If it's Heshvan, days depend on length of year
|
||||
if (month === 2) {
|
||||
return yearType === 2 ? 30 : 29;
|
||||
}
|
||||
|
||||
// Similarly, Kislev varies with the length of year
|
||||
if (month === 3) {
|
||||
return yearType === 0 ? 29 : 30;
|
||||
}
|
||||
|
||||
// Adar I only exists in leap years
|
||||
if (month === 6) {
|
||||
return isLeapYear(year) ? 30 : 0;
|
||||
}
|
||||
|
||||
return 30;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Hebrew calendar is used in Israel and around the world by the Jewish faith.
|
||||
* Years include either 12 or 13 months depending on whether it is a leap year.
|
||||
* In leap years, an extra month is inserted at month 6.
|
||||
*/
|
||||
export class HebrewCalendar implements Calendar {
|
||||
identifier: CalendarIdentifier = 'hebrew';
|
||||
|
||||
fromJulianDay(jd: number): CalendarDate {
|
||||
let d = jd - HEBREW_EPOCH;
|
||||
let m = (d * DAY_PARTS) / MONTH_PARTS; // Months (approx)
|
||||
let year = Math.floor((19 * m + 234) / 235) + 1; // Years (approx)
|
||||
let ys = startOfYear(year); // 1st day of year
|
||||
let dayOfYear = Math.floor(d - ys);
|
||||
|
||||
// Because of the postponement rules, it's possible to guess wrong. Fix it.
|
||||
while (dayOfYear < 1) {
|
||||
year--;
|
||||
ys = startOfYear(year);
|
||||
dayOfYear = Math.floor(d - ys);
|
||||
}
|
||||
|
||||
// Now figure out which month we're in, and the date within that month
|
||||
let month = 1;
|
||||
let monthStart = 0;
|
||||
while (monthStart < dayOfYear) {
|
||||
monthStart += getDaysInMonth(year, month);
|
||||
month++;
|
||||
}
|
||||
|
||||
month--;
|
||||
monthStart -= getDaysInMonth(year, month);
|
||||
|
||||
let day = dayOfYear - monthStart;
|
||||
return new CalendarDate(this, year, month, day);
|
||||
}
|
||||
|
||||
toJulianDay(date: AnyCalendarDate): number {
|
||||
let jd = startOfYear(date.year);
|
||||
for (let month = 1; month < date.month; month++) {
|
||||
jd += getDaysInMonth(date.year, month);
|
||||
}
|
||||
|
||||
return jd + date.day + HEBREW_EPOCH;
|
||||
}
|
||||
|
||||
getDaysInMonth(date: AnyCalendarDate): number {
|
||||
return getDaysInMonth(date.year, date.month);
|
||||
}
|
||||
|
||||
getMonthsInYear(date: AnyCalendarDate): number {
|
||||
return isLeapYear(date.year) ? 13 : 12;
|
||||
}
|
||||
|
||||
getDaysInYear(date: AnyCalendarDate): number {
|
||||
return getDaysInYear(date.year);
|
||||
}
|
||||
|
||||
getMaximumMonthsInYear(): number {
|
||||
return 13;
|
||||
}
|
||||
|
||||
getMaximumDaysInMonth(): number {
|
||||
return 30;
|
||||
}
|
||||
|
||||
getYearsInEra(): number {
|
||||
// 6239 gregorian
|
||||
return 9999;
|
||||
}
|
||||
|
||||
getEras(): string[] {
|
||||
return ['AM'];
|
||||
}
|
||||
|
||||
balanceYearMonth(date: Mutable<AnyCalendarDate>, previousDate: AnyCalendarDate): void {
|
||||
// Keep date in the same month when switching between leap years and non leap years
|
||||
if (previousDate.year !== date.year) {
|
||||
if (isLeapYear(previousDate.year) && !isLeapYear(date.year) && previousDate.month > 6) {
|
||||
date.month--;
|
||||
} else if (
|
||||
!isLeapYear(previousDate.year) &&
|
||||
isLeapYear(date.year) &&
|
||||
previousDate.month > 6
|
||||
) {
|
||||
date.month++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,134 @@
|
||||
/*
|
||||
* Copyright 2020 Adobe. All rights reserved.
|
||||
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License. You may obtain a copy
|
||||
* of the License at http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software distributed under
|
||||
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
|
||||
* OF ANY KIND, either express or implied. See the License for the specific language
|
||||
* governing permissions and limitations under the License.
|
||||
*/
|
||||
|
||||
// Portions of the code in this file are based on code from ICU.
|
||||
// Original licensing can be found in the NOTICE file in the root directory of this source tree.
|
||||
|
||||
import type { AnyCalendarDate, CalendarIdentifier } from '../core/types';
|
||||
import { CalendarDate } from '../core/CalendarDate';
|
||||
import {
|
||||
fromExtendedYear,
|
||||
GregorianCalendar,
|
||||
gregorianToJulianDay,
|
||||
isLeapYear
|
||||
} from './GregorianCalendar';
|
||||
|
||||
// Starts in 78 AD,
|
||||
const INDIAN_ERA_START = 78;
|
||||
|
||||
// The Indian year starts 80 days later than the Gregorian year.
|
||||
const INDIAN_YEAR_START = 80;
|
||||
|
||||
/**
|
||||
* The Indian National Calendar is similar to the Gregorian calendar, but with
|
||||
* years numbered since the Saka era in 78 AD (Gregorian). There are 12 months
|
||||
* in each year, with either 30 or 31 days. Only one era identifier is supported: 'saka'.
|
||||
*/
|
||||
export class IndianCalendar extends GregorianCalendar {
|
||||
identifier: CalendarIdentifier = 'indian';
|
||||
|
||||
fromJulianDay(jd: number): CalendarDate {
|
||||
// Gregorian date for Julian day
|
||||
let date = super.fromJulianDay(jd);
|
||||
|
||||
// Year in Saka era
|
||||
let indianYear = date.year - INDIAN_ERA_START;
|
||||
|
||||
// Day number in Gregorian year (starting from 0)
|
||||
let yDay = jd - gregorianToJulianDay(date.era, date.year, 1, 1);
|
||||
|
||||
let leapMonth: number;
|
||||
if (yDay < INDIAN_YEAR_START) {
|
||||
// Day is at the end of the preceding Saka year
|
||||
indianYear--;
|
||||
|
||||
// Days in leapMonth this year, previous Gregorian year
|
||||
leapMonth = isLeapYear(date.year - 1) ? 31 : 30;
|
||||
yDay += leapMonth + 31 * 5 + 30 * 3 + 10;
|
||||
} else {
|
||||
// Days in leapMonth this year
|
||||
leapMonth = isLeapYear(date.year) ? 31 : 30;
|
||||
yDay -= INDIAN_YEAR_START;
|
||||
}
|
||||
|
||||
let indianMonth: number;
|
||||
let indianDay: number;
|
||||
if (yDay < leapMonth) {
|
||||
indianMonth = 1;
|
||||
indianDay = yDay + 1;
|
||||
} else {
|
||||
let mDay = yDay - leapMonth;
|
||||
if (mDay < 31 * 5) {
|
||||
indianMonth = Math.floor(mDay / 31) + 2;
|
||||
indianDay = (mDay % 31) + 1;
|
||||
} else {
|
||||
mDay -= 31 * 5;
|
||||
indianMonth = Math.floor(mDay / 30) + 7;
|
||||
indianDay = (mDay % 30) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return new CalendarDate(this, indianYear, indianMonth, indianDay);
|
||||
}
|
||||
|
||||
toJulianDay(date: AnyCalendarDate): number {
|
||||
let extendedYear = date.year + INDIAN_ERA_START;
|
||||
let [era, year] = fromExtendedYear(extendedYear);
|
||||
|
||||
let leapMonth: number;
|
||||
let jd: number;
|
||||
if (isLeapYear(year)) {
|
||||
leapMonth = 31;
|
||||
jd = gregorianToJulianDay(era, year, 3, 21);
|
||||
} else {
|
||||
leapMonth = 30;
|
||||
jd = gregorianToJulianDay(era, year, 3, 22);
|
||||
}
|
||||
|
||||
if (date.month === 1) {
|
||||
return jd + date.day - 1;
|
||||
}
|
||||
|
||||
jd += leapMonth + Math.min(date.month - 2, 5) * 31;
|
||||
|
||||
if (date.month >= 8) {
|
||||
jd += (date.month - 7) * 30;
|
||||
}
|
||||
|
||||
jd += date.day - 1;
|
||||
return jd;
|
||||
}
|
||||
|
||||
getDaysInMonth(date: AnyCalendarDate): number {
|
||||
if (date.month === 1 && isLeapYear(date.year + INDIAN_ERA_START)) {
|
||||
return 31;
|
||||
}
|
||||
|
||||
if (date.month >= 2 && date.month <= 6) {
|
||||
return 31;
|
||||
}
|
||||
|
||||
return 30;
|
||||
}
|
||||
|
||||
getYearsInEra(): number {
|
||||
// 9999-12-31 gregorian is 9920-10-10 indian.
|
||||
// Round down to 9919 for the last full year.
|
||||
return 9919;
|
||||
}
|
||||
|
||||
getEras(): string[] {
|
||||
return ['saka'];
|
||||
}
|
||||
|
||||
balanceDate(): void {}
|
||||
}
|
||||
@ -0,0 +1,245 @@
|
||||
/*
|
||||
* Copyright 2020 Adobe. All rights reserved.
|
||||
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License. You may obtain a copy
|
||||
* of the License at http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software distributed under
|
||||
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
|
||||
* OF ANY KIND, either express or implied. See the License for the specific language
|
||||
* governing permissions and limitations under the License.
|
||||
*/
|
||||
|
||||
// Portions of the code in this file are based on code from ICU.
|
||||
// Original licensing can be found in the NOTICE file in the root directory of this source tree.
|
||||
|
||||
import type { AnyCalendarDate, Calendar, CalendarIdentifier } from '../core/types';
|
||||
import { CalendarDate } from '../core/CalendarDate';
|
||||
|
||||
const CIVIL_EPOC = 1948440; // CE 622 July 16 Friday (Julian calendar) / CE 622 July 19 (Gregorian calendar)
|
||||
const ASTRONOMICAL_EPOC = 1948439; // CE 622 July 15 Thursday (Julian calendar)
|
||||
const UMALQURA_YEAR_START = 1300;
|
||||
const UMALQURA_YEAR_END = 1600;
|
||||
const UMALQURA_START_DAYS = 460322;
|
||||
|
||||
function islamicToJulianDay(epoch: number, year: number, month: number, day: number): number {
|
||||
return (
|
||||
day +
|
||||
Math.ceil(29.5 * (month - 1)) +
|
||||
(year - 1) * 354 +
|
||||
Math.floor((3 + 11 * year) / 30) +
|
||||
epoch -
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
function julianDayToIslamic(calendar: Calendar, epoch: number, jd: number) {
|
||||
let year = Math.floor((30 * (jd - epoch) + 10646) / 10631);
|
||||
let month = Math.min(
|
||||
12,
|
||||
Math.ceil((jd - (29 + islamicToJulianDay(epoch, year, 1, 1))) / 29.5) + 1
|
||||
);
|
||||
let day = jd - islamicToJulianDay(epoch, year, month, 1) + 1;
|
||||
|
||||
return new CalendarDate(calendar, year, month, day);
|
||||
}
|
||||
|
||||
function isLeapYear(year: number): boolean {
|
||||
return (14 + 11 * year) % 30 < 11;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Islamic calendar, also known as the "Hijri" calendar, is used throughout much of the Arab world.
|
||||
* The civil variant uses simple arithmetic rules rather than astronomical calculations to approximate
|
||||
* the traditional calendar, which is based on sighting of the crescent moon. It uses Friday, July 16 622 CE (Julian) as the epoch.
|
||||
* Each year has 12 months, with either 354 or 355 days depending on whether it is a leap year.
|
||||
* Learn more about the available Islamic calendars [here](https://cldr.unicode.org/development/development-process/design-proposals/islamic-calendar-types).
|
||||
*/
|
||||
export class IslamicCivilCalendar implements Calendar {
|
||||
identifier: CalendarIdentifier = 'islamic-civil';
|
||||
|
||||
fromJulianDay(jd: number): CalendarDate {
|
||||
return julianDayToIslamic(this, CIVIL_EPOC, jd);
|
||||
}
|
||||
|
||||
toJulianDay(date: AnyCalendarDate): number {
|
||||
return islamicToJulianDay(CIVIL_EPOC, date.year, date.month, date.day);
|
||||
}
|
||||
|
||||
getDaysInMonth(date: AnyCalendarDate): number {
|
||||
let length = 29 + (date.month % 2);
|
||||
if (date.month === 12 && isLeapYear(date.year)) {
|
||||
length++;
|
||||
}
|
||||
|
||||
return length;
|
||||
}
|
||||
|
||||
getMonthsInYear(): number {
|
||||
return 12;
|
||||
}
|
||||
|
||||
getDaysInYear(date: AnyCalendarDate): number {
|
||||
return isLeapYear(date.year) ? 355 : 354;
|
||||
}
|
||||
|
||||
getMaximumMonthsInYear(): number {
|
||||
return 12;
|
||||
}
|
||||
|
||||
getMaximumDaysInMonth(): number {
|
||||
return 30;
|
||||
}
|
||||
|
||||
getYearsInEra(): number {
|
||||
// 9999 gregorian
|
||||
return 9665;
|
||||
}
|
||||
|
||||
getEras(): string[] {
|
||||
return ['AH'];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Islamic calendar, also known as the "Hijri" calendar, is used throughout much of the Arab world.
|
||||
* The tabular variant uses simple arithmetic rules rather than astronomical calculations to approximate
|
||||
* the traditional calendar, which is based on sighting of the crescent moon. It uses Thursday, July 15 622 CE (Julian) as the epoch.
|
||||
* Each year has 12 months, with either 354 or 355 days depending on whether it is a leap year.
|
||||
* Learn more about the available Islamic calendars [here](https://cldr.unicode.org/development/development-process/design-proposals/islamic-calendar-types).
|
||||
*/
|
||||
export class IslamicTabularCalendar extends IslamicCivilCalendar {
|
||||
identifier: CalendarIdentifier = 'islamic-tbla';
|
||||
|
||||
fromJulianDay(jd: number): CalendarDate {
|
||||
return julianDayToIslamic(this, ASTRONOMICAL_EPOC, jd);
|
||||
}
|
||||
|
||||
toJulianDay(date: AnyCalendarDate): number {
|
||||
return islamicToJulianDay(ASTRONOMICAL_EPOC, date.year, date.month, date.day);
|
||||
}
|
||||
}
|
||||
|
||||
// Generated by scripts/generate-umalqura.js
|
||||
const UMALQURA_DATA =
|
||||
'qgpUDckO1AbqBmwDrQpVBakGkgepC9QF2gpcBS0NlQZKB1QLagutBa4ETwoXBYsGpQbVCtYCWwmdBE0KJg2VDawFtgm6AlsKKwWVCsoG6Qr0AnYJtgJWCcoKpAvSC9kF3AJtCU0FpQpSC6ULtAW2CVcFlwJLBaMGUgdlC2oFqworBZUMSg2lDcoF1gpXCasESwmlClILagt1BXYCtwhbBFUFqQW0BdoJ3QRuAjYJqgpUDbIN1QXaAlsJqwRVCkkLZAtxC7QFtQpVCiUNkg7JDtQG6QprCasEkwpJDaQNsg25CroEWworBZUKKgtVC1wFvQQ9Ah0JlQpKC1oLbQW2AjsJmwRVBqkGVAdqC2wFrQpVBSkLkgupC9QF2gpaBasKlQVJB2QHqgu1BbYCVgpNDiULUgtqC60FrgIvCZcESwalBqwG1gpdBZ0ETQoWDZUNqgW1BdoCWwmtBJUFygbkBuoK9QS2AlYJqgpUC9IL2QXqAm0JrQSVCkoLpQuyBbUJ1gSXCkcFkwZJB1ULagVrCisFiwpGDaMNygXWCtsEawJLCaUKUgtpC3UFdgG3CFsCKwVlBbQF2gntBG0BtgimClINqQ3UBdoKWwmrBFMGKQdiB6kLsgW1ClUFJQuSDckO0gbpCmsFqwRVCikNVA2qDbUJugQ7CpsETQqqCtUK2gJdCV4ELgqaDFUNsga5BroEXQotBZUKUguoC7QLuQXaAloJSgukDdEO6AZqC20FNQWVBkoNqA3UDdoGWwWdAisGFQtKC5ULqgWuCi4JjwwnBZUGqgbWCl0FnQI=';
|
||||
let UMALQURA_MONTHLENGTH: Uint16Array;
|
||||
let UMALQURA_YEAR_START_TABLE: Uint32Array;
|
||||
|
||||
function umalquraYearStart(year: number): number {
|
||||
return UMALQURA_START_DAYS + UMALQURA_YEAR_START_TABLE[year - UMALQURA_YEAR_START];
|
||||
}
|
||||
|
||||
function umalquraMonthLength(year: number, month: number): number {
|
||||
let idx = year - UMALQURA_YEAR_START;
|
||||
let mask = 0x01 << (11 - (month - 1));
|
||||
if ((UMALQURA_MONTHLENGTH[idx] & mask) === 0) {
|
||||
return 29;
|
||||
} else {
|
||||
return 30;
|
||||
}
|
||||
}
|
||||
|
||||
function umalquraMonthStart(year: number, month: number): number {
|
||||
let day = umalquraYearStart(year);
|
||||
for (let i = 1; i < month; i++) {
|
||||
day += umalquraMonthLength(year, i);
|
||||
}
|
||||
return day;
|
||||
}
|
||||
|
||||
function umalquraYearLength(year: number): number {
|
||||
return (
|
||||
UMALQURA_YEAR_START_TABLE[year + 1 - UMALQURA_YEAR_START] -
|
||||
UMALQURA_YEAR_START_TABLE[year - UMALQURA_YEAR_START]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Islamic calendar, also known as the "Hijri" calendar, is used throughout much of the Arab world.
|
||||
* The Umalqura variant is primarily used in Saudi Arabia. It is a lunar calendar, based on astronomical
|
||||
* calculations that predict the sighting of a crescent moon. Month and year lengths vary between years
|
||||
* depending on these calculations.
|
||||
* Learn more about the available Islamic calendars [here](https://cldr.unicode.org/development/development-process/design-proposals/islamic-calendar-types).
|
||||
*/
|
||||
export class IslamicUmalquraCalendar extends IslamicCivilCalendar {
|
||||
identifier: CalendarIdentifier = 'islamic-umalqura';
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
if (!UMALQURA_MONTHLENGTH) {
|
||||
UMALQURA_MONTHLENGTH = new Uint16Array(
|
||||
Uint8Array.from(atob(UMALQURA_DATA), (c) => c.charCodeAt(0)).buffer
|
||||
);
|
||||
}
|
||||
|
||||
if (!UMALQURA_YEAR_START_TABLE) {
|
||||
UMALQURA_YEAR_START_TABLE = new Uint32Array(UMALQURA_YEAR_END - UMALQURA_YEAR_START + 1);
|
||||
|
||||
let yearStart = 0;
|
||||
for (let year = UMALQURA_YEAR_START; year <= UMALQURA_YEAR_END; year++) {
|
||||
UMALQURA_YEAR_START_TABLE[year - UMALQURA_YEAR_START] = yearStart;
|
||||
for (let i = 1; i <= 12; i++) {
|
||||
yearStart += umalquraMonthLength(year, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fromJulianDay(jd: number): CalendarDate {
|
||||
let days = jd - CIVIL_EPOC;
|
||||
let startDays = umalquraYearStart(UMALQURA_YEAR_START);
|
||||
let endDays = umalquraYearStart(UMALQURA_YEAR_END);
|
||||
if (days < startDays || days > endDays) {
|
||||
return super.fromJulianDay(jd);
|
||||
} else {
|
||||
let y = UMALQURA_YEAR_START - 1;
|
||||
let m = 1;
|
||||
let d = 1;
|
||||
while (d > 0) {
|
||||
y++;
|
||||
d = days - umalquraYearStart(y) + 1;
|
||||
let yearLength = umalquraYearLength(y);
|
||||
if (d === yearLength) {
|
||||
m = 12;
|
||||
break;
|
||||
} else if (d < yearLength) {
|
||||
let monthLength = umalquraMonthLength(y, m);
|
||||
m = 1;
|
||||
while (d > monthLength) {
|
||||
d -= monthLength;
|
||||
m++;
|
||||
monthLength = umalquraMonthLength(y, m);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return new CalendarDate(this, y, m, days - umalquraMonthStart(y, m) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
toJulianDay(date: AnyCalendarDate): number {
|
||||
if (date.year < UMALQURA_YEAR_START || date.year > UMALQURA_YEAR_END) {
|
||||
return super.toJulianDay(date);
|
||||
}
|
||||
|
||||
return CIVIL_EPOC + umalquraMonthStart(date.year, date.month) + (date.day - 1);
|
||||
}
|
||||
|
||||
getDaysInMonth(date: AnyCalendarDate): number {
|
||||
if (date.year < UMALQURA_YEAR_START || date.year > UMALQURA_YEAR_END) {
|
||||
return super.getDaysInMonth(date);
|
||||
}
|
||||
|
||||
return umalquraMonthLength(date.year, date.month);
|
||||
}
|
||||
|
||||
getDaysInYear(date: AnyCalendarDate): number {
|
||||
if (date.year < UMALQURA_YEAR_START || date.year > UMALQURA_YEAR_END) {
|
||||
return super.getDaysInYear(date);
|
||||
}
|
||||
|
||||
return umalquraYearLength(date.year);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,184 @@
|
||||
/*
|
||||
* Copyright 2020 Adobe. All rights reserved.
|
||||
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License. You may obtain a copy
|
||||
* of the License at http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software distributed under
|
||||
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
|
||||
* OF ANY KIND, either express or implied. See the License for the specific language
|
||||
* governing permissions and limitations under the License.
|
||||
*/
|
||||
|
||||
// Portions of the code in this file are based on code from the TC39 Temporal proposal.
|
||||
// Original licensing can be found in the NOTICE file in the root directory of this source tree.
|
||||
|
||||
import type { AnyCalendarDate, CalendarIdentifier } from '../core/types';
|
||||
import { CalendarDate } from '../core/CalendarDate';
|
||||
import { GregorianCalendar } from './GregorianCalendar';
|
||||
import type { Mutable } from '$lib/util/objs';
|
||||
|
||||
const ERA_START_DATES = [
|
||||
[1868, 9, 8],
|
||||
[1912, 7, 30],
|
||||
[1926, 12, 25],
|
||||
[1989, 1, 8],
|
||||
[2019, 5, 1]
|
||||
];
|
||||
const ERA_END_DATES = [
|
||||
[1912, 7, 29],
|
||||
[1926, 12, 24],
|
||||
[1989, 1, 7],
|
||||
[2019, 4, 30]
|
||||
];
|
||||
const ERA_ADDENDS = [1867, 1911, 1925, 1988, 2018];
|
||||
const ERA_NAMES = ['meiji', 'taisho', 'showa', 'heisei', 'reiwa'];
|
||||
|
||||
function findEraFromGregorianDate(date: AnyCalendarDate) {
|
||||
const idx = ERA_START_DATES.findIndex(([year, month, day]) => {
|
||||
if (date.year < year) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (date.year === year && date.month < month) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (date.year === year && date.month === month && date.day < day) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
if (idx === -1) {
|
||||
return ERA_START_DATES.length - 1;
|
||||
}
|
||||
|
||||
if (idx === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return idx - 1;
|
||||
}
|
||||
|
||||
function toGregorian(date: AnyCalendarDate) {
|
||||
let eraAddend = ERA_ADDENDS[ERA_NAMES.indexOf(date.era)];
|
||||
if (!eraAddend) {
|
||||
throw new Error('Unknown era: ' + date.era);
|
||||
}
|
||||
|
||||
return new CalendarDate(date.year + eraAddend, date.month, date.day);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Japanese calendar is based on the Gregorian calendar, but with eras for the reign of each Japanese emperor.
|
||||
* Whenever a new emperor ascends to the throne, a new era begins and the year starts again from 1.
|
||||
* Note that eras before 1868 (Gregorian) are not currently supported by this implementation.
|
||||
*/
|
||||
export class JapaneseCalendar extends GregorianCalendar {
|
||||
identifier: CalendarIdentifier = 'japanese';
|
||||
|
||||
fromJulianDay(jd: number): CalendarDate {
|
||||
let date = super.fromJulianDay(jd);
|
||||
let era = findEraFromGregorianDate(date);
|
||||
|
||||
return new CalendarDate(
|
||||
this,
|
||||
ERA_NAMES[era],
|
||||
date.year - ERA_ADDENDS[era],
|
||||
date.month,
|
||||
date.day
|
||||
);
|
||||
}
|
||||
|
||||
toJulianDay(date: AnyCalendarDate): number {
|
||||
return super.toJulianDay(toGregorian(date));
|
||||
}
|
||||
|
||||
balanceDate(date: Mutable<AnyCalendarDate>): void {
|
||||
let gregorianDate = toGregorian(date);
|
||||
let era = findEraFromGregorianDate(gregorianDate);
|
||||
|
||||
if (ERA_NAMES[era] !== date.era) {
|
||||
date.era = ERA_NAMES[era];
|
||||
date.year = gregorianDate.year - ERA_ADDENDS[era];
|
||||
}
|
||||
|
||||
// Constrain in case we went before the first supported era.
|
||||
this.constrainDate(date);
|
||||
}
|
||||
|
||||
constrainDate(date: Mutable<AnyCalendarDate>): void {
|
||||
let idx = ERA_NAMES.indexOf(date.era);
|
||||
let end = ERA_END_DATES[idx];
|
||||
if (end != null) {
|
||||
let [endYear, endMonth, endDay] = end;
|
||||
|
||||
// Constrain the year to the maximum possible value in the era.
|
||||
// Then constrain the month and day fields within that.
|
||||
let maxYear = endYear - ERA_ADDENDS[idx];
|
||||
date.year = Math.max(1, Math.min(maxYear, date.year));
|
||||
if (date.year === maxYear) {
|
||||
date.month = Math.min(endMonth, date.month);
|
||||
|
||||
if (date.month === endMonth) {
|
||||
date.day = Math.min(endDay, date.day);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (date.year === 1 && idx >= 0) {
|
||||
let [, startMonth, startDay] = ERA_START_DATES[idx];
|
||||
date.month = Math.max(startMonth, date.month);
|
||||
|
||||
if (date.month === startMonth) {
|
||||
date.day = Math.max(startDay, date.day);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getEras(): string[] {
|
||||
return ERA_NAMES;
|
||||
}
|
||||
|
||||
getYearsInEra(date: AnyCalendarDate): number {
|
||||
// Get the number of years in the era, taking into account the date's month and day fields.
|
||||
let era = ERA_NAMES.indexOf(date.era);
|
||||
let cur = ERA_START_DATES[era];
|
||||
let next = ERA_START_DATES[era + 1];
|
||||
if (next == null) {
|
||||
// 9999 gregorian is the maximum year allowed.
|
||||
return 9999 - cur[0] + 1;
|
||||
}
|
||||
|
||||
let years = next[0] - cur[0];
|
||||
|
||||
if (date.month < next[1] || (date.month === next[1] && date.day < next[2])) {
|
||||
years++;
|
||||
}
|
||||
|
||||
return years;
|
||||
}
|
||||
|
||||
getDaysInMonth(date: AnyCalendarDate): number {
|
||||
return super.getDaysInMonth(toGregorian(date));
|
||||
}
|
||||
|
||||
getMinimumMonthInYear(date: AnyCalendarDate): number {
|
||||
let start = getMinimums(date);
|
||||
return start ? start[1] : 1;
|
||||
}
|
||||
|
||||
getMinimumDayInMonth(date: AnyCalendarDate): number {
|
||||
let start = getMinimums(date);
|
||||
return start && date.month === start[1] ? start[2] : 1;
|
||||
}
|
||||
}
|
||||
|
||||
function getMinimums(date: AnyCalendarDate) {
|
||||
if (date.year === 1) {
|
||||
let idx = ERA_NAMES.indexOf(date.era);
|
||||
return ERA_START_DATES[idx];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,98 @@
|
||||
/*
|
||||
* Copyright 2020 Adobe. All rights reserved.
|
||||
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License. You may obtain a copy
|
||||
* of the License at http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software distributed under
|
||||
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
|
||||
* OF ANY KIND, either express or implied. See the License for the specific language
|
||||
* governing permissions and limitations under the License.
|
||||
*/
|
||||
|
||||
// Portions of the code in this file are based on code from ICU.
|
||||
// Original licensing can be found in the NOTICE file in the root directory of this source tree.
|
||||
|
||||
import type { AnyCalendarDate, Calendar, CalendarIdentifier } from '../core/types';
|
||||
import { CalendarDate } from '../core/CalendarDate';
|
||||
import { mod } from '$lib/util/numbers';
|
||||
|
||||
const PERSIAN_EPOCH = 1948320;
|
||||
|
||||
// Number of days from the start of the year to the start of each month.
|
||||
const MONTH_START = [
|
||||
0, // Farvardin
|
||||
31, // Ordibehesht
|
||||
62, // Khordad
|
||||
93, // Tir
|
||||
124, // Mordad
|
||||
155, // Shahrivar
|
||||
186, // Mehr
|
||||
216, // Aban
|
||||
246, // Azar
|
||||
276, // Dey
|
||||
306, // Bahman
|
||||
336 // Esfand
|
||||
];
|
||||
|
||||
/**
|
||||
* The Persian calendar is the main calendar used in Iran and Afghanistan. It has 12 months
|
||||
* in each year, the first 6 of which have 31 days, and the next 5 have 30 days. The 12th month
|
||||
* has either 29 or 30 days depending on whether it is a leap year. The Persian year starts
|
||||
* around the March equinox.
|
||||
*/
|
||||
export class PersianCalendar implements Calendar {
|
||||
identifier: CalendarIdentifier = 'persian';
|
||||
|
||||
fromJulianDay(jd: number): CalendarDate {
|
||||
let daysSinceEpoch = jd - PERSIAN_EPOCH;
|
||||
let year = 1 + Math.floor((33 * daysSinceEpoch + 3) / 12053);
|
||||
let farvardin1 = 365 * (year - 1) + Math.floor((8 * year + 21) / 33);
|
||||
let dayOfYear = daysSinceEpoch - farvardin1;
|
||||
let month = dayOfYear < 216 ? Math.floor(dayOfYear / 31) : Math.floor((dayOfYear - 6) / 30);
|
||||
let day = dayOfYear - MONTH_START[month] + 1;
|
||||
return new CalendarDate(this, year, month + 1, day);
|
||||
}
|
||||
|
||||
toJulianDay(date: AnyCalendarDate): number {
|
||||
let jd = PERSIAN_EPOCH - 1 + 365 * (date.year - 1) + Math.floor((8 * date.year + 21) / 33);
|
||||
jd += MONTH_START[date.month - 1];
|
||||
jd += date.day;
|
||||
return jd;
|
||||
}
|
||||
|
||||
getMonthsInYear(): number {
|
||||
return 12;
|
||||
}
|
||||
|
||||
getDaysInMonth(date: AnyCalendarDate): number {
|
||||
if (date.month <= 6) {
|
||||
return 31;
|
||||
}
|
||||
|
||||
if (date.month <= 11) {
|
||||
return 30;
|
||||
}
|
||||
|
||||
let isLeapYear = mod(25 * date.year + 11, 33) < 8;
|
||||
return isLeapYear ? 30 : 29;
|
||||
}
|
||||
|
||||
getMaximumMonthsInYear(): number {
|
||||
return 12;
|
||||
}
|
||||
|
||||
getMaximumDaysInMonth(): number {
|
||||
return 31;
|
||||
}
|
||||
|
||||
getEras(): string[] {
|
||||
return ['AP'];
|
||||
}
|
||||
|
||||
getYearsInEra(): number {
|
||||
// 9378-10-10 persian is 9999-12-31 gregorian.
|
||||
// Round down to 9377 to set the maximum full year.
|
||||
return 9377;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,81 @@
|
||||
/*
|
||||
* Copyright 2020 Adobe. All rights reserved.
|
||||
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License. You may obtain a copy
|
||||
* of the License at http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software distributed under
|
||||
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
|
||||
* OF ANY KIND, either express or implied. See the License for the specific language
|
||||
* governing permissions and limitations under the License.
|
||||
*/
|
||||
|
||||
// Portions of the code in this file are based on code from ICU.
|
||||
// Original licensing can be found in the NOTICE file in the root directory of this source tree.
|
||||
|
||||
import type { AnyCalendarDate, CalendarIdentifier } from '../core/types';
|
||||
import { CalendarDate } from '../core/CalendarDate';
|
||||
import { fromExtendedYear, getExtendedYear, GregorianCalendar } from './GregorianCalendar';
|
||||
import type { Mutable } from '$lib/util/objs';
|
||||
|
||||
const TAIWAN_ERA_START = 1911;
|
||||
|
||||
function gregorianYear(date: AnyCalendarDate) {
|
||||
return date.era === 'minguo' ? date.year + TAIWAN_ERA_START : 1 - date.year + TAIWAN_ERA_START;
|
||||
}
|
||||
|
||||
function gregorianToTaiwan(year: number): [string, number] {
|
||||
let y = year - TAIWAN_ERA_START;
|
||||
if (y > 0) {
|
||||
return ['minguo', y];
|
||||
} else {
|
||||
return ['before_minguo', 1 - y];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Taiwanese calendar is the same as the Gregorian calendar, but years
|
||||
* are numbered starting from 1912 (Gregorian). Two eras are supported:
|
||||
* 'before_minguo' and 'minguo'.
|
||||
*/
|
||||
export class TaiwanCalendar extends GregorianCalendar {
|
||||
identifier: CalendarIdentifier = 'roc'; // Republic of China
|
||||
|
||||
fromJulianDay(jd: number): CalendarDate {
|
||||
let date = super.fromJulianDay(jd);
|
||||
let extendedYear = getExtendedYear(date.era, date.year);
|
||||
let [era, year] = gregorianToTaiwan(extendedYear);
|
||||
return new CalendarDate(this, era, year, date.month, date.day);
|
||||
}
|
||||
|
||||
toJulianDay(date: AnyCalendarDate): number {
|
||||
return super.toJulianDay(toGregorian(date));
|
||||
}
|
||||
|
||||
getEras(): string[] {
|
||||
return ['before_minguo', 'minguo'];
|
||||
}
|
||||
|
||||
balanceDate(date: Mutable<AnyCalendarDate>): void {
|
||||
let [era, year] = gregorianToTaiwan(gregorianYear(date));
|
||||
date.era = era;
|
||||
date.year = year;
|
||||
}
|
||||
|
||||
isInverseEra(date: AnyCalendarDate): boolean {
|
||||
return date.era === 'before_minguo';
|
||||
}
|
||||
|
||||
getDaysInMonth(date: AnyCalendarDate): number {
|
||||
return super.getDaysInMonth(toGregorian(date));
|
||||
}
|
||||
|
||||
getYearsInEra(date: AnyCalendarDate): number {
|
||||
return date.era === 'before_minguo' ? 9999 : 9999 - TAIWAN_ERA_START;
|
||||
}
|
||||
}
|
||||
|
||||
function toGregorian(date: AnyCalendarDate) {
|
||||
let [era, year] = fromExtendedYear(gregorianYear(date));
|
||||
return new CalendarDate(era, year, date.month, date.day);
|
||||
}
|
||||
@ -0,0 +1,559 @@
|
||||
/*
|
||||
* Copyright 2020 Adobe. All rights reserved.
|
||||
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License. You may obtain a copy
|
||||
* of the License at http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software distributed under
|
||||
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
|
||||
* OF ANY KIND, either express or implied. See the License for the specific language
|
||||
* governing permissions and limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
add,
|
||||
addTime,
|
||||
addZoned,
|
||||
constrain,
|
||||
constrainTime,
|
||||
cycleDate,
|
||||
cycleTime,
|
||||
cycleZoned,
|
||||
set,
|
||||
setTime,
|
||||
setZoned,
|
||||
subtract,
|
||||
subtractTime,
|
||||
subtractZoned
|
||||
} from '../operations/manipulation';
|
||||
import type {
|
||||
AnyCalendarDate,
|
||||
AnyTime,
|
||||
Calendar,
|
||||
CycleOptions,
|
||||
CycleTimeOptions,
|
||||
DateDuration,
|
||||
DateField,
|
||||
DateFields,
|
||||
DateTimeDuration,
|
||||
Disambiguation,
|
||||
TimeDuration,
|
||||
TimeField,
|
||||
TimeFields
|
||||
} from './types';
|
||||
import { compareDate, compareTime } from '../operations/queries';
|
||||
import {
|
||||
dateTimeToString,
|
||||
dateToString,
|
||||
timeToString,
|
||||
zonedDateTimeToString
|
||||
} from '../operations/string';
|
||||
import { GregorianCalendar } from '../calendars/GregorianCalendar';
|
||||
import { toCalendarDateTime, toDate, toZoned, zonedToDate } from '../operations/conversion';
|
||||
|
||||
function shiftArgs(args: any[]) {
|
||||
let calendar: Calendar = typeof args[0] === 'object' ? args.shift() : new GregorianCalendar();
|
||||
|
||||
let era: string;
|
||||
if (typeof args[0] === 'string') {
|
||||
era = args.shift();
|
||||
} else {
|
||||
let eras = calendar.getEras();
|
||||
era = eras[eras.length - 1];
|
||||
}
|
||||
|
||||
let year = args.shift();
|
||||
let month = args.shift();
|
||||
let day = args.shift();
|
||||
|
||||
return [calendar, era, year, month, day];
|
||||
}
|
||||
|
||||
/** A CalendarDate represents a date without any time component in a specific calendar system. */
|
||||
export class CalendarDate {
|
||||
// This prevents TypeScript from allowing other types with the same fields to match.
|
||||
// i.e. a ZonedDateTime should not be be passable to a parameter that expects CalendarDate.
|
||||
// If that behavior is desired, use the AnyCalendarDate interface instead.
|
||||
// @ts-ignore
|
||||
#type;
|
||||
/** The calendar system associated with this date, e.g. Gregorian. */
|
||||
public readonly calendar: Calendar;
|
||||
/** The calendar era for this date, e.g. "BC" or "AD". */
|
||||
public readonly era: string;
|
||||
/** The year of this date within the era. */
|
||||
public readonly year: number;
|
||||
/**
|
||||
* The month number within the year. Note that some calendar systems such as Hebrew
|
||||
* may have a variable number of months per year. Therefore, month numbers may not
|
||||
* always correspond to the same month names in different years.
|
||||
*/
|
||||
public readonly month: number;
|
||||
/** The day number within the month. */
|
||||
public readonly day: number;
|
||||
|
||||
constructor(year: number, month: number, day: number);
|
||||
constructor(era: string, year: number, month: number, day: number);
|
||||
constructor(calendar: Calendar, year: number, month: number, day: number);
|
||||
constructor(calendar: Calendar, era: string, year: number, month: number, day: number);
|
||||
constructor(...args: any[]) {
|
||||
let [calendar, era, year, month, day] = shiftArgs(args);
|
||||
this.calendar = calendar;
|
||||
this.era = era;
|
||||
this.year = year;
|
||||
this.month = month;
|
||||
this.day = day;
|
||||
|
||||
constrain(this);
|
||||
}
|
||||
|
||||
/** Returns a copy of this date. */
|
||||
copy(): CalendarDate {
|
||||
if (this.era) {
|
||||
return new CalendarDate(this.calendar, this.era, this.year, this.month, this.day);
|
||||
} else {
|
||||
return new CalendarDate(this.calendar, this.year, this.month, this.day);
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns a new `CalendarDate` with the given duration added to it. */
|
||||
add(duration: DateDuration): CalendarDate {
|
||||
return add(this, duration);
|
||||
}
|
||||
|
||||
/** Returns a new `CalendarDate` with the given duration subtracted from it. */
|
||||
subtract(duration: DateDuration): CalendarDate {
|
||||
return subtract(this, duration);
|
||||
}
|
||||
|
||||
/** Returns a new `CalendarDate` with the given fields set to the provided values. Other fields will be constrained accordingly. */
|
||||
set(fields: DateFields): CalendarDate {
|
||||
return set(this, fields);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new `CalendarDate` with the given field adjusted by a specified amount.
|
||||
* When the resulting value reaches the limits of the field, it wraps around.
|
||||
*/
|
||||
cycle(field: DateField, amount: number, options?: CycleOptions): CalendarDate {
|
||||
return cycleDate(this, field, amount, options);
|
||||
}
|
||||
|
||||
/** Converts the date to a native JavaScript Date object, with the time set to midnight in the given time zone. */
|
||||
toDate(timeZone: string): Date {
|
||||
return toDate(this, timeZone);
|
||||
}
|
||||
|
||||
/** Converts the date to an ISO 8601 formatted string. */
|
||||
toString(): string {
|
||||
return dateToString(this);
|
||||
}
|
||||
|
||||
/** Compares this date with another. A negative result indicates that this date is before the given one, and a positive date indicates that it is after. */
|
||||
compare(b: AnyCalendarDate): number {
|
||||
return compareDate(this, b);
|
||||
}
|
||||
}
|
||||
|
||||
/** A Time represents a clock time without any date component. */
|
||||
export class Time {
|
||||
// This prevents TypeScript from allowing other types with the same fields to match.
|
||||
// @ts-ignore
|
||||
#type;
|
||||
/** The hour, numbered from 0 to 23. */
|
||||
public readonly hour: number;
|
||||
/** The minute in the hour. */
|
||||
public readonly minute: number;
|
||||
/** The second in the minute. */
|
||||
public readonly second: number;
|
||||
/** The millisecond in the second. */
|
||||
public readonly millisecond: number;
|
||||
|
||||
constructor(hour: number = 0, minute: number = 0, second: number = 0, millisecond: number = 0) {
|
||||
this.hour = hour;
|
||||
this.minute = minute;
|
||||
this.second = second;
|
||||
this.millisecond = millisecond;
|
||||
constrainTime(this);
|
||||
}
|
||||
|
||||
/** Returns a copy of this time. */
|
||||
copy(): Time {
|
||||
return new Time(this.hour, this.minute, this.second, this.millisecond);
|
||||
}
|
||||
|
||||
/** Returns a new `Time` with the given duration added to it. */
|
||||
add(duration: TimeDuration): Time {
|
||||
return addTime(this, duration);
|
||||
}
|
||||
|
||||
/** Returns a new `Time` with the given duration subtracted from it. */
|
||||
subtract(duration: TimeDuration): Time {
|
||||
return subtractTime(this, duration);
|
||||
}
|
||||
|
||||
/** Returns a new `Time` with the given fields set to the provided values. Other fields will be constrained accordingly. */
|
||||
set(fields: TimeFields): Time {
|
||||
return setTime(this, fields);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new `Time` with the given field adjusted by a specified amount.
|
||||
* When the resulting value reaches the limits of the field, it wraps around.
|
||||
*/
|
||||
cycle(field: TimeField, amount: number, options?: CycleTimeOptions): Time {
|
||||
return cycleTime(this, field, amount, options);
|
||||
}
|
||||
|
||||
/** Converts the time to an ISO 8601 formatted string. */
|
||||
toString(): string {
|
||||
return timeToString(this);
|
||||
}
|
||||
|
||||
/** Compares this time with another. A negative result indicates that this time is before the given one, and a positive time indicates that it is after. */
|
||||
compare(b: AnyTime): number {
|
||||
return compareTime(this, b);
|
||||
}
|
||||
}
|
||||
|
||||
/** A CalendarDateTime represents a date and time without a time zone, in a specific calendar system. */
|
||||
export class CalendarDateTime {
|
||||
// This prevents TypeScript from allowing other types with the same fields to match.
|
||||
// @ts-ignore
|
||||
#type;
|
||||
/** The calendar system associated with this date, e.g. Gregorian. */
|
||||
public readonly calendar: Calendar;
|
||||
/** The calendar era for this date, e.g. "BC" or "AD". */
|
||||
public readonly era: string;
|
||||
/** The year of this date within the era. */
|
||||
public readonly year: number;
|
||||
/**
|
||||
* The month number within the year. Note that some calendar systems such as Hebrew
|
||||
* may have a variable number of months per year. Therefore, month numbers may not
|
||||
* always correspond to the same month names in different years.
|
||||
*/
|
||||
public readonly month: number;
|
||||
/** The day number within the month. */
|
||||
public readonly day: number;
|
||||
/** The hour in the day, numbered from 0 to 23. */
|
||||
public readonly hour: number;
|
||||
/** The minute in the hour. */
|
||||
public readonly minute: number;
|
||||
/** The second in the minute. */
|
||||
public readonly second: number;
|
||||
/** The millisecond in the second. */
|
||||
public readonly millisecond: number;
|
||||
|
||||
constructor(
|
||||
year: number,
|
||||
month: number,
|
||||
day: number,
|
||||
hour?: number,
|
||||
minute?: number,
|
||||
second?: number,
|
||||
millisecond?: number
|
||||
);
|
||||
constructor(
|
||||
era: string,
|
||||
year: number,
|
||||
month: number,
|
||||
day: number,
|
||||
hour?: number,
|
||||
minute?: number,
|
||||
second?: number,
|
||||
millisecond?: number
|
||||
);
|
||||
constructor(
|
||||
calendar: Calendar,
|
||||
year: number,
|
||||
month: number,
|
||||
day: number,
|
||||
hour?: number,
|
||||
minute?: number,
|
||||
second?: number,
|
||||
millisecond?: number
|
||||
);
|
||||
constructor(
|
||||
calendar: Calendar,
|
||||
era: string,
|
||||
year: number,
|
||||
month: number,
|
||||
day: number,
|
||||
hour?: number,
|
||||
minute?: number,
|
||||
second?: number,
|
||||
millisecond?: number
|
||||
);
|
||||
constructor(...args: any[]) {
|
||||
let [calendar, era, year, month, day] = shiftArgs(args);
|
||||
this.calendar = calendar;
|
||||
this.era = era;
|
||||
this.year = year;
|
||||
this.month = month;
|
||||
this.day = day;
|
||||
this.hour = args.shift() || 0;
|
||||
this.minute = args.shift() || 0;
|
||||
this.second = args.shift() || 0;
|
||||
this.millisecond = args.shift() || 0;
|
||||
|
||||
constrain(this);
|
||||
}
|
||||
|
||||
/** Returns a copy of this date. */
|
||||
copy(): CalendarDateTime {
|
||||
if (this.era) {
|
||||
return new CalendarDateTime(
|
||||
this.calendar,
|
||||
this.era,
|
||||
this.year,
|
||||
this.month,
|
||||
this.day,
|
||||
this.hour,
|
||||
this.minute,
|
||||
this.second,
|
||||
this.millisecond
|
||||
);
|
||||
} else {
|
||||
return new CalendarDateTime(
|
||||
this.calendar,
|
||||
this.year,
|
||||
this.month,
|
||||
this.day,
|
||||
this.hour,
|
||||
this.minute,
|
||||
this.second,
|
||||
this.millisecond
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns a new `CalendarDateTime` with the given duration added to it. */
|
||||
add(duration: DateTimeDuration): CalendarDateTime {
|
||||
return add(this, duration);
|
||||
}
|
||||
|
||||
/** Returns a new `CalendarDateTime` with the given duration subtracted from it. */
|
||||
subtract(duration: DateTimeDuration): CalendarDateTime {
|
||||
return subtract(this, duration);
|
||||
}
|
||||
|
||||
/** Returns a new `CalendarDateTime` with the given fields set to the provided values. Other fields will be constrained accordingly. */
|
||||
set(fields: DateFields & TimeFields): CalendarDateTime {
|
||||
return set(setTime(this, fields), fields);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new `CalendarDateTime` with the given field adjusted by a specified amount.
|
||||
* When the resulting value reaches the limits of the field, it wraps around.
|
||||
*/
|
||||
cycle(
|
||||
field: DateField | TimeField,
|
||||
amount: number,
|
||||
options?: CycleTimeOptions
|
||||
): CalendarDateTime {
|
||||
switch (field) {
|
||||
case 'era':
|
||||
case 'year':
|
||||
case 'month':
|
||||
case 'day':
|
||||
return cycleDate(this, field, amount, options);
|
||||
default:
|
||||
return cycleTime(this, field, amount, options);
|
||||
}
|
||||
}
|
||||
|
||||
/** Converts the date to a native JavaScript Date object in the given time zone. */
|
||||
toDate(timeZone: string, disambiguation?: Disambiguation): Date {
|
||||
return toDate(this, timeZone, disambiguation);
|
||||
}
|
||||
|
||||
/** Converts the date to an ISO 8601 formatted string. */
|
||||
toString(): string {
|
||||
return dateTimeToString(this);
|
||||
}
|
||||
|
||||
/** Compares this date with another. A negative result indicates that this date is before the given one, and a positive date indicates that it is after. */
|
||||
compare(b: CalendarDate | CalendarDateTime | ZonedDateTime): number {
|
||||
let res = compareDate(this, b);
|
||||
if (res === 0) {
|
||||
return compareTime(this, toCalendarDateTime(b));
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
/** A ZonedDateTime represents a date and time in a specific time zone and calendar system. */
|
||||
export class ZonedDateTime {
|
||||
// This prevents TypeScript from allowing other types with the same fields to match.
|
||||
// @ts-ignore
|
||||
#type;
|
||||
/** The calendar system associated with this date, e.g. Gregorian. */
|
||||
public readonly calendar: Calendar;
|
||||
/** The calendar era for this date, e.g. "BC" or "AD". */
|
||||
public readonly era: string;
|
||||
/** The year of this date within the era. */
|
||||
public readonly year: number;
|
||||
/**
|
||||
* The month number within the year. Note that some calendar systems such as Hebrew
|
||||
* may have a variable number of months per year. Therefore, month numbers may not
|
||||
* always correspond to the same month names in different years.
|
||||
*/
|
||||
public readonly month: number;
|
||||
/** The day number within the month. */
|
||||
public readonly day: number;
|
||||
/** The hour in the day, numbered from 0 to 23. */
|
||||
public readonly hour: number;
|
||||
/** The minute in the hour. */
|
||||
public readonly minute: number;
|
||||
/** The second in the minute. */
|
||||
public readonly second: number;
|
||||
/** The millisecond in the second. */
|
||||
public readonly millisecond: number;
|
||||
/** The IANA time zone identifier that this date and time is represented in. */
|
||||
public readonly timeZone: string;
|
||||
/** The UTC offset for this time, in milliseconds. */
|
||||
public readonly offset: number;
|
||||
|
||||
constructor(
|
||||
year: number,
|
||||
month: number,
|
||||
day: number,
|
||||
timeZone: string,
|
||||
offset: number,
|
||||
hour?: number,
|
||||
minute?: number,
|
||||
second?: number,
|
||||
millisecond?: number
|
||||
);
|
||||
constructor(
|
||||
era: string,
|
||||
year: number,
|
||||
month: number,
|
||||
day: number,
|
||||
timeZone: string,
|
||||
offset: number,
|
||||
hour?: number,
|
||||
minute?: number,
|
||||
second?: number,
|
||||
millisecond?: number
|
||||
);
|
||||
constructor(
|
||||
calendar: Calendar,
|
||||
year: number,
|
||||
month: number,
|
||||
day: number,
|
||||
timeZone: string,
|
||||
offset: number,
|
||||
hour?: number,
|
||||
minute?: number,
|
||||
second?: number,
|
||||
millisecond?: number
|
||||
);
|
||||
constructor(
|
||||
calendar: Calendar,
|
||||
era: string,
|
||||
year: number,
|
||||
month: number,
|
||||
day: number,
|
||||
timeZone: string,
|
||||
offset: number,
|
||||
hour?: number,
|
||||
minute?: number,
|
||||
second?: number,
|
||||
millisecond?: number
|
||||
);
|
||||
constructor(...args: any[]) {
|
||||
let [calendar, era, year, month, day] = shiftArgs(args);
|
||||
let timeZone = args.shift();
|
||||
let offset = args.shift();
|
||||
this.calendar = calendar;
|
||||
this.era = era;
|
||||
this.year = year;
|
||||
this.month = month;
|
||||
this.day = day;
|
||||
this.timeZone = timeZone;
|
||||
this.offset = offset;
|
||||
this.hour = args.shift() || 0;
|
||||
this.minute = args.shift() || 0;
|
||||
this.second = args.shift() || 0;
|
||||
this.millisecond = args.shift() || 0;
|
||||
|
||||
constrain(this);
|
||||
}
|
||||
|
||||
/** Returns a copy of this date. */
|
||||
copy(): ZonedDateTime {
|
||||
if (this.era) {
|
||||
return new ZonedDateTime(
|
||||
this.calendar,
|
||||
this.era,
|
||||
this.year,
|
||||
this.month,
|
||||
this.day,
|
||||
this.timeZone,
|
||||
this.offset,
|
||||
this.hour,
|
||||
this.minute,
|
||||
this.second,
|
||||
this.millisecond
|
||||
);
|
||||
} else {
|
||||
return new ZonedDateTime(
|
||||
this.calendar,
|
||||
this.year,
|
||||
this.month,
|
||||
this.day,
|
||||
this.timeZone,
|
||||
this.offset,
|
||||
this.hour,
|
||||
this.minute,
|
||||
this.second,
|
||||
this.millisecond
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns a new `ZonedDateTime` with the given duration added to it. */
|
||||
add(duration: DateTimeDuration): ZonedDateTime {
|
||||
return addZoned(this, duration);
|
||||
}
|
||||
|
||||
/** Returns a new `ZonedDateTime` with the given duration subtracted from it. */
|
||||
subtract(duration: DateTimeDuration): ZonedDateTime {
|
||||
return subtractZoned(this, duration);
|
||||
}
|
||||
|
||||
/** Returns a new `ZonedDateTime` with the given fields set to the provided values. Other fields will be constrained accordingly. */
|
||||
set(fields: DateFields & TimeFields, disambiguation?: Disambiguation): ZonedDateTime {
|
||||
return setZoned(this, fields, disambiguation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new `ZonedDateTime` with the given field adjusted by a specified amount.
|
||||
* When the resulting value reaches the limits of the field, it wraps around.
|
||||
*/
|
||||
cycle(field: DateField | TimeField, amount: number, options?: CycleTimeOptions): ZonedDateTime {
|
||||
return cycleZoned(this, field, amount, options);
|
||||
}
|
||||
|
||||
/** Converts the date to a native JavaScript Date object. */
|
||||
toDate(): Date {
|
||||
return zonedToDate(this);
|
||||
}
|
||||
|
||||
/** Converts the date to an ISO 8601 formatted string, including the UTC offset and time zone identifier. */
|
||||
toString(): string {
|
||||
return zonedDateTimeToString(this);
|
||||
}
|
||||
|
||||
/** Converts the date to an ISO 8601 formatted string in UTC. */
|
||||
toAbsoluteString(): string {
|
||||
return this.toDate().toISOString();
|
||||
}
|
||||
|
||||
/** Compares this date with another. A negative result indicates that this date is before the given one, and a positive date indicates that it is after. */
|
||||
compare(b: CalendarDate | CalendarDateTime | ZonedDateTime): number {
|
||||
// TODO: Is this a bad idea??
|
||||
return this.toDate().getTime() - toZoned(b, this.timeZone).toDate().getTime();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Copyright 2020 Adobe. All rights reserved.
|
||||
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License. You may obtain a copy
|
||||
* of the License at http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software distributed under
|
||||
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
|
||||
* OF ANY KIND, either express or implied. See the License for the specific language
|
||||
* governing permissions and limitations under the License.
|
||||
*/
|
||||
|
||||
import { BuddhistCalendar } from '../calendars/BuddhistCalendar';
|
||||
import type { Calendar, CalendarIdentifier } from './types';
|
||||
import {
|
||||
CopticCalendar,
|
||||
EthiopicAmeteAlemCalendar,
|
||||
EthiopicCalendar
|
||||
} from '../calendars/EthiopicCalendar';
|
||||
import { GregorianCalendar } from '../calendars/GregorianCalendar';
|
||||
import { HebrewCalendar } from '../calendars/HebrewCalendar';
|
||||
import { IndianCalendar } from '../calendars/IndianCalendar';
|
||||
import {
|
||||
IslamicCivilCalendar,
|
||||
IslamicTabularCalendar,
|
||||
IslamicUmalquraCalendar
|
||||
} from '../calendars/IslamicCalendar';
|
||||
import { JapaneseCalendar } from '../calendars/JapaneseCalendar';
|
||||
import { PersianCalendar } from '../calendars/PersianCalendar';
|
||||
import { TaiwanCalendar } from '../calendars/TaiwanCalendar';
|
||||
|
||||
/** Creates a `Calendar` instance from a Unicode calendar identifier string. */
|
||||
export function createCalendar(name: CalendarIdentifier): Calendar {
|
||||
switch (name) {
|
||||
case 'buddhist':
|
||||
return new BuddhistCalendar();
|
||||
case 'ethiopic':
|
||||
return new EthiopicCalendar();
|
||||
case 'ethioaa':
|
||||
return new EthiopicAmeteAlemCalendar();
|
||||
case 'coptic':
|
||||
return new CopticCalendar();
|
||||
case 'hebrew':
|
||||
return new HebrewCalendar();
|
||||
case 'indian':
|
||||
return new IndianCalendar();
|
||||
case 'islamic-civil':
|
||||
return new IslamicCivilCalendar();
|
||||
case 'islamic-tbla':
|
||||
return new IslamicTabularCalendar();
|
||||
case 'islamic-umalqura':
|
||||
return new IslamicUmalquraCalendar();
|
||||
case 'japanese':
|
||||
return new JapaneseCalendar();
|
||||
case 'persian':
|
||||
return new PersianCalendar();
|
||||
case 'roc':
|
||||
return new TaiwanCalendar();
|
||||
case 'gregory':
|
||||
default:
|
||||
return new GregorianCalendar();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,212 @@
|
||||
/*
|
||||
* Copyright 2020 Adobe. All rights reserved.
|
||||
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License. You may obtain a copy
|
||||
* of the License at http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software distributed under
|
||||
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
|
||||
* OF ANY KIND, either express or implied. See the License for the specific language
|
||||
* governing permissions and limitations under the License.
|
||||
*/
|
||||
|
||||
import { CalendarDate } from './CalendarDate';
|
||||
|
||||
/** An interface that is compatible with any object with date fields. */
|
||||
export interface AnyCalendarDate {
|
||||
readonly calendar: Calendar;
|
||||
readonly era: string;
|
||||
readonly year: number;
|
||||
readonly month: number;
|
||||
readonly day: number;
|
||||
copy(): this;
|
||||
}
|
||||
|
||||
/** An interface that is compatible with any object with time fields. */
|
||||
export interface AnyTime {
|
||||
readonly hour: number;
|
||||
readonly minute: number;
|
||||
readonly second: number;
|
||||
readonly millisecond: number;
|
||||
copy(): this;
|
||||
}
|
||||
|
||||
/** An interface that is compatible with any object with both date and time fields. */
|
||||
export interface AnyDateTime extends AnyCalendarDate, AnyTime {}
|
||||
|
||||
export type CalendarIdentifier =
|
||||
| 'gregory'
|
||||
| 'buddhist'
|
||||
| 'chinese'
|
||||
| 'coptic'
|
||||
| 'dangi'
|
||||
| 'ethioaa'
|
||||
| 'ethiopic'
|
||||
| 'hebrew'
|
||||
| 'indian'
|
||||
| 'islamic'
|
||||
| 'islamic-umalqura'
|
||||
| 'islamic-tbla'
|
||||
| 'islamic-civil'
|
||||
| 'islamic-rgsa'
|
||||
| 'iso8601'
|
||||
| 'japanese'
|
||||
| 'persian'
|
||||
| 'roc';
|
||||
|
||||
/**
|
||||
* The Calendar interface represents a calendar system, including information
|
||||
* about how days, months, years, and eras are organized, and methods to perform
|
||||
* arithmetic on dates.
|
||||
*/
|
||||
export interface Calendar {
|
||||
/**
|
||||
* A string identifier for the calendar, as defined by Unicode CLDR.
|
||||
* See [MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/supportedValuesOf#supported_calendar_types).
|
||||
*/
|
||||
identifier: CalendarIdentifier;
|
||||
|
||||
/** Creates a CalendarDate in this calendar from the given Julian day number. */
|
||||
fromJulianDay(jd: number): CalendarDate;
|
||||
/** Converts a date in this calendar to a Julian day number. */
|
||||
toJulianDay(date: AnyCalendarDate): number;
|
||||
|
||||
/** Returns the number of days in the month of the given date. */
|
||||
getDaysInMonth(date: AnyCalendarDate): number;
|
||||
/** Returns the number of months in the year of the given date. */
|
||||
getMonthsInYear(date: AnyCalendarDate): number;
|
||||
/** Returns the number of years in the era of the given date. */
|
||||
getYearsInEra(date: AnyCalendarDate): number;
|
||||
/** Returns a list of era identifiers for the calendar. */
|
||||
getEras(): string[];
|
||||
|
||||
/**
|
||||
* Returns the minimum month number of the given date's year.
|
||||
* Normally, this is 1, but in some calendars such as the Japanese,
|
||||
* eras may begin in the middle of a year.
|
||||
*/
|
||||
getMinimumMonthInYear?(date: AnyCalendarDate): number;
|
||||
/**
|
||||
* Returns the minimum day number of the given date's month.
|
||||
* Normally, this is 1, but in some calendars such as the Japanese,
|
||||
* eras may begin in the middle of a month.
|
||||
*/
|
||||
getMinimumDayInMonth?(date: AnyCalendarDate): number;
|
||||
/** Returns the maximum months across all years. */
|
||||
getMaximumMonthsInYear(): number;
|
||||
/** Returns the maximum days across all months. */
|
||||
getMaximumDaysInMonth(): number;
|
||||
/**
|
||||
* Returns a date that is the first day of the month for the given date.
|
||||
* This is used to determine the month that the given date falls in, if
|
||||
* the calendar has months that do not align with the standard calendar months
|
||||
* (e.g. fiscal calendars).
|
||||
*/
|
||||
getFormattableMonth?(date: AnyCalendarDate): CalendarDate;
|
||||
|
||||
/** Returns whether the given calendar is the same as this calendar. */
|
||||
isEqual?(calendar: Calendar): boolean;
|
||||
|
||||
/** @private */
|
||||
balanceDate?(date: AnyCalendarDate): void;
|
||||
/** @private */
|
||||
balanceYearMonth?(date: AnyCalendarDate, previousDate: AnyCalendarDate): void;
|
||||
/** @private */
|
||||
constrainDate?(date: AnyCalendarDate): void;
|
||||
/** @private */
|
||||
isInverseEra?(date: AnyCalendarDate): boolean;
|
||||
}
|
||||
|
||||
/** Represents an amount of time in calendar-specific units, for use when performing arithmetic. */
|
||||
export interface DateDuration {
|
||||
/** The number of years to add or subtract. */
|
||||
years?: number;
|
||||
/** The number of months to add or subtract. */
|
||||
months?: number;
|
||||
/** The number of weeks to add or subtract. */
|
||||
weeks?: number;
|
||||
/** The number of days to add or subtract. */
|
||||
days?: number;
|
||||
}
|
||||
|
||||
/** Represents an amount of time, for use whe performing arithmetic. */
|
||||
export interface TimeDuration {
|
||||
/** The number of hours to add or subtract. */
|
||||
hours?: number;
|
||||
/** The number of minutes to add or subtract. */
|
||||
minutes?: number;
|
||||
/** The number of seconds to add or subtract. */
|
||||
seconds?: number;
|
||||
/** The number of milliseconds to add or subtract. */
|
||||
milliseconds?: number;
|
||||
}
|
||||
|
||||
/** Represents an amount of time with both date and time component, for use when performing arithmetic. */
|
||||
export interface DateTimeDuration extends DateDuration, TimeDuration {}
|
||||
|
||||
export interface DateFields {
|
||||
era?: string;
|
||||
year?: number;
|
||||
month?: number;
|
||||
day?: number;
|
||||
}
|
||||
|
||||
export interface TimeFields {
|
||||
hour?: number;
|
||||
minute?: number;
|
||||
second?: number;
|
||||
millisecond?: number;
|
||||
}
|
||||
|
||||
export type DateField = keyof DateFields;
|
||||
export type TimeField = keyof TimeFields;
|
||||
|
||||
export type Disambiguation = 'compatible' | 'earlier' | 'later' | 'reject';
|
||||
|
||||
export interface CycleOptions {
|
||||
/** Whether to round the field value to the nearest interval of the amount. */
|
||||
round?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hour cycle for time display.
|
||||
* - `12` — 12-hour clock (AM/PM)
|
||||
* - `24` — 24-hour clock
|
||||
*/
|
||||
export type HourCycle = 12 | 24;
|
||||
|
||||
export interface CycleTimeOptions extends CycleOptions {
|
||||
/**
|
||||
* Whether to use 12 or 24 hour time. If 12 hour time is chosen, the resulting value
|
||||
* will remain in the same day period as the original value (e.g. if the value is AM,
|
||||
* the resulting value also be AM).
|
||||
* @default 24
|
||||
*/
|
||||
hourCycle?: HourCycle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nivel de detalle en el formateo de fecha u hora.
|
||||
* Mapea directamente a los valores que acepta `Intl.DateTimeFormat`.
|
||||
*/
|
||||
export type DateStyleType = 'short' | 'medium' | 'long' | 'full';
|
||||
|
||||
/** Orden preferido de los componentes de fecha (informativo para la UI). */
|
||||
export type DateOrder = 'DMY' | 'MDY' | 'YMD';
|
||||
|
||||
// ─── Domain types ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Granularity of a date/time value. Determines which fields are included
|
||||
* when rendering or editing a date field.
|
||||
*/
|
||||
export type Granularity = 'day' | 'hour' | 'minute' | 'second';
|
||||
|
||||
/** Time-only granularity (subset of Granularity). */
|
||||
export type TimeGranularity = 'hour' | 'minute' | 'second';
|
||||
|
||||
/**
|
||||
* Day of the week that the calendar week starts on.
|
||||
* 0 = Sunday, 1 = Monday, …, 6 = Saturday.
|
||||
*/
|
||||
export type WeekStartsOn = 0 | 1 | 2 | 3 | 4 | 5 | 6;
|
||||
@ -0,0 +1,92 @@
|
||||
import { CalendarDate, CalendarDateTime, Time } from '../core/CalendarDate';
|
||||
import type { DateValue, TimeValue } from '../operations/queries';
|
||||
import { getLocalTimeZone } from '../operations/queries';
|
||||
import type { Granularity, TimeGranularity, HourCycle, DateOrder } from '../core/types';
|
||||
|
||||
/** Valores por defecto compartidos para componentes desacoplados de contexto. */
|
||||
export const DEFAULT_TIME_FORMAT: HourCycle = 24;
|
||||
export const DEFAULT_DATE_ORDER: DateOrder = 'DMY';
|
||||
|
||||
// ─── Date defaults ────────────────────────────────────────────────────────────
|
||||
|
||||
type GetDefaultDateOpts = {
|
||||
defaultValue?: DateValue | DateValue[] | undefined;
|
||||
minValue?: DateValue;
|
||||
maxValue?: DateValue;
|
||||
granularity?: Granularity;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a sensible default `DateValue` for use when no value is set.
|
||||
* Respects `minValue`/`maxValue` clamps and `granularity` to decide whether
|
||||
* to return a `CalendarDate` or a `CalendarDateTime`.
|
||||
*/
|
||||
export function getDefaultDate(opts: GetDefaultDateOpts): DateValue {
|
||||
const { defaultValue, granularity = 'day', minValue, maxValue } = opts;
|
||||
|
||||
if (Array.isArray(defaultValue) && defaultValue.length) {
|
||||
return defaultValue[defaultValue.length - 1]!;
|
||||
}
|
||||
if (defaultValue && !Array.isArray(defaultValue)) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
let date = new Date();
|
||||
const tz = getLocalTimeZone();
|
||||
if (minValue && date < minValue.toDate(tz)) date = minValue.toDate(tz);
|
||||
else if (maxValue && date > maxValue.toDate(tz)) date = maxValue.toDate(tz);
|
||||
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth() + 1;
|
||||
const day = date.getDate();
|
||||
|
||||
if (granularity === 'hour' || granularity === 'minute' || granularity === 'second') {
|
||||
return new CalendarDateTime(year, month, day, 0, 0, 0);
|
||||
}
|
||||
return new CalendarDate(year, month, day);
|
||||
}
|
||||
|
||||
// ─── Time defaults ────────────────────────────────────────────────────────────
|
||||
|
||||
type GetDefaultTimeOpts = {
|
||||
defaultValue?: TimeValue | undefined;
|
||||
granularity?: TimeGranularity;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a sensible default `TimeValue`. Falls back to midnight (`00:00:00`).
|
||||
*/
|
||||
export function getDefaultTime(opts: GetDefaultTimeOpts): TimeValue {
|
||||
return opts.defaultValue ?? new Time(0, 0, 0);
|
||||
}
|
||||
|
||||
// ─── Granularity inference ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Infers the appropriate `Granularity` from a `DateValue`.
|
||||
* If an explicit `granularity` is provided, it is returned as-is.
|
||||
*/
|
||||
export function inferGranularity(value: DateValue, granularity?: Granularity): Granularity {
|
||||
if (granularity) return granularity;
|
||||
if (value instanceof CalendarDate) return 'day';
|
||||
return 'minute';
|
||||
}
|
||||
|
||||
/**
|
||||
* Infers the appropriate `TimeGranularity`.
|
||||
* Defaults to `'minute'` if none is provided.
|
||||
*/
|
||||
export function inferTimeGranularity(granularity?: TimeGranularity): TimeGranularity {
|
||||
return granularity ?? 'minute';
|
||||
}
|
||||
|
||||
// ─── HourCycle ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns the default hour cycle for the given locale based on
|
||||
* `Intl.DateTimeFormat` resolved options.
|
||||
*/
|
||||
export function getDefaultHourCycle(locale: string): HourCycle {
|
||||
const opts = new Intl.DateTimeFormat(locale, { hour: 'numeric' }).resolvedOptions();
|
||||
return opts.hour12 ? 12 : 24;
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
import type { HourCycle } from '../core/types';
|
||||
|
||||
export type Mutable<T> = {
|
||||
-readonly [P in keyof T]: T[P];
|
||||
};
|
||||
|
||||
/** Convierte la preferencia de usuario `DatiTimeFormat` al `HourCycle` numérico de `Intl`. */
|
||||
export function timeFormatToHourCycle(fmt: HourCycle): HourCycle {
|
||||
return fmt === 12 ? 12 : 24;
|
||||
}
|
||||
@ -0,0 +1,111 @@
|
||||
/*
|
||||
* Copyright 2020 Adobe. All rights reserved.
|
||||
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License. You may obtain a copy
|
||||
* of the License at http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software distributed under
|
||||
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
|
||||
* OF ANY KIND, either express or implied. See the License for the specific language
|
||||
* governing permissions and limitations under the License.
|
||||
*/
|
||||
|
||||
// Data from https://github.com/unicode-cldr/cldr-core/blob/master/supplemental/weekData.json
|
||||
// Locales starting on Sunday have been removed for compression.
|
||||
export const weekStartData = {
|
||||
'001': 1,
|
||||
AD: 1,
|
||||
AE: 6,
|
||||
AF: 6,
|
||||
AI: 1,
|
||||
AL: 1,
|
||||
AM: 1,
|
||||
AN: 1,
|
||||
AR: 1,
|
||||
AT: 1,
|
||||
AU: 1,
|
||||
AX: 1,
|
||||
AZ: 1,
|
||||
BA: 1,
|
||||
BE: 1,
|
||||
BG: 1,
|
||||
BH: 6,
|
||||
BM: 1,
|
||||
BN: 1,
|
||||
BY: 1,
|
||||
CH: 1,
|
||||
CL: 1,
|
||||
CM: 1,
|
||||
CN: 1,
|
||||
CR: 1,
|
||||
CY: 1,
|
||||
CZ: 1,
|
||||
DE: 1,
|
||||
DJ: 6,
|
||||
DK: 1,
|
||||
DZ: 6,
|
||||
EC: 1,
|
||||
EE: 1,
|
||||
EG: 6,
|
||||
ES: 1,
|
||||
FI: 1,
|
||||
FJ: 1,
|
||||
FO: 1,
|
||||
FR: 1,
|
||||
GB: 1,
|
||||
GE: 1,
|
||||
GF: 1,
|
||||
GP: 1,
|
||||
GR: 1,
|
||||
HR: 1,
|
||||
HU: 1,
|
||||
IE: 1,
|
||||
IQ: 6,
|
||||
IR: 6,
|
||||
IS: 1,
|
||||
IT: 1,
|
||||
JO: 6,
|
||||
KG: 1,
|
||||
KW: 6,
|
||||
KZ: 1,
|
||||
LB: 1,
|
||||
LI: 1,
|
||||
LK: 1,
|
||||
LT: 1,
|
||||
LU: 1,
|
||||
LV: 1,
|
||||
LY: 6,
|
||||
MC: 1,
|
||||
MD: 1,
|
||||
ME: 1,
|
||||
MK: 1,
|
||||
MN: 1,
|
||||
MQ: 1,
|
||||
MV: 5,
|
||||
MY: 1,
|
||||
NL: 1,
|
||||
NO: 1,
|
||||
NZ: 1,
|
||||
OM: 6,
|
||||
PL: 1,
|
||||
QA: 6,
|
||||
RE: 1,
|
||||
RO: 1,
|
||||
RS: 1,
|
||||
RU: 1,
|
||||
SD: 6,
|
||||
SE: 1,
|
||||
SI: 1,
|
||||
SK: 1,
|
||||
SM: 1,
|
||||
SY: 6,
|
||||
TJ: 1,
|
||||
TM: 1,
|
||||
TR: 1,
|
||||
UA: 1,
|
||||
UY: 1,
|
||||
UZ: 1,
|
||||
VA: 1,
|
||||
VN: 1,
|
||||
XK: 1
|
||||
};
|
||||
@ -0,0 +1,425 @@
|
||||
/*
|
||||
* Copyright 2020 Adobe. All rights reserved.
|
||||
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License. You may obtain a copy
|
||||
* of the License at http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software distributed under
|
||||
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
|
||||
* OF ANY KIND, either express or implied. See the License for the specific language
|
||||
* governing permissions and limitations under the License.
|
||||
*/
|
||||
|
||||
// Portions of the code in this file are based on code from the TC39 Temporal proposal.
|
||||
// Original licensing can be found in the NOTICE file in the root directory of this source tree.
|
||||
|
||||
import type {
|
||||
AnyCalendarDate,
|
||||
AnyDateTime,
|
||||
AnyTime,
|
||||
Calendar,
|
||||
DateFields,
|
||||
Disambiguation,
|
||||
TimeFields
|
||||
} from '../core/types';
|
||||
import { CalendarDate, CalendarDateTime, Time, ZonedDateTime } from '../core/CalendarDate';
|
||||
import { constrain } from './manipulation';
|
||||
import { getExtendedYear, GregorianCalendar } from '../calendars/GregorianCalendar';
|
||||
import { getLocalTimeZone, isEqualCalendar, isLocalTimeZoneOverridden } from './queries';
|
||||
import type { Mutable } from '../internal/utils';
|
||||
|
||||
export function epochFromDate(date: AnyDateTime): number {
|
||||
date = toCalendar(date, new GregorianCalendar());
|
||||
let year = getExtendedYear(date.era, date.year);
|
||||
return epochFromParts(
|
||||
year,
|
||||
date.month,
|
||||
date.day,
|
||||
date.hour,
|
||||
date.minute,
|
||||
date.second,
|
||||
date.millisecond
|
||||
);
|
||||
}
|
||||
|
||||
function epochFromParts(
|
||||
year: number,
|
||||
month: number,
|
||||
day: number,
|
||||
hour: number,
|
||||
minute: number,
|
||||
second: number,
|
||||
millisecond: number
|
||||
): number {
|
||||
// Note: Date.UTC() interprets one and two-digit years as being in the
|
||||
// 20th century, so don't use it
|
||||
let date = new Date();
|
||||
date.setUTCHours(hour, minute, second, millisecond);
|
||||
date.setUTCFullYear(year, month - 1, day);
|
||||
return date.getTime();
|
||||
}
|
||||
|
||||
export function getTimeZoneOffset(ms: number, timeZone: string): number {
|
||||
// Fast path for UTC.
|
||||
if (timeZone === 'UTC') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Fast path: for local timezone after 1970, use native Date.
|
||||
// Skip this fast path if the local timezone was explicitly overridden via setLocalTimeZone,
|
||||
// since native Date always uses the browser's timezone, not the overridden one.
|
||||
if (ms > 0 && timeZone === getLocalTimeZone() && !isLocalTimeZoneOverridden()) {
|
||||
return new Date(ms).getTimezoneOffset() * -60 * 1000;
|
||||
}
|
||||
|
||||
let { year, month, day, hour, minute, second } = getTimeZoneParts(ms, timeZone);
|
||||
let utc = epochFromParts(year, month, day, hour, minute, second, 0);
|
||||
return utc - Math.floor(ms / 1000) * 1000;
|
||||
}
|
||||
|
||||
const formattersByTimeZone = new Map<string, Intl.DateTimeFormat>();
|
||||
|
||||
function getTimeZoneParts(ms: number, timeZone: string) {
|
||||
let formatter = formattersByTimeZone.get(timeZone);
|
||||
if (!formatter) {
|
||||
formatter = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone,
|
||||
hour12: false,
|
||||
era: 'short',
|
||||
year: 'numeric',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
second: 'numeric'
|
||||
});
|
||||
|
||||
formattersByTimeZone.set(timeZone, formatter);
|
||||
}
|
||||
|
||||
let parts = formatter.formatToParts(new Date(ms));
|
||||
let namedParts: { [name: string]: string } = {};
|
||||
for (let part of parts) {
|
||||
if (part.type !== 'literal') {
|
||||
namedParts[part.type] = part.value;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// Firefox returns B instead of BC... https://bugzilla.mozilla.org/show_bug.cgi?id=1752253
|
||||
year:
|
||||
namedParts.era === 'BC' || namedParts.era === 'B' ? -namedParts.year + 1 : +namedParts.year,
|
||||
month: +namedParts.month,
|
||||
day: +namedParts.day,
|
||||
hour: namedParts.hour === '24' ? 0 : +namedParts.hour, // bugs.chromium.org/p/chromium/issues/detail?id=1045791
|
||||
minute: +namedParts.minute,
|
||||
second: +namedParts.second
|
||||
};
|
||||
}
|
||||
|
||||
const DAYMILLIS = 86400000;
|
||||
|
||||
export function possibleAbsolutes(date: CalendarDateTime, timeZone: string): number[] {
|
||||
let ms = epochFromDate(date);
|
||||
let earlier = ms - getTimeZoneOffset(ms - DAYMILLIS, timeZone);
|
||||
let later = ms - getTimeZoneOffset(ms + DAYMILLIS, timeZone);
|
||||
return getValidWallTimes(date, timeZone, earlier, later);
|
||||
}
|
||||
|
||||
function getValidWallTimes(
|
||||
date: CalendarDateTime,
|
||||
timeZone: string,
|
||||
earlier: number,
|
||||
later: number
|
||||
): number[] {
|
||||
let found = earlier === later ? [earlier] : [earlier, later];
|
||||
return found.filter((absolute) => isValidWallTime(date, timeZone, absolute));
|
||||
}
|
||||
|
||||
function isValidWallTime(date: CalendarDateTime, timeZone: string, absolute: number) {
|
||||
let parts = getTimeZoneParts(absolute, timeZone);
|
||||
return (
|
||||
date.year === parts.year &&
|
||||
date.month === parts.month &&
|
||||
date.day === parts.day &&
|
||||
date.hour === parts.hour &&
|
||||
date.minute === parts.minute &&
|
||||
date.second === parts.second
|
||||
);
|
||||
}
|
||||
|
||||
export function toAbsolute(
|
||||
date: CalendarDate | CalendarDateTime,
|
||||
timeZone: string,
|
||||
disambiguation: Disambiguation = 'compatible'
|
||||
): number {
|
||||
let dateTime = toCalendarDateTime(date);
|
||||
|
||||
// Fast path: if the time zone is UTC, use native Date.
|
||||
if (timeZone === 'UTC') {
|
||||
return epochFromDate(dateTime);
|
||||
}
|
||||
|
||||
// Fast path: if the time zone is the local timezone and disambiguation is compatible, use native Date.
|
||||
// Skip this fast path if the local timezone was explicitly overridden via setLocalTimeZone,
|
||||
// since native Date always uses the browser's timezone, not the overridden one.
|
||||
if (
|
||||
timeZone === getLocalTimeZone() &&
|
||||
disambiguation === 'compatible' &&
|
||||
!isLocalTimeZoneOverridden()
|
||||
) {
|
||||
dateTime = toCalendar(dateTime, new GregorianCalendar());
|
||||
|
||||
// Don't use Date constructor here because two-digit years are interpreted in the 20th century.
|
||||
let date = new Date();
|
||||
let year = getExtendedYear(dateTime.era, dateTime.year);
|
||||
date.setFullYear(year, dateTime.month - 1, dateTime.day);
|
||||
date.setHours(dateTime.hour, dateTime.minute, dateTime.second, dateTime.millisecond);
|
||||
return date.getTime();
|
||||
}
|
||||
|
||||
let ms = epochFromDate(dateTime);
|
||||
let offsetBefore = getTimeZoneOffset(ms - DAYMILLIS, timeZone);
|
||||
let offsetAfter = getTimeZoneOffset(ms + DAYMILLIS, timeZone);
|
||||
let valid = getValidWallTimes(dateTime, timeZone, ms - offsetBefore, ms - offsetAfter);
|
||||
|
||||
if (valid.length === 1) {
|
||||
return valid[0];
|
||||
}
|
||||
|
||||
if (valid.length > 1) {
|
||||
switch (disambiguation) {
|
||||
// 'compatible' means 'earlier' for "fall back" transitions
|
||||
case 'compatible':
|
||||
case 'earlier':
|
||||
return valid[0];
|
||||
case 'later':
|
||||
return valid[valid.length - 1];
|
||||
case 'reject':
|
||||
throw new RangeError('Multiple possible absolute times found');
|
||||
}
|
||||
}
|
||||
|
||||
switch (disambiguation) {
|
||||
case 'earlier':
|
||||
return Math.min(ms - offsetBefore, ms - offsetAfter);
|
||||
// 'compatible' means 'later' for "spring forward" transitions
|
||||
case 'compatible':
|
||||
case 'later':
|
||||
return Math.max(ms - offsetBefore, ms - offsetAfter);
|
||||
case 'reject':
|
||||
throw new RangeError('No such absolute time found');
|
||||
}
|
||||
}
|
||||
|
||||
export function toDate(
|
||||
dateTime: CalendarDate | CalendarDateTime,
|
||||
timeZone: string,
|
||||
disambiguation: Disambiguation = 'compatible'
|
||||
): Date {
|
||||
return new Date(toAbsolute(dateTime, timeZone, disambiguation));
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a Unix epoch (milliseconds since 1970) and converts it to the provided time zone.
|
||||
*/
|
||||
export function fromAbsolute(ms: number, timeZone: string): ZonedDateTime {
|
||||
let offset = getTimeZoneOffset(ms, timeZone);
|
||||
let date = new Date(ms + offset);
|
||||
let year = date.getUTCFullYear();
|
||||
let month = date.getUTCMonth() + 1;
|
||||
let day = date.getUTCDate();
|
||||
let hour = date.getUTCHours();
|
||||
let minute = date.getUTCMinutes();
|
||||
let second = date.getUTCSeconds();
|
||||
let millisecond = date.getUTCMilliseconds();
|
||||
|
||||
return new ZonedDateTime(
|
||||
year < 1 ? 'BC' : 'AD',
|
||||
year < 1 ? -year + 1 : year,
|
||||
month,
|
||||
day,
|
||||
timeZone,
|
||||
offset,
|
||||
hour,
|
||||
minute,
|
||||
second,
|
||||
millisecond
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a `Date` object and converts it to the provided time zone.
|
||||
*/
|
||||
export function fromDate(date: Date, timeZone: string): ZonedDateTime {
|
||||
return fromAbsolute(date.getTime(), timeZone);
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a `Date` object and converts it to the time zone identifier for the current user.
|
||||
*/
|
||||
export function fromDateToLocal(date: Date): ZonedDateTime {
|
||||
return fromDate(date, getLocalTimeZone());
|
||||
}
|
||||
|
||||
/** Converts a value with date component such as a `CalendarDateTime` or `ZonedDateTime` into a `CalendarDate`. */
|
||||
export function toCalendarDate(dateTime: AnyCalendarDate): CalendarDate {
|
||||
return new CalendarDate(
|
||||
dateTime.calendar,
|
||||
dateTime.era,
|
||||
dateTime.year,
|
||||
dateTime.month,
|
||||
dateTime.day
|
||||
);
|
||||
}
|
||||
|
||||
export function toDateFields(date: AnyCalendarDate): DateFields {
|
||||
return {
|
||||
era: date.era,
|
||||
year: date.year,
|
||||
month: date.month,
|
||||
day: date.day
|
||||
};
|
||||
}
|
||||
|
||||
export function toTimeFields(date: AnyTime): TimeFields {
|
||||
return {
|
||||
hour: date.hour,
|
||||
minute: date.minute,
|
||||
second: date.second,
|
||||
millisecond: date.millisecond
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a date value to a `CalendarDateTime`. An optional `Time` value can be passed to set the time
|
||||
* of the resulting value, otherwise it will default to midnight.
|
||||
*/
|
||||
export function toCalendarDateTime(
|
||||
date: CalendarDate | CalendarDateTime | ZonedDateTime,
|
||||
time?: AnyTime
|
||||
): CalendarDateTime {
|
||||
let hour = 0,
|
||||
minute = 0,
|
||||
second = 0,
|
||||
millisecond = 0;
|
||||
if ('timeZone' in date) {
|
||||
({ hour, minute, second, millisecond } = date);
|
||||
} else if ('hour' in date && !time) {
|
||||
return date;
|
||||
}
|
||||
|
||||
if (time) {
|
||||
({ hour, minute, second, millisecond } = time);
|
||||
}
|
||||
|
||||
return new CalendarDateTime(
|
||||
date.calendar,
|
||||
date.era,
|
||||
date.year,
|
||||
date.month,
|
||||
date.day,
|
||||
hour,
|
||||
minute,
|
||||
second,
|
||||
millisecond
|
||||
);
|
||||
}
|
||||
|
||||
/** Extracts the time component from a value containing a date and time. */
|
||||
export function toTime(dateTime: CalendarDateTime | ZonedDateTime): Time {
|
||||
return new Time(dateTime.hour, dateTime.minute, dateTime.second, dateTime.millisecond);
|
||||
}
|
||||
|
||||
/** Converts a date from one calendar system to another. */
|
||||
export function toCalendar<T extends AnyCalendarDate>(date: T, calendar: Calendar): T {
|
||||
if (isEqualCalendar(date.calendar, calendar)) {
|
||||
return date;
|
||||
}
|
||||
|
||||
let calendarDate = calendar.fromJulianDay(date.calendar.toJulianDay(date));
|
||||
let copy: Mutable<T> = date.copy();
|
||||
copy.calendar = calendar;
|
||||
copy.era = calendarDate.era;
|
||||
copy.year = calendarDate.year;
|
||||
copy.month = calendarDate.month;
|
||||
copy.day = calendarDate.day;
|
||||
constrain(copy);
|
||||
return copy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a date value to a `ZonedDateTime` in the provided time zone. The `disambiguation` option can be set
|
||||
* to control how values that fall on daylight saving time changes are interpreted.
|
||||
*/
|
||||
export function toZoned(
|
||||
date: CalendarDate | CalendarDateTime | ZonedDateTime,
|
||||
timeZone: string,
|
||||
disambiguation?: Disambiguation
|
||||
): ZonedDateTime {
|
||||
if (date instanceof ZonedDateTime) {
|
||||
if (date.timeZone === timeZone) {
|
||||
return date;
|
||||
}
|
||||
|
||||
return toTimeZone(date, timeZone);
|
||||
}
|
||||
|
||||
let ms = toAbsolute(date, timeZone, disambiguation);
|
||||
return fromAbsolute(ms, timeZone);
|
||||
}
|
||||
|
||||
export function zonedToDate(date: ZonedDateTime): Date {
|
||||
let ms = epochFromDate(date) - date.offset;
|
||||
return new Date(ms);
|
||||
}
|
||||
|
||||
/** Converts a `ZonedDateTime` from one time zone to another. */
|
||||
export function toTimeZone(date: ZonedDateTime, timeZone: string): ZonedDateTime {
|
||||
let ms = epochFromDate(date) - date.offset;
|
||||
return toCalendar(fromAbsolute(ms, timeZone), date.calendar);
|
||||
}
|
||||
|
||||
/** Converts the given `ZonedDateTime` into the user's local time zone. */
|
||||
export function toLocalTimeZone(date: ZonedDateTime): ZonedDateTime {
|
||||
return toTimeZone(date, getLocalTimeZone());
|
||||
}
|
||||
|
||||
// ─── DateValue / TimeValue bridge ─────────────────────────────────────────────
|
||||
|
||||
import type { TimeValue } from './queries';
|
||||
|
||||
/**
|
||||
* Converts a `DateValue` to a native `Date`.
|
||||
* Uses the local time zone unless a specific `tz` is provided.
|
||||
*/
|
||||
export function dateValueToDate(
|
||||
dateValue: CalendarDate | CalendarDateTime | ZonedDateTime,
|
||||
tz: string = getLocalTimeZone()
|
||||
): Date {
|
||||
if (dateValue instanceof ZonedDateTime) return dateValue.toDate();
|
||||
return dateValue.toDate(tz);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a `TimeValue` (which may be a bare `Time`) in a `CalendarDateTime`
|
||||
* so it can be used wherever a `DateValue` is required.
|
||||
* The date portion is set to 2020-01-01 as a neutral anchor.
|
||||
*/
|
||||
export function convertTimeValueToDateValue(time: TimeValue): CalendarDateTime | ZonedDateTime {
|
||||
if (time instanceof Time) {
|
||||
return new CalendarDateTime(2020, 1, 1, time.hour, time.minute, time.second, time.millisecond);
|
||||
}
|
||||
return time;
|
||||
}
|
||||
|
||||
/** Extracts a `Time` from any `TimeValue`. */
|
||||
export function convertTimeValueToTime(time: TimeValue): Time {
|
||||
if (time instanceof Time) return time;
|
||||
return new Time(time.hour, time.minute, time.second, time.millisecond);
|
||||
}
|
||||
|
||||
/** Returns an ISO 8601 time string for any `TimeValue`. */
|
||||
export function getISOTimeValue(time: TimeValue): string {
|
||||
return convertTimeValueToTime(time).toString();
|
||||
}
|
||||
@ -0,0 +1,580 @@
|
||||
/*
|
||||
* Copyright 2020 Adobe. All rights reserved.
|
||||
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License. You may obtain a copy
|
||||
* of the License at http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software distributed under
|
||||
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
|
||||
* OF ANY KIND, either express or implied. See the License for the specific language
|
||||
* governing permissions and limitations under the License.
|
||||
*/
|
||||
|
||||
import type {
|
||||
AnyCalendarDate,
|
||||
AnyDateTime,
|
||||
AnyTime,
|
||||
CycleOptions,
|
||||
CycleTimeOptions,
|
||||
DateDuration,
|
||||
DateField,
|
||||
DateFields,
|
||||
DateTimeDuration,
|
||||
Disambiguation,
|
||||
TimeDuration,
|
||||
TimeField,
|
||||
TimeFields
|
||||
} from '../core/types';
|
||||
import { CalendarDate, CalendarDateTime, Time, ZonedDateTime } from '../core/CalendarDate';
|
||||
import {
|
||||
epochFromDate,
|
||||
fromAbsolute,
|
||||
toAbsolute,
|
||||
toCalendar,
|
||||
toCalendarDateTime
|
||||
} from './conversion';
|
||||
import { GregorianCalendar } from '../calendars/GregorianCalendar';
|
||||
import type { Mutable } from '../internal/utils';
|
||||
|
||||
const ONE_HOUR = 3600000;
|
||||
|
||||
export function add(date: CalendarDateTime, duration: DateTimeDuration): CalendarDateTime;
|
||||
export function add(date: CalendarDate, duration: DateDuration): CalendarDate;
|
||||
export function add(
|
||||
date: CalendarDate | CalendarDateTime,
|
||||
duration: DateTimeDuration
|
||||
): CalendarDate | CalendarDateTime;
|
||||
export function add(
|
||||
date: CalendarDate | CalendarDateTime,
|
||||
duration: DateTimeDuration
|
||||
): Mutable<AnyCalendarDate | AnyDateTime> {
|
||||
let mutableDate: Mutable<AnyCalendarDate | AnyDateTime> = date.copy();
|
||||
let days = 'hour' in mutableDate ? addTimeFields(mutableDate, duration) : 0;
|
||||
|
||||
addYears(mutableDate, duration.years || 0);
|
||||
if (mutableDate.calendar.balanceYearMonth) {
|
||||
mutableDate.calendar.balanceYearMonth(mutableDate, date);
|
||||
}
|
||||
|
||||
mutableDate.month += duration.months || 0;
|
||||
|
||||
balanceYearMonth(mutableDate);
|
||||
constrainMonthDay(mutableDate);
|
||||
|
||||
mutableDate.day += (duration.weeks || 0) * 7;
|
||||
mutableDate.day += duration.days || 0;
|
||||
mutableDate.day += days;
|
||||
|
||||
balanceDay(mutableDate);
|
||||
|
||||
if (mutableDate.calendar.balanceDate) {
|
||||
mutableDate.calendar.balanceDate(mutableDate);
|
||||
}
|
||||
|
||||
// Constrain in case adding ended up with a date outside the valid range for the calendar system.
|
||||
// The behavior here is slightly different than when constraining in the `set` function in that
|
||||
// we adjust smaller fields to their minimum/maximum values rather than constraining each field
|
||||
// individually. This matches the general behavior of `add` vs `set` regarding how fields are balanced.
|
||||
if (mutableDate.year < 1) {
|
||||
mutableDate.year = 1;
|
||||
mutableDate.month = 1;
|
||||
mutableDate.day = 1;
|
||||
}
|
||||
|
||||
let maxYear = mutableDate.calendar.getYearsInEra(mutableDate);
|
||||
if (mutableDate.year > maxYear) {
|
||||
let isInverseEra = mutableDate.calendar.isInverseEra?.(mutableDate);
|
||||
mutableDate.year = maxYear;
|
||||
mutableDate.month = isInverseEra ? 1 : mutableDate.calendar.getMonthsInYear(mutableDate);
|
||||
mutableDate.day = isInverseEra ? 1 : mutableDate.calendar.getDaysInMonth(mutableDate);
|
||||
}
|
||||
|
||||
if (mutableDate.month < 1) {
|
||||
mutableDate.month = 1;
|
||||
mutableDate.day = 1;
|
||||
}
|
||||
|
||||
let maxMonth = mutableDate.calendar.getMonthsInYear(mutableDate);
|
||||
if (mutableDate.month > maxMonth) {
|
||||
mutableDate.month = maxMonth;
|
||||
mutableDate.day = mutableDate.calendar.getDaysInMonth(mutableDate);
|
||||
}
|
||||
|
||||
mutableDate.day = Math.max(
|
||||
1,
|
||||
Math.min(mutableDate.calendar.getDaysInMonth(mutableDate), mutableDate.day)
|
||||
);
|
||||
return mutableDate;
|
||||
}
|
||||
|
||||
function addYears(date: Mutable<AnyCalendarDate>, years: number) {
|
||||
if (date.calendar.isInverseEra?.(date)) {
|
||||
years = -years;
|
||||
}
|
||||
|
||||
date.year += years;
|
||||
}
|
||||
|
||||
function balanceYearMonth(date: Mutable<AnyCalendarDate>) {
|
||||
while (date.month < 1) {
|
||||
addYears(date, -1);
|
||||
date.month += date.calendar.getMonthsInYear(date);
|
||||
}
|
||||
|
||||
let monthsInYear = 0;
|
||||
while (date.month > (monthsInYear = date.calendar.getMonthsInYear(date))) {
|
||||
date.month -= monthsInYear;
|
||||
addYears(date, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function balanceDay(date: Mutable<AnyCalendarDate>) {
|
||||
while (date.day < 1) {
|
||||
date.month--;
|
||||
balanceYearMonth(date);
|
||||
date.day += date.calendar.getDaysInMonth(date);
|
||||
}
|
||||
|
||||
while (date.day > date.calendar.getDaysInMonth(date)) {
|
||||
date.day -= date.calendar.getDaysInMonth(date);
|
||||
date.month++;
|
||||
balanceYearMonth(date);
|
||||
}
|
||||
}
|
||||
|
||||
function constrainMonthDay(date: Mutable<AnyCalendarDate>) {
|
||||
date.month = Math.max(1, Math.min(date.calendar.getMonthsInYear(date), date.month));
|
||||
date.day = Math.max(1, Math.min(date.calendar.getDaysInMonth(date), date.day));
|
||||
}
|
||||
|
||||
export function constrain(date: Mutable<AnyCalendarDate>): void {
|
||||
if (date.calendar.constrainDate) {
|
||||
date.calendar.constrainDate(date);
|
||||
}
|
||||
|
||||
date.year = Math.max(1, Math.min(date.calendar.getYearsInEra(date), date.year));
|
||||
constrainMonthDay(date);
|
||||
}
|
||||
|
||||
export function invertDuration(duration: DateTimeDuration): DateTimeDuration {
|
||||
let inverseDuration: Record<string, number> = {};
|
||||
for (let key in duration) {
|
||||
const k = key as keyof DateTimeDuration;
|
||||
if (typeof duration[k] === 'number') {
|
||||
inverseDuration[key] = -(duration[k] as number);
|
||||
}
|
||||
}
|
||||
|
||||
return inverseDuration as DateTimeDuration;
|
||||
}
|
||||
|
||||
export function subtract(date: CalendarDateTime, duration: DateTimeDuration): CalendarDateTime;
|
||||
export function subtract(date: CalendarDate, duration: DateDuration): CalendarDate;
|
||||
export function subtract(
|
||||
date: CalendarDate | CalendarDateTime,
|
||||
duration: DateTimeDuration
|
||||
): CalendarDate | CalendarDateTime {
|
||||
return add(date, invertDuration(duration));
|
||||
}
|
||||
|
||||
export function set(date: CalendarDateTime, fields: DateFields): CalendarDateTime;
|
||||
export function set(date: CalendarDate, fields: DateFields): CalendarDate;
|
||||
export function set(
|
||||
date: CalendarDate | CalendarDateTime,
|
||||
fields: DateFields
|
||||
): Mutable<AnyCalendarDate> {
|
||||
let mutableDate: Mutable<AnyCalendarDate> = date.copy();
|
||||
|
||||
if (fields.era != null) {
|
||||
mutableDate.era = fields.era;
|
||||
}
|
||||
|
||||
if (fields.year != null) {
|
||||
mutableDate.year = fields.year;
|
||||
}
|
||||
|
||||
if (fields.month != null) {
|
||||
mutableDate.month = fields.month;
|
||||
}
|
||||
|
||||
if (fields.day != null) {
|
||||
mutableDate.day = fields.day;
|
||||
}
|
||||
|
||||
constrain(mutableDate);
|
||||
return mutableDate;
|
||||
}
|
||||
|
||||
export function setTime(value: CalendarDateTime, fields: TimeFields): CalendarDateTime;
|
||||
export function setTime(value: Time, fields: TimeFields): Time;
|
||||
export function setTime(
|
||||
value: Time | CalendarDateTime,
|
||||
fields: TimeFields
|
||||
): Mutable<Time | CalendarDateTime> {
|
||||
let mutableValue: Mutable<Time | CalendarDateTime> = value.copy();
|
||||
|
||||
if (fields.hour != null) {
|
||||
mutableValue.hour = fields.hour;
|
||||
}
|
||||
|
||||
if (fields.minute != null) {
|
||||
mutableValue.minute = fields.minute;
|
||||
}
|
||||
|
||||
if (fields.second != null) {
|
||||
mutableValue.second = fields.second;
|
||||
}
|
||||
|
||||
if (fields.millisecond != null) {
|
||||
mutableValue.millisecond = fields.millisecond;
|
||||
}
|
||||
|
||||
constrainTime(mutableValue);
|
||||
return mutableValue;
|
||||
}
|
||||
|
||||
function balanceTime(time: Mutable<AnyTime>): number {
|
||||
time.second += Math.floor(time.millisecond / 1000);
|
||||
time.millisecond = nonNegativeMod(time.millisecond, 1000);
|
||||
|
||||
time.minute += Math.floor(time.second / 60);
|
||||
time.second = nonNegativeMod(time.second, 60);
|
||||
|
||||
time.hour += Math.floor(time.minute / 60);
|
||||
time.minute = nonNegativeMod(time.minute, 60);
|
||||
|
||||
let days = Math.floor(time.hour / 24);
|
||||
time.hour = nonNegativeMod(time.hour, 24);
|
||||
|
||||
return days;
|
||||
}
|
||||
|
||||
export function constrainTime(time: Mutable<AnyTime>): void {
|
||||
time.millisecond = Math.max(0, Math.min(time.millisecond, 1000));
|
||||
time.second = Math.max(0, Math.min(time.second, 59));
|
||||
time.minute = Math.max(0, Math.min(time.minute, 59));
|
||||
time.hour = Math.max(0, Math.min(time.hour, 23));
|
||||
}
|
||||
|
||||
function nonNegativeMod(a: number, b: number) {
|
||||
let result = a % b;
|
||||
if (result < 0) {
|
||||
result += b;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function addTimeFields(time: Mutable<AnyTime>, duration: TimeDuration): number {
|
||||
time.hour += duration.hours || 0;
|
||||
time.minute += duration.minutes || 0;
|
||||
time.second += duration.seconds || 0;
|
||||
time.millisecond += duration.milliseconds || 0;
|
||||
return balanceTime(time);
|
||||
}
|
||||
|
||||
export function addTime(time: Time, duration: TimeDuration): Time {
|
||||
let res = time.copy();
|
||||
addTimeFields(res, duration);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function subtractTime(time: Time, duration: TimeDuration): Time {
|
||||
return addTime(time, invertDuration(duration));
|
||||
}
|
||||
|
||||
export function cycleDate(
|
||||
value: CalendarDateTime,
|
||||
field: DateField,
|
||||
amount: number,
|
||||
options?: CycleOptions
|
||||
): CalendarDateTime;
|
||||
export function cycleDate(
|
||||
value: CalendarDate,
|
||||
field: DateField,
|
||||
amount: number,
|
||||
options?: CycleOptions
|
||||
): CalendarDate;
|
||||
export function cycleDate(
|
||||
value: CalendarDate | CalendarDateTime,
|
||||
field: DateField,
|
||||
amount: number,
|
||||
options?: CycleOptions
|
||||
): Mutable<CalendarDate | CalendarDateTime> {
|
||||
let mutable: Mutable<CalendarDate | CalendarDateTime> = value.copy();
|
||||
|
||||
switch (field) {
|
||||
case 'era': {
|
||||
let eras = value.calendar.getEras();
|
||||
let eraIndex = eras.indexOf(value.era);
|
||||
if (eraIndex < 0) {
|
||||
throw new Error('Invalid era: ' + value.era);
|
||||
}
|
||||
eraIndex = cycleValue(eraIndex, amount, 0, eras.length - 1, options?.round);
|
||||
mutable.era = eras[eraIndex];
|
||||
|
||||
// Constrain the year and other fields within the era, so the era doesn't change when we balance below.
|
||||
constrain(mutable);
|
||||
break;
|
||||
}
|
||||
case 'year': {
|
||||
if (mutable.calendar.isInverseEra?.(mutable)) {
|
||||
amount = -amount;
|
||||
}
|
||||
|
||||
// The year field should not cycle within the era as that can cause weird behavior affecting other fields.
|
||||
// We need to also allow values < 1 so that decrementing goes to the previous era. If we get -Infinity back
|
||||
// we know we wrapped around after reaching 9999 (the maximum), so set the year back to 1.
|
||||
mutable.year = cycleValue(value.year, amount, -Infinity, 9999, options?.round);
|
||||
if (mutable.year === -Infinity) {
|
||||
mutable.year = 1;
|
||||
}
|
||||
|
||||
if (mutable.calendar.balanceYearMonth) {
|
||||
mutable.calendar.balanceYearMonth(mutable, value);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'month':
|
||||
mutable.month = cycleValue(
|
||||
value.month,
|
||||
amount,
|
||||
1,
|
||||
value.calendar.getMonthsInYear(value),
|
||||
options?.round
|
||||
);
|
||||
break;
|
||||
case 'day':
|
||||
mutable.day = cycleValue(
|
||||
value.day,
|
||||
amount,
|
||||
1,
|
||||
value.calendar.getDaysInMonth(value),
|
||||
options?.round
|
||||
);
|
||||
break;
|
||||
default:
|
||||
throw new Error('Unsupported field ' + field);
|
||||
}
|
||||
|
||||
if (value.calendar.balanceDate) {
|
||||
value.calendar.balanceDate(mutable);
|
||||
}
|
||||
|
||||
constrain(mutable);
|
||||
return mutable;
|
||||
}
|
||||
|
||||
export function cycleTime(
|
||||
value: CalendarDateTime,
|
||||
field: TimeField,
|
||||
amount: number,
|
||||
options?: CycleTimeOptions
|
||||
): CalendarDateTime;
|
||||
export function cycleTime(
|
||||
value: Time,
|
||||
field: TimeField,
|
||||
amount: number,
|
||||
options?: CycleTimeOptions
|
||||
): Time;
|
||||
export function cycleTime(
|
||||
value: Time | CalendarDateTime,
|
||||
field: TimeField,
|
||||
amount: number,
|
||||
options?: CycleTimeOptions
|
||||
): Mutable<Time | CalendarDateTime> {
|
||||
let mutable: Mutable<Time | CalendarDateTime> = value.copy();
|
||||
|
||||
switch (field) {
|
||||
case 'hour': {
|
||||
let hours = value.hour;
|
||||
let min = 0;
|
||||
let max = 23;
|
||||
if (options?.hourCycle === 12) {
|
||||
let isPM = hours >= 12;
|
||||
min = isPM ? 12 : 0;
|
||||
max = isPM ? 23 : 11;
|
||||
}
|
||||
mutable.hour = cycleValue(hours, amount, min, max, options?.round);
|
||||
break;
|
||||
}
|
||||
case 'minute':
|
||||
mutable.minute = cycleValue(value.minute, amount, 0, 59, options?.round);
|
||||
break;
|
||||
case 'second':
|
||||
mutable.second = cycleValue(value.second, amount, 0, 59, options?.round);
|
||||
break;
|
||||
case 'millisecond':
|
||||
mutable.millisecond = cycleValue(value.millisecond, amount, 0, 999, options?.round);
|
||||
break;
|
||||
default:
|
||||
throw new Error('Unsupported field ' + field);
|
||||
}
|
||||
|
||||
return mutable;
|
||||
}
|
||||
|
||||
function cycleValue(value: number, amount: number, min: number, max: number, round = false) {
|
||||
if (round) {
|
||||
value += Math.sign(amount);
|
||||
|
||||
if (value < min) {
|
||||
value = max;
|
||||
}
|
||||
|
||||
let div = Math.abs(amount);
|
||||
if (amount > 0) {
|
||||
value = Math.ceil(value / div) * div;
|
||||
} else {
|
||||
value = Math.floor(value / div) * div;
|
||||
}
|
||||
|
||||
if (value > max) {
|
||||
value = min;
|
||||
}
|
||||
} else {
|
||||
value += amount;
|
||||
if (value < min) {
|
||||
value = max - (min - value - 1);
|
||||
} else if (value > max) {
|
||||
value = min + (value - max - 1);
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export function addZoned(dateTime: ZonedDateTime, duration: DateTimeDuration): ZonedDateTime {
|
||||
let ms: number;
|
||||
if (
|
||||
(duration.years != null && duration.years !== 0) ||
|
||||
(duration.months != null && duration.months !== 0) ||
|
||||
(duration.weeks != null && duration.weeks !== 0) ||
|
||||
(duration.days != null && duration.days !== 0)
|
||||
) {
|
||||
let res = add(toCalendarDateTime(dateTime), {
|
||||
years: duration.years,
|
||||
months: duration.months,
|
||||
weeks: duration.weeks,
|
||||
days: duration.days
|
||||
});
|
||||
|
||||
// Changing the date may change the timezone offset, so we need to recompute
|
||||
// using the 'compatible' disambiguation.
|
||||
ms = toAbsolute(res, dateTime.timeZone);
|
||||
} else {
|
||||
// Otherwise, preserve the offset of the original date.
|
||||
ms = epochFromDate(dateTime) - dateTime.offset;
|
||||
}
|
||||
|
||||
// Perform time manipulation in milliseconds rather than on the original time fields to account for DST.
|
||||
// For example, adding one hour during a DST transition may result in the hour field staying the same or
|
||||
// skipping an hour. This results in the offset field changing value instead of the specified field.
|
||||
ms += duration.milliseconds || 0;
|
||||
ms += (duration.seconds || 0) * 1000;
|
||||
ms += (duration.minutes || 0) * 60 * 1000;
|
||||
ms += (duration.hours || 0) * 60 * 60 * 1000;
|
||||
|
||||
let res = fromAbsolute(ms, dateTime.timeZone);
|
||||
return toCalendar(res, dateTime.calendar);
|
||||
}
|
||||
|
||||
export function subtractZoned(dateTime: ZonedDateTime, duration: DateTimeDuration): ZonedDateTime {
|
||||
return addZoned(dateTime, invertDuration(duration));
|
||||
}
|
||||
|
||||
export function cycleZoned(
|
||||
dateTime: ZonedDateTime,
|
||||
field: DateField | TimeField,
|
||||
amount: number,
|
||||
options?: CycleTimeOptions
|
||||
): ZonedDateTime {
|
||||
// For date fields, we want the time to remain consistent and the UTC offset to potentially change to account for DST changes.
|
||||
// For time fields, we want the time to change by the amount given. This may result in the hour field staying the same, but the UTC
|
||||
// offset changing in the case of a backward DST transition, or skipping an hour in the case of a forward DST transition.
|
||||
switch (field) {
|
||||
case 'hour': {
|
||||
let min = 0;
|
||||
let max = 23;
|
||||
if (options?.hourCycle === 12) {
|
||||
let isPM = dateTime.hour >= 12;
|
||||
min = isPM ? 12 : 0;
|
||||
max = isPM ? 23 : 11;
|
||||
}
|
||||
|
||||
// The minimum and maximum hour may be affected by daylight saving time.
|
||||
// For example, it might jump forward at midnight, and skip 1am.
|
||||
// Or it might end at midnight and repeat the 11pm hour. To handle this, we get
|
||||
// the possible absolute times for the min and max, and find the maximum range
|
||||
// that is within the current day.
|
||||
let plainDateTime = toCalendarDateTime(dateTime);
|
||||
let minDate = toCalendar(setTime(plainDateTime, { hour: min }), new GregorianCalendar());
|
||||
let minAbsolute = [
|
||||
toAbsolute(minDate, dateTime.timeZone, 'earlier'),
|
||||
toAbsolute(minDate, dateTime.timeZone, 'later')
|
||||
].filter((ms) => fromAbsolute(ms, dateTime.timeZone).day === minDate.day)[0];
|
||||
|
||||
let maxDate = toCalendar(setTime(plainDateTime, { hour: max }), new GregorianCalendar());
|
||||
let maxAbsolute = [
|
||||
toAbsolute(maxDate, dateTime.timeZone, 'earlier'),
|
||||
toAbsolute(maxDate, dateTime.timeZone, 'later')
|
||||
]
|
||||
.filter((ms) => fromAbsolute(ms, dateTime.timeZone).day === maxDate.day)
|
||||
.pop()!;
|
||||
|
||||
// Since hours may repeat, we need to operate on the absolute time in milliseconds.
|
||||
// This is done in hours from the Unix epoch so that cycleValue works correctly,
|
||||
// and then converted back to milliseconds.
|
||||
let ms = epochFromDate(dateTime) - dateTime.offset;
|
||||
let hours = Math.floor(ms / ONE_HOUR);
|
||||
let remainder = ms % ONE_HOUR;
|
||||
ms =
|
||||
cycleValue(
|
||||
hours,
|
||||
amount,
|
||||
Math.floor(minAbsolute / ONE_HOUR),
|
||||
Math.floor(maxAbsolute / ONE_HOUR),
|
||||
options?.round
|
||||
) *
|
||||
ONE_HOUR +
|
||||
remainder;
|
||||
|
||||
// Now compute the new timezone offset, and convert the absolute time back to local time.
|
||||
return toCalendar(fromAbsolute(ms, dateTime.timeZone), dateTime.calendar);
|
||||
}
|
||||
case 'minute':
|
||||
case 'second':
|
||||
case 'millisecond':
|
||||
// @ts-ignore
|
||||
return cycleTime(dateTime, field, amount, options);
|
||||
case 'era':
|
||||
case 'year':
|
||||
case 'month':
|
||||
case 'day': {
|
||||
let res = cycleDate(toCalendarDateTime(dateTime), field, amount, options);
|
||||
let ms = toAbsolute(res, dateTime.timeZone);
|
||||
return toCalendar(fromAbsolute(ms, dateTime.timeZone), dateTime.calendar);
|
||||
}
|
||||
default:
|
||||
throw new Error('Unsupported field ' + field);
|
||||
}
|
||||
}
|
||||
|
||||
export function setZoned(
|
||||
dateTime: ZonedDateTime,
|
||||
fields: DateFields & TimeFields,
|
||||
disambiguation?: Disambiguation
|
||||
): ZonedDateTime {
|
||||
// Set the date/time fields, and recompute the UTC offset to account for DST changes.
|
||||
// We also need to validate by converting back to a local time in case hours are skipped during forward DST transitions.
|
||||
let plainDateTime = toCalendarDateTime(dateTime);
|
||||
let res = setTime(set(plainDateTime, fields), fields);
|
||||
|
||||
// If the resulting plain date time values are equal, return the original time.
|
||||
// We don't want to change the offset when setting the time to the same value.
|
||||
if (res.compare(plainDateTime) === 0) {
|
||||
return dateTime;
|
||||
}
|
||||
|
||||
let ms = toAbsolute(res, dateTime.timeZone, disambiguation);
|
||||
return toCalendar(fromAbsolute(ms, dateTime.timeZone), dateTime.calendar);
|
||||
}
|
||||
@ -0,0 +1,581 @@
|
||||
/*
|
||||
* Copyright 2020 Adobe. All rights reserved.
|
||||
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License. You may obtain a copy
|
||||
* of the License at http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software distributed under
|
||||
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
|
||||
* OF ANY KIND, either express or implied. See the License for the specific language
|
||||
* governing permissions and limitations under the License.
|
||||
*/
|
||||
|
||||
import type { AnyCalendarDate, AnyTime, Calendar } from '../core/types';
|
||||
import { CalendarDate, CalendarDateTime, Time, ZonedDateTime } from '../core/CalendarDate';
|
||||
import { fromAbsolute, toAbsolute, toCalendar, toCalendarDate } from './conversion';
|
||||
import { weekStartData } from '../internal/weekStartData';
|
||||
|
||||
export type DateValue = CalendarDate | CalendarDateTime | ZonedDateTime;
|
||||
|
||||
/** Returns whether the given dates occur on the same day, regardless of the time or calendar system. */
|
||||
export function isSameDay(a: DateValue, b: DateValue): boolean {
|
||||
b = toCalendar(b, a.calendar);
|
||||
return a.era === b.era && a.year === b.year && a.month === b.month && a.day === b.day;
|
||||
}
|
||||
|
||||
/** Returns whether the given dates occur in the same month, using the calendar system of the first date. */
|
||||
export function isSameMonth(a: DateValue, b: DateValue): boolean {
|
||||
b = toCalendar(b, a.calendar);
|
||||
// In the Japanese calendar, months can span multiple eras/years, so only compare the first of the month.
|
||||
a = startOfMonth(a);
|
||||
b = startOfMonth(b);
|
||||
return a.era === b.era && a.year === b.year && a.month === b.month;
|
||||
}
|
||||
|
||||
/** Returns whether the given dates occur in the same year, using the calendar system of the first date. */
|
||||
export function isSameYear(a: DateValue, b: DateValue): boolean {
|
||||
b = toCalendar(b, a.calendar);
|
||||
a = startOfYear(a);
|
||||
b = startOfYear(b);
|
||||
return a.era === b.era && a.year === b.year;
|
||||
}
|
||||
|
||||
/** Returns whether the given dates occur on the same day, and are of the same calendar system. */
|
||||
export function isEqualDay(a: DateValue, b: DateValue): boolean {
|
||||
return isEqualCalendar(a.calendar, b.calendar) && isSameDay(a, b);
|
||||
}
|
||||
|
||||
/** Returns whether the given dates occur in the same month, and are of the same calendar system. */
|
||||
export function isEqualMonth(a: DateValue, b: DateValue): boolean {
|
||||
return isEqualCalendar(a.calendar, b.calendar) && isSameMonth(a, b);
|
||||
}
|
||||
|
||||
/** Returns whether the given dates occur in the same year, and are of the same calendar system. */
|
||||
export function isEqualYear(a: DateValue, b: DateValue): boolean {
|
||||
return isEqualCalendar(a.calendar, b.calendar) && isSameYear(a, b);
|
||||
}
|
||||
|
||||
/** Returns whether two calendars are the same. */
|
||||
export function isEqualCalendar(a: Calendar, b: Calendar): boolean {
|
||||
return a.isEqual?.(b) ?? b.isEqual?.(a) ?? a.identifier === b.identifier;
|
||||
}
|
||||
|
||||
/** Returns whether the date is today in the given time zone. */
|
||||
export function isToday(date: DateValue, timeZone: string): boolean {
|
||||
return isSameDay(date, today(timeZone));
|
||||
}
|
||||
|
||||
const DAY_MAP = {
|
||||
sun: 0,
|
||||
mon: 1,
|
||||
tue: 2,
|
||||
wed: 3,
|
||||
thu: 4,
|
||||
fri: 5,
|
||||
sat: 6
|
||||
};
|
||||
|
||||
type DayOfWeek = 'sun' | 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat';
|
||||
|
||||
/**
|
||||
* Returns the day of week for the given date and locale. Days are numbered from zero to six,
|
||||
* where zero is the first day of the week in the given locale. For example, in the United States,
|
||||
* the first day of the week is Sunday, but in France it is Monday.
|
||||
*/
|
||||
export function getDayOfWeek(date: DateValue, locale: string, firstDayOfWeek?: DayOfWeek): number {
|
||||
let julian = date.calendar.toJulianDay(date);
|
||||
|
||||
// If julian is negative, then julian % 7 will be negative, so we adjust
|
||||
// accordingly. Julian day 0 is Monday.
|
||||
let weekStart = firstDayOfWeek ? DAY_MAP[firstDayOfWeek] : getWeekStart(locale);
|
||||
let dayOfWeek = Math.ceil(julian + 1 - weekStart) % 7;
|
||||
if (dayOfWeek < 0) {
|
||||
dayOfWeek += 7;
|
||||
}
|
||||
|
||||
return dayOfWeek;
|
||||
}
|
||||
|
||||
/** Returns the current time in the given time zone. */
|
||||
export function now(timeZone: string): ZonedDateTime {
|
||||
return fromAbsolute(Date.now(), timeZone);
|
||||
}
|
||||
|
||||
/** Returns today's date in the given time zone. */
|
||||
export function today(timeZone: string): CalendarDate {
|
||||
return toCalendarDate(now(timeZone));
|
||||
}
|
||||
|
||||
export function compareDate(a: AnyCalendarDate, b: AnyCalendarDate): number {
|
||||
return a.calendar.toJulianDay(a) - b.calendar.toJulianDay(b);
|
||||
}
|
||||
|
||||
export function compareTime(a: AnyTime, b: AnyTime): number {
|
||||
return timeToMs(a) - timeToMs(b);
|
||||
}
|
||||
|
||||
function timeToMs(a: AnyTime): number {
|
||||
return a.hour * 60 * 60 * 1000 + a.minute * 60 * 1000 + a.second * 1000 + a.millisecond;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the number of hours in the given date and time zone.
|
||||
* Usually this is 24, but it could be 23 or 25 if the date is on a daylight saving transition.
|
||||
*/
|
||||
export function getHoursInDay(a: CalendarDate, timeZone: string): number {
|
||||
let ms = toAbsolute(a, timeZone);
|
||||
let tomorrow = a.add({ days: 1 });
|
||||
let tomorrowMs = toAbsolute(tomorrow, timeZone);
|
||||
return (tomorrowMs - ms) / 3600000;
|
||||
}
|
||||
|
||||
let localTimeZone: string | null = null;
|
||||
let localTimeZoneOverride = false;
|
||||
|
||||
/** Returns the time zone identifier for the current user. */
|
||||
export function getLocalTimeZone(): string {
|
||||
if (localTimeZone == null) {
|
||||
localTimeZone = new Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
}
|
||||
|
||||
return localTimeZone!;
|
||||
}
|
||||
|
||||
/** Sets the time zone identifier for the current user. */
|
||||
export function setLocalTimeZone(timeZone: string): void {
|
||||
localTimeZoneOverride = true;
|
||||
localTimeZone = timeZone;
|
||||
}
|
||||
|
||||
/** Resets the time zone identifier for the current user. */
|
||||
export function resetLocalTimeZone(): void {
|
||||
localTimeZoneOverride = false;
|
||||
localTimeZone = null;
|
||||
}
|
||||
|
||||
/** Returns whether the local time zone has been explicitly overridden via `setLocalTimeZone`. */
|
||||
export function isLocalTimeZoneOverridden(): boolean {
|
||||
return localTimeZoneOverride;
|
||||
}
|
||||
|
||||
/** Returns the first date of the month for the given date. */
|
||||
export function startOfMonth(date: ZonedDateTime): ZonedDateTime;
|
||||
export function startOfMonth(date: CalendarDateTime): CalendarDateTime;
|
||||
export function startOfMonth(date: CalendarDate): CalendarDate;
|
||||
export function startOfMonth(date: DateValue): DateValue;
|
||||
export function startOfMonth(date: DateValue): DateValue {
|
||||
// Use `subtract` instead of `set` so we don't get constrained in an era.
|
||||
return date.subtract({ days: date.day - 1 });
|
||||
}
|
||||
|
||||
/** Returns the last date of the month for the given date. */
|
||||
export function endOfMonth(date: ZonedDateTime): ZonedDateTime;
|
||||
export function endOfMonth(date: CalendarDateTime): CalendarDateTime;
|
||||
export function endOfMonth(date: CalendarDate): CalendarDate;
|
||||
export function endOfMonth(date: DateValue): DateValue;
|
||||
export function endOfMonth(date: DateValue): DateValue {
|
||||
return date.add({ days: date.calendar.getDaysInMonth(date) - date.day });
|
||||
}
|
||||
|
||||
/** Returns the first day of the year for the given date. */
|
||||
export function startOfYear(date: ZonedDateTime): ZonedDateTime;
|
||||
export function startOfYear(date: CalendarDateTime): CalendarDateTime;
|
||||
export function startOfYear(date: CalendarDate): CalendarDate;
|
||||
export function startOfYear(date: DateValue): DateValue;
|
||||
export function startOfYear(date: DateValue): DateValue {
|
||||
return startOfMonth(date.subtract({ months: date.month - 1 }));
|
||||
}
|
||||
|
||||
/** Returns the last day of the year for the given date. */
|
||||
export function endOfYear(date: ZonedDateTime): ZonedDateTime;
|
||||
export function endOfYear(date: CalendarDateTime): CalendarDateTime;
|
||||
export function endOfYear(date: CalendarDate): CalendarDate;
|
||||
export function endOfYear(date: DateValue): DateValue;
|
||||
export function endOfYear(date: DateValue): DateValue {
|
||||
return endOfMonth(date.add({ months: date.calendar.getMonthsInYear(date) - date.month }));
|
||||
}
|
||||
|
||||
export function getMinimumMonthInYear(date: AnyCalendarDate): number {
|
||||
if (date.calendar.getMinimumMonthInYear) {
|
||||
return date.calendar.getMinimumMonthInYear(date);
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
export function getMinimumDayInMonth(date: AnyCalendarDate): number {
|
||||
if (date.calendar.getMinimumDayInMonth) {
|
||||
return date.calendar.getMinimumDayInMonth(date);
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
/** Returns the first date of the week for the given date and locale. */
|
||||
export function startOfWeek(
|
||||
date: ZonedDateTime,
|
||||
locale: string,
|
||||
firstDayOfWeek?: DayOfWeek
|
||||
): ZonedDateTime;
|
||||
export function startOfWeek(
|
||||
date: CalendarDateTime,
|
||||
locale: string,
|
||||
firstDayOfWeek?: DayOfWeek
|
||||
): CalendarDateTime;
|
||||
export function startOfWeek(
|
||||
date: CalendarDate,
|
||||
locale: string,
|
||||
firstDayOfWeek?: DayOfWeek
|
||||
): CalendarDate;
|
||||
export function startOfWeek(date: DateValue, locale: string, firstDayOfWeek?: DayOfWeek): DateValue;
|
||||
export function startOfWeek(
|
||||
date: DateValue,
|
||||
locale: string,
|
||||
firstDayOfWeek?: DayOfWeek
|
||||
): DateValue {
|
||||
let dayOfWeek = getDayOfWeek(date, locale, firstDayOfWeek);
|
||||
return date.subtract({ days: dayOfWeek });
|
||||
}
|
||||
|
||||
/** Returns the last date of the week for the given date and locale. */
|
||||
export function endOfWeek(
|
||||
date: ZonedDateTime,
|
||||
locale: string,
|
||||
firstDayOfWeek?: DayOfWeek
|
||||
): ZonedDateTime;
|
||||
export function endOfWeek(
|
||||
date: CalendarDateTime,
|
||||
locale: string,
|
||||
firstDayOfWeek?: DayOfWeek
|
||||
): CalendarDateTime;
|
||||
export function endOfWeek(
|
||||
date: CalendarDate,
|
||||
locale: string,
|
||||
firstDayOfWeek?: DayOfWeek
|
||||
): CalendarDate;
|
||||
export function endOfWeek(date: DateValue, locale: string, firstDayOfWeek?: DayOfWeek): DateValue;
|
||||
export function endOfWeek(date: DateValue, locale: string, firstDayOfWeek?: DayOfWeek): DateValue {
|
||||
return startOfWeek(date, locale, firstDayOfWeek).add({ days: 6 });
|
||||
}
|
||||
|
||||
const cachedRegions = new Map<string, string>();
|
||||
const cachedWeekInfo = new Map<string, { firstDay: number }>();
|
||||
|
||||
function getRegion(locale: string): string | undefined {
|
||||
// If the Intl.Locale API is available, use it to get the region for the locale.
|
||||
// @ts-ignore
|
||||
if (Intl.Locale) {
|
||||
// Constructing an Intl.Locale is expensive, so cache the result.
|
||||
let region = cachedRegions.get(locale);
|
||||
if (!region) {
|
||||
// @ts-ignore
|
||||
region = new Intl.Locale(locale).maximize().region;
|
||||
if (region) {
|
||||
cachedRegions.set(locale, region);
|
||||
}
|
||||
}
|
||||
return region;
|
||||
}
|
||||
|
||||
// If not, just try splitting the string.
|
||||
// If the second part of the locale string is 'u',
|
||||
// then this is a unicode extension, so ignore it.
|
||||
// Otherwise, it should be the region.
|
||||
let part = locale.split('-')[1];
|
||||
return part === 'u' ? undefined : part;
|
||||
}
|
||||
|
||||
function getWeekStart(locale: string): number {
|
||||
// TODO: use Intl.Locale for this once browsers support the weekInfo property
|
||||
// https://github.com/tc39/proposal-intl-locale-info
|
||||
let weekInfo = cachedWeekInfo.get(locale);
|
||||
if (!weekInfo) {
|
||||
if (Intl.Locale) {
|
||||
// @ts-ignore
|
||||
let localeInst = new Intl.Locale(locale);
|
||||
if ('getWeekInfo' in localeInst) {
|
||||
// @ts-expect-error
|
||||
weekInfo = localeInst.getWeekInfo();
|
||||
if (weekInfo) {
|
||||
cachedWeekInfo.set(locale, weekInfo);
|
||||
return weekInfo.firstDay;
|
||||
}
|
||||
}
|
||||
}
|
||||
let region = getRegion(locale);
|
||||
if (locale.includes('-fw-')) {
|
||||
// pull the value for the attribute fw from strings such as en-US-u-ca-iso8601-fw-tue or en-US-u-ca-iso8601-fw-mon-nu-thai
|
||||
// where the fw attribute could be followed by another unicode locale extension or not
|
||||
let day = locale.split('-fw-')[1].split('-')[0];
|
||||
if (day === 'mon') {
|
||||
weekInfo = { firstDay: 1 };
|
||||
} else if (day === 'tue') {
|
||||
weekInfo = { firstDay: 2 };
|
||||
} else if (day === 'wed') {
|
||||
weekInfo = { firstDay: 3 };
|
||||
} else if (day === 'thu') {
|
||||
weekInfo = { firstDay: 4 };
|
||||
} else if (day === 'fri') {
|
||||
weekInfo = { firstDay: 5 };
|
||||
} else if (day === 'sat') {
|
||||
weekInfo = { firstDay: 6 };
|
||||
} else {
|
||||
weekInfo = { firstDay: 0 };
|
||||
}
|
||||
} else if (locale.includes('-ca-iso8601')) {
|
||||
weekInfo = { firstDay: 1 };
|
||||
} else {
|
||||
weekInfo = {
|
||||
firstDay: region ? weekStartData[region as keyof typeof weekStartData] || 0 : 0
|
||||
};
|
||||
}
|
||||
cachedWeekInfo.set(locale, weekInfo);
|
||||
}
|
||||
|
||||
return weekInfo.firstDay;
|
||||
}
|
||||
|
||||
/** Returns the number of weeks in the given month and locale. */
|
||||
export function getWeeksInMonth(
|
||||
date: DateValue,
|
||||
locale: string,
|
||||
firstDayOfWeek?: DayOfWeek
|
||||
): number {
|
||||
let days = date.calendar.getDaysInMonth(date);
|
||||
return Math.ceil((getDayOfWeek(startOfMonth(date), locale, firstDayOfWeek) + days) / 7);
|
||||
}
|
||||
|
||||
/** Returns the lesser of the two provider dates. */
|
||||
export function minDate<A extends DateValue, B extends DateValue>(
|
||||
a?: A | null,
|
||||
b?: B | null
|
||||
): A | B | null | undefined {
|
||||
if (a && b) {
|
||||
return a.compare(b) <= 0 ? a : b;
|
||||
}
|
||||
|
||||
return a || b;
|
||||
}
|
||||
|
||||
/** Returns the greater of the two provider dates. */
|
||||
export function maxDate<A extends DateValue, B extends DateValue>(
|
||||
a?: A | null,
|
||||
b?: B | null
|
||||
): A | B | null | undefined {
|
||||
if (a && b) {
|
||||
return a.compare(b) >= 0 ? a : b;
|
||||
}
|
||||
|
||||
return a || b;
|
||||
}
|
||||
|
||||
const WEEKEND_DATA = {
|
||||
AF: [4, 5],
|
||||
AE: [5, 6],
|
||||
BH: [5, 6],
|
||||
DZ: [5, 6],
|
||||
EG: [5, 6],
|
||||
IL: [5, 6],
|
||||
IQ: [5, 6],
|
||||
IR: [5, 5],
|
||||
JO: [5, 6],
|
||||
KW: [5, 6],
|
||||
LY: [5, 6],
|
||||
OM: [5, 6],
|
||||
QA: [5, 6],
|
||||
SA: [5, 6],
|
||||
SD: [5, 6],
|
||||
SY: [5, 6],
|
||||
YE: [5, 6]
|
||||
};
|
||||
|
||||
/** Returns whether the given date is on a weekend in the given locale. */
|
||||
export function isWeekend(date: DateValue, locale: string): boolean {
|
||||
let julian = date.calendar.toJulianDay(date);
|
||||
|
||||
// If julian is negative, then julian % 7 will be negative, so we adjust
|
||||
// accordingly. Julian day 0 is Monday.
|
||||
let dayOfWeek = Math.ceil(julian + 1) % 7;
|
||||
if (dayOfWeek < 0) {
|
||||
dayOfWeek += 7;
|
||||
}
|
||||
|
||||
let region = getRegion(locale);
|
||||
// Use Intl.Locale for this once weekInfo is supported.
|
||||
// https://github.com/tc39/proposal-intl-locale-info
|
||||
let [start, end] = (region ? WEEKEND_DATA[region as keyof typeof WEEKEND_DATA] : undefined) || [
|
||||
6, 0
|
||||
];
|
||||
return dayOfWeek === start || dayOfWeek === end;
|
||||
}
|
||||
|
||||
/** Returns whether the given date is on a weekday in the given locale. */
|
||||
export function isWeekday(date: DateValue, locale: string): boolean {
|
||||
return !isWeekend(date, locale);
|
||||
}
|
||||
|
||||
// ─── Domain types ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Union of all date+time value types. */
|
||||
export type TimeValue = Time | CalendarDateTime | ZonedDateTime;
|
||||
|
||||
/** A date range with optional start and end. */
|
||||
export type DateRange = {
|
||||
start: DateValue | undefined;
|
||||
end: DateValue | undefined;
|
||||
};
|
||||
|
||||
/** A time range with optional start and end. */
|
||||
export type TimeRange<T extends TimeValue = Time> = {
|
||||
start: T | undefined;
|
||||
end: T | undefined;
|
||||
};
|
||||
|
||||
/** A predicate that returns true if the given date matches some criteria. */
|
||||
export type DateMatcher = (date: DateValue) => boolean;
|
||||
|
||||
/**
|
||||
* A function that returns validation errors for a date, or nothing if valid.
|
||||
*/
|
||||
export type DateValidator = (date: DateValue) => string[] | string | void;
|
||||
|
||||
/**
|
||||
* A function that returns validation errors for a date range, or nothing if valid.
|
||||
*/
|
||||
export type DateRangeValidator = (range: {
|
||||
start: DateValue;
|
||||
end: DateValue;
|
||||
}) => string[] | string | void;
|
||||
|
||||
/** A function that returns validation errors for a time value, or nothing if valid. */
|
||||
export type TimeValidator<T extends TimeValue> = (time: T) => string[] | string | void;
|
||||
|
||||
/** A function that returns validation errors for a time range, or nothing if valid. */
|
||||
export type TimeRangeValidator<T extends TimeValue = Time> = (range: {
|
||||
start: T;
|
||||
end: T;
|
||||
}) => string[] | string | void;
|
||||
|
||||
/** Callback fired when a date value is determined to be invalid. */
|
||||
export type DateOnInvalid = (reason: 'min' | 'max' | 'custom', msg?: string | string[]) => void;
|
||||
|
||||
/** Callback fired when a time value is determined to be invalid. */
|
||||
export type TimeOnInvalid = (reason: 'min' | 'max' | 'custom', msg?: string | string[]) => void;
|
||||
|
||||
/**
|
||||
* Represents a single month in a calendar grid.
|
||||
* - `value` — the DateValue that identifies this month.
|
||||
* - `weeks` — rows of dates (T[][]) for rendering a table grid.
|
||||
* - `dates` — flat array of all dates shown in the grid.
|
||||
*/
|
||||
export type Month<T> = {
|
||||
value: DateValue;
|
||||
weeks: T[][];
|
||||
dates: T[];
|
||||
};
|
||||
|
||||
// ─── Comparison helpers ───────────────────────────────────────────────────────
|
||||
|
||||
function isBeforeOrSame(dateToCompare: DateValue, referenceDate: DateValue): boolean {
|
||||
return dateToCompare.compare(referenceDate) <= 0;
|
||||
}
|
||||
|
||||
function isAfterOrSame(dateToCompare: DateValue, referenceDate: DateValue): boolean {
|
||||
return dateToCompare.compare(referenceDate) >= 0;
|
||||
}
|
||||
|
||||
/** Returns whether `dateToCompare` is strictly before `referenceDate`. */
|
||||
export function isBefore(dateToCompare: DateValue, referenceDate: DateValue): boolean {
|
||||
return dateToCompare.compare(referenceDate) < 0;
|
||||
}
|
||||
|
||||
/** Returns whether `dateToCompare` is strictly after `referenceDate`. */
|
||||
export function isAfter(dateToCompare: DateValue, referenceDate: DateValue): boolean {
|
||||
return dateToCompare.compare(referenceDate) > 0;
|
||||
}
|
||||
|
||||
/** Returns whether `date` is inclusively between `start` and `end`. */
|
||||
export function isBetweenInclusive(date: DateValue, start: DateValue, end: DateValue): boolean {
|
||||
return isAfterOrSame(date, start) && isBeforeOrSame(date, end);
|
||||
}
|
||||
|
||||
/** Type guard — returns true if `dateValue` is a `ZonedDateTime`. */
|
||||
export function isZonedDateTime(dateValue: DateValue | TimeValue): dateValue is ZonedDateTime {
|
||||
return dateValue instanceof ZonedDateTime;
|
||||
}
|
||||
|
||||
/** Returns true if `dateValue` contains time component. */
|
||||
export function hasTime(dateValue: DateValue): dateValue is CalendarDateTime | ZonedDateTime {
|
||||
return dateValue instanceof CalendarDateTime || dateValue instanceof ZonedDateTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the number of days in the month of the given date.
|
||||
* Accepts a native `Date` or a `DateValue`.
|
||||
*/
|
||||
export function getDaysInMonth(date: Date | DateValue): number {
|
||||
if (date instanceof Date) {
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth() + 1;
|
||||
return new Date(year, month, 0).getDate();
|
||||
}
|
||||
return date.set({ day: 100 }).day;
|
||||
}
|
||||
|
||||
/** Returns the first day of the week that contains `date`. */
|
||||
export function getLastFirstDayOfWeek<T extends DateValue = DateValue>(
|
||||
date: T,
|
||||
firstDayOfWeek: number,
|
||||
locale: string
|
||||
): T {
|
||||
const day = getDayOfWeek(date, locale);
|
||||
if (firstDayOfWeek > day) return date.subtract({ days: day + 7 - firstDayOfWeek }) as T;
|
||||
if (firstDayOfWeek === day) return date as T;
|
||||
return date.subtract({ days: day - firstDayOfWeek }) as T;
|
||||
}
|
||||
|
||||
/** Returns the last day of the week that contains `date`. */
|
||||
export function getNextLastDayOfWeek<T extends DateValue = DateValue>(
|
||||
date: T,
|
||||
firstDayOfWeek: number,
|
||||
locale: string
|
||||
): T {
|
||||
const day = getDayOfWeek(date, locale);
|
||||
const lastDayOfWeek = firstDayOfWeek === 0 ? 6 : firstDayOfWeek - 1;
|
||||
if (day === lastDayOfWeek) return date as T;
|
||||
if (day > lastDayOfWeek) return date.add({ days: 7 - day + lastDayOfWeek }) as T;
|
||||
return date.add({ days: lastDayOfWeek - day }) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if all days between `start` and `end` pass the provided
|
||||
* `isUnavailable` and `isDisabled` matchers (or if no matchers are given).
|
||||
*/
|
||||
export function areAllDaysBetweenValid(
|
||||
start: DateValue,
|
||||
end: DateValue,
|
||||
isUnavailable: DateMatcher | undefined,
|
||||
isDisabled: DateMatcher | undefined
|
||||
): boolean {
|
||||
if (isUnavailable === undefined && isDisabled === undefined) return true;
|
||||
let dCurrent = start.add({ days: 1 });
|
||||
if (isDisabled?.(dCurrent) || isUnavailable?.(dCurrent)) return false;
|
||||
const dEnd = end;
|
||||
while (dCurrent.compare(dEnd) < 0) {
|
||||
dCurrent = dCurrent.add({ days: 1 });
|
||||
if (isDisabled?.(dCurrent) || isUnavailable?.(dCurrent)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── Time comparison helpers ──────────────────────────────────────────────────
|
||||
|
||||
/** Returns whether `timeToCompare` is strictly before `referenceTime`. */
|
||||
export function isTimeBefore(timeToCompare: Time, referenceTime: Time): boolean {
|
||||
return timeToCompare.compare(referenceTime) < 0;
|
||||
}
|
||||
|
||||
/** Returns whether `timeToCompare` is strictly after `referenceTime`. */
|
||||
export function isTimeAfter(timeToCompare: Time, referenceTime: Time): boolean {
|
||||
return timeToCompare.compare(referenceTime) > 0;
|
||||
}
|
||||
@ -0,0 +1,372 @@
|
||||
/*
|
||||
* Copyright 2020 Adobe. All rights reserved.
|
||||
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License. You may obtain a copy
|
||||
* of the License at http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software distributed under
|
||||
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
|
||||
* OF ANY KIND, either express or implied. See the License for the specific language
|
||||
* governing permissions and limitations under the License.
|
||||
*/
|
||||
|
||||
import type { AnyDateTime, DateTimeDuration, Disambiguation } from '../core/types';
|
||||
import { CalendarDate, CalendarDateTime, Time, ZonedDateTime } from '../core/CalendarDate';
|
||||
import {
|
||||
epochFromDate,
|
||||
fromAbsolute,
|
||||
possibleAbsolutes,
|
||||
toAbsolute,
|
||||
toCalendar,
|
||||
toCalendarDateTime,
|
||||
toTimeZone
|
||||
} from './conversion';
|
||||
import { getLocalTimeZone } from './queries';
|
||||
import { GregorianCalendar } from '../calendars/GregorianCalendar';
|
||||
import type { Mutable } from '../internal/utils';
|
||||
|
||||
const TIME_RE = /^(\d{2})(?::(\d{2}))?(?::(\d{2}))?(\.\d+)?$/;
|
||||
const DATE_RE = /^([+-]\d{6}|\d{4})-(\d{2})-(\d{2})$/;
|
||||
const DATE_TIME_RE =
|
||||
/^([+-]\d{6}|\d{4})-(\d{2})-(\d{2})(?:T(\d{2}))?(?::(\d{2}))?(?::(\d{2}))?(\.\d+)?$/;
|
||||
const ZONED_DATE_TIME_RE =
|
||||
/^([+-]\d{6}|\d{4})-(\d{2})-(\d{2})(?:T(\d{2}))?(?::(\d{2}))?(?::(\d{2}))?(\.\d+)?(?:([+-]\d{2})(?::?(\d{2}))?(?::?(\d{2}))?)?\[(.*?)\]$/;
|
||||
const ABSOLUTE_RE =
|
||||
/^([+-]\d{6}|\d{4})-(\d{2})-(\d{2})(?:T(\d{2}))?(?::(\d{2}))?(?::(\d{2}))?(\.\d+)?(?:(?:([+-]\d{2})(?::?(\d{2}))?)|Z)$/;
|
||||
const DATE_TIME_DURATION_RE =
|
||||
/^((?<negative>-)|\+)?P((?<years>\d*)Y)?((?<months>\d*)M)?((?<weeks>\d*)W)?((?<days>\d*)D)?((?<time>T)((?<hours>\d*[.,]?\d{1,9})H)?((?<minutes>\d*[.,]?\d{1,9})M)?((?<seconds>\d*[.,]?\d{1,9})S)?)?$/;
|
||||
const requiredDurationTimeGroups = ['hours', 'minutes', 'seconds'];
|
||||
const requiredDurationGroups = ['years', 'months', 'weeks', 'days', ...requiredDurationTimeGroups];
|
||||
|
||||
/** Parses an ISO 8601 time string. */
|
||||
export function parseTime(value: string): Time {
|
||||
let m = value.match(TIME_RE);
|
||||
if (!m) {
|
||||
throw new Error('Invalid ISO 8601 time string: ' + value);
|
||||
}
|
||||
|
||||
return new Time(
|
||||
parseNumber(m[1], 0, 23),
|
||||
m[2] ? parseNumber(m[2], 0, 59) : 0,
|
||||
m[3] ? parseNumber(m[3], 0, 59) : 0,
|
||||
m[4] ? parseNumber(m[4], 0, Infinity) * 1000 : 0
|
||||
);
|
||||
}
|
||||
|
||||
/** Parses an ISO 8601 date string, with no time component. */
|
||||
export function parseDate(value: string): CalendarDate {
|
||||
let m = value.match(DATE_RE);
|
||||
if (!m) {
|
||||
if (ABSOLUTE_RE.test(value)) {
|
||||
throw new Error(`Invalid ISO 8601 date string: ${value}. Use parseAbsolute() instead.`);
|
||||
}
|
||||
throw new Error('Invalid ISO 8601 date string: ' + value);
|
||||
}
|
||||
|
||||
let date: Mutable<CalendarDate> = new CalendarDate(
|
||||
parseNumber(m[1], 0, 9999),
|
||||
parseNumber(m[2], 1, 12),
|
||||
1
|
||||
);
|
||||
|
||||
date.day = parseNumber(m[3], 1, date.calendar.getDaysInMonth(date));
|
||||
return date as CalendarDate;
|
||||
}
|
||||
|
||||
/** Parses an ISO 8601 date and time string, with no time zone. */
|
||||
export function parseDateTime(value: string): CalendarDateTime {
|
||||
let m = value.match(DATE_TIME_RE);
|
||||
if (!m) {
|
||||
if (ABSOLUTE_RE.test(value)) {
|
||||
throw new Error(`Invalid ISO 8601 date time string: ${value}. Use parseAbsolute() instead.`);
|
||||
}
|
||||
throw new Error('Invalid ISO 8601 date time string: ' + value);
|
||||
}
|
||||
|
||||
let year = parseNumber(m[1], -9999, 9999);
|
||||
let era = year < 1 ? 'BC' : 'AD';
|
||||
|
||||
let date: Mutable<CalendarDateTime> = new CalendarDateTime(
|
||||
era,
|
||||
year < 1 ? -year + 1 : year,
|
||||
parseNumber(m[2], 1, 12),
|
||||
1,
|
||||
m[4] ? parseNumber(m[4], 0, 23) : 0,
|
||||
m[5] ? parseNumber(m[5], 0, 59) : 0,
|
||||
m[6] ? parseNumber(m[6], 0, 59) : 0,
|
||||
m[7] ? parseNumber(m[7], 0, Infinity) * 1000 : 0
|
||||
);
|
||||
|
||||
date.day = parseNumber(m[3], 0, date.calendar.getDaysInMonth(date));
|
||||
return date as CalendarDateTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an ISO 8601 date and time string with a time zone extension and optional UTC offset
|
||||
* (e.g. "2021-11-07T00:45[America/Los_Angeles]" or "2021-11-07T00:45-07:00[America/Los_Angeles]").
|
||||
* Ambiguous times due to daylight saving time transitions are resolved according to the `disambiguation`
|
||||
* parameter.
|
||||
*/
|
||||
export function parseZonedDateTime(value: string, disambiguation?: Disambiguation): ZonedDateTime {
|
||||
let m = value.match(ZONED_DATE_TIME_RE);
|
||||
if (!m) {
|
||||
throw new Error('Invalid ISO 8601 date time string: ' + value);
|
||||
}
|
||||
|
||||
let year = parseNumber(m[1], -9999, 9999);
|
||||
let era = year < 1 ? 'BC' : 'AD';
|
||||
|
||||
let date: Mutable<ZonedDateTime> = new ZonedDateTime(
|
||||
era,
|
||||
year < 1 ? -year + 1 : year,
|
||||
parseNumber(m[2], 1, 12),
|
||||
1,
|
||||
m[11],
|
||||
0,
|
||||
m[4] ? parseNumber(m[4], 0, 23) : 0,
|
||||
m[5] ? parseNumber(m[5], 0, 59) : 0,
|
||||
m[6] ? parseNumber(m[6], 0, 59) : 0,
|
||||
m[7] ? parseNumber(m[7], 0, Infinity) * 1000 : 0
|
||||
);
|
||||
|
||||
date.day = parseNumber(m[3], 0, date.calendar.getDaysInMonth(date));
|
||||
|
||||
let plainDateTime = toCalendarDateTime(date as ZonedDateTime);
|
||||
|
||||
let ms: number;
|
||||
if (m[8]) {
|
||||
let hourOffset = parseNumber(m[8], -23, 23);
|
||||
date.offset =
|
||||
Math.sign(hourOffset) *
|
||||
(Math.abs(hourOffset) * 60 * 60 * 1000 +
|
||||
parseNumber(m[9] ?? '0', 0, 59) * 60 * 1000 +
|
||||
parseNumber(m[10] ?? '0', 0, 59) * 1000);
|
||||
ms = epochFromDate(date as ZonedDateTime) - date.offset;
|
||||
|
||||
// Validate offset against parsed date.
|
||||
let absolutes = possibleAbsolutes(plainDateTime, date.timeZone);
|
||||
if (!absolutes.includes(ms)) {
|
||||
throw new Error(
|
||||
`Offset ${offsetToString(date.offset)} is invalid for ${dateTimeToString(date)} in ${date.timeZone}`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Convert to absolute and back to fix invalid times due to DST.
|
||||
ms = toAbsolute(toCalendarDateTime(plainDateTime), date.timeZone, disambiguation);
|
||||
}
|
||||
|
||||
return fromAbsolute(ms, date.timeZone);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an ISO 8601 date and time string with a UTC offset (e.g. "2021-11-07T07:45:00Z"
|
||||
* or "2021-11-07T07:45:00-07:00"). The result is converted to the provided time zone.
|
||||
*/
|
||||
export function parseAbsolute(value: string, timeZone: string): ZonedDateTime {
|
||||
let m = value.match(ABSOLUTE_RE);
|
||||
if (!m) {
|
||||
throw new Error('Invalid ISO 8601 date time string: ' + value);
|
||||
}
|
||||
|
||||
let year = parseNumber(m[1], -9999, 9999);
|
||||
let era = year < 1 ? 'BC' : 'AD';
|
||||
|
||||
let date: Mutable<ZonedDateTime> = new ZonedDateTime(
|
||||
era,
|
||||
year < 1 ? -year + 1 : year,
|
||||
parseNumber(m[2], 1, 12),
|
||||
1,
|
||||
timeZone,
|
||||
0,
|
||||
m[4] ? parseNumber(m[4], 0, 23) : 0,
|
||||
m[5] ? parseNumber(m[5], 0, 59) : 0,
|
||||
m[6] ? parseNumber(m[6], 0, 59) : 0,
|
||||
m[7] ? parseNumber(m[7], 0, Infinity) * 1000 : 0
|
||||
);
|
||||
|
||||
date.day = parseNumber(m[3], 0, date.calendar.getDaysInMonth(date));
|
||||
|
||||
if (m[8]) {
|
||||
date.offset =
|
||||
parseNumber(m[8], -23, 23) * 60 * 60 * 1000 + parseNumber(m[9] ?? '0', 0, 59) * 60 * 1000;
|
||||
}
|
||||
|
||||
return toTimeZone(date as ZonedDateTime, timeZone);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an ISO 8601 date and time string with a UTC offset (e.g. "2021-11-07T07:45:00Z"
|
||||
* or "2021-11-07T07:45:00-07:00"). The result is converted to the user's local time zone.
|
||||
*/
|
||||
export function parseAbsoluteToLocal(value: string): ZonedDateTime {
|
||||
return parseAbsolute(value, getLocalTimeZone());
|
||||
}
|
||||
|
||||
function parseNumber(value: string, min: number, max: number) {
|
||||
let val = Number(value);
|
||||
if (val < min || val > max) {
|
||||
throw new RangeError(`Value out of range: ${min} <= ${val} <= ${max}`);
|
||||
}
|
||||
|
||||
return val;
|
||||
}
|
||||
|
||||
export function timeToString(time: Time): string {
|
||||
return `${String(time.hour).padStart(2, '0')}:${String(time.minute).padStart(2, '0')}:${String(time.second).padStart(2, '0')}${time.millisecond ? String(time.millisecond / 1000).slice(1) : ''}`;
|
||||
}
|
||||
|
||||
export function dateToString(date: CalendarDate): string {
|
||||
let gregorianDate = toCalendar(date, new GregorianCalendar());
|
||||
let year: string;
|
||||
if (gregorianDate.era === 'BC') {
|
||||
year =
|
||||
gregorianDate.year === 1
|
||||
? '0000'
|
||||
: '-' + String(Math.abs(1 - gregorianDate.year)).padStart(6, '00');
|
||||
} else {
|
||||
year = String(gregorianDate.year).padStart(4, '0');
|
||||
}
|
||||
return `${year}-${String(gregorianDate.month).padStart(2, '0')}-${String(gregorianDate.day).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function dateTimeToString(date: AnyDateTime): string {
|
||||
// @ts-ignore
|
||||
return `${dateToString(date)}T${timeToString(date)}`;
|
||||
}
|
||||
|
||||
function offsetToString(offset: number) {
|
||||
let sign = Math.sign(offset) < 0 ? '-' : '+';
|
||||
offset = Math.abs(offset);
|
||||
let offsetHours = Math.floor(offset / (60 * 60 * 1000));
|
||||
let offsetMinutes = Math.floor((offset % (60 * 60 * 1000)) / (60 * 1000));
|
||||
let offsetSeconds = Math.floor(((offset % (60 * 60 * 1000)) % (60 * 1000)) / 1000);
|
||||
let stringOffset = `${sign}${String(offsetHours).padStart(2, '0')}:${String(offsetMinutes).padStart(2, '0')}`;
|
||||
if (offsetSeconds !== 0) {
|
||||
stringOffset += `:${String(offsetSeconds).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
return stringOffset;
|
||||
}
|
||||
|
||||
export function zonedDateTimeToString(date: ZonedDateTime): string {
|
||||
return `${dateTimeToString(date)}${offsetToString(date.offset)}[${date.timeZone}]`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an ISO 8601 duration string (e.g. "P3Y6M6W4DT12H30M5S").
|
||||
* @param value An ISO 8601 duration string.
|
||||
* @returns A DateTimeDuration object.
|
||||
*/
|
||||
export function parseDuration(value: string): Required<DateTimeDuration> {
|
||||
const match = value.match(DATE_TIME_DURATION_RE);
|
||||
|
||||
if (!match) {
|
||||
throw new Error(`Invalid ISO 8601 Duration string: ${value}`);
|
||||
}
|
||||
|
||||
const parseDurationGroup = (group: string | undefined, isNegative: boolean): number => {
|
||||
if (!group) {
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
const sign = isNegative ? -1 : 1;
|
||||
return sign * Number(group.replace(',', '.'));
|
||||
} catch {
|
||||
throw new Error(`Invalid ISO 8601 Duration string: ${value}`);
|
||||
}
|
||||
};
|
||||
|
||||
const isNegative = !!match.groups?.negative;
|
||||
|
||||
const hasRequiredGroups = requiredDurationGroups.some((group) => match.groups?.[group]);
|
||||
|
||||
if (!hasRequiredGroups) {
|
||||
throw new Error(`Invalid ISO 8601 Duration string: ${value}`);
|
||||
}
|
||||
|
||||
const durationStringIncludesTime = match.groups?.time;
|
||||
|
||||
if (durationStringIncludesTime) {
|
||||
const hasRequiredDurationTimeGroups = requiredDurationTimeGroups.some(
|
||||
(group) => match.groups?.[group]
|
||||
);
|
||||
if (!hasRequiredDurationTimeGroups) {
|
||||
throw new Error(`Invalid ISO 8601 Duration string: ${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
const duration: Mutable<DateTimeDuration> = {
|
||||
years: parseDurationGroup(match.groups?.years, isNegative),
|
||||
months: parseDurationGroup(match.groups?.months, isNegative),
|
||||
weeks: parseDurationGroup(match.groups?.weeks, isNegative),
|
||||
days: parseDurationGroup(match.groups?.days, isNegative),
|
||||
hours: parseDurationGroup(match.groups?.hours, isNegative),
|
||||
minutes: parseDurationGroup(match.groups?.minutes, isNegative),
|
||||
seconds: parseDurationGroup(match.groups?.seconds, isNegative)
|
||||
};
|
||||
|
||||
if (
|
||||
duration.hours !== undefined &&
|
||||
duration.hours % 1 !== 0 &&
|
||||
(duration.minutes || duration.seconds)
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid ISO 8601 Duration string: ${value} - only the smallest unit can be fractional`
|
||||
);
|
||||
}
|
||||
|
||||
if (duration.minutes !== undefined && duration.minutes % 1 !== 0 && duration.seconds) {
|
||||
throw new Error(
|
||||
`Invalid ISO 8601 Duration string: ${value} - only the smallest unit can be fractional`
|
||||
);
|
||||
}
|
||||
|
||||
return duration as Required<DateTimeDuration>;
|
||||
}
|
||||
|
||||
// ─── DateValue string utilities ───────────────────────────────────────────────
|
||||
|
||||
import type { DateValue } from './queries';
|
||||
|
||||
/** Returns a string identifier for the concrete type of a `DateValue`. */
|
||||
export function getDateValueType(date: DateValue): 'date' | 'datetime' | 'zoneddatetime' {
|
||||
if (date instanceof CalendarDate) return 'date';
|
||||
if (date instanceof CalendarDateTime) return 'datetime';
|
||||
if (date instanceof ZonedDateTime) return 'zoneddatetime';
|
||||
throw new Error('Unknown DateValue type');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an ISO 8601 string to the same concrete type as `referenceVal`,
|
||||
* and converts the result to the same calendar system.
|
||||
*/
|
||||
export function parseStringToDateValue(dateStr: string, referenceVal: DateValue): DateValue {
|
||||
let dateValue: DateValue;
|
||||
if (referenceVal instanceof ZonedDateTime) dateValue = parseZonedDateTime(dateStr);
|
||||
else if (referenceVal instanceof CalendarDateTime) dateValue = parseDateTime(dateStr);
|
||||
else dateValue = parseDate(dateStr);
|
||||
|
||||
return dateValue.calendar !== referenceVal.calendar
|
||||
? toCalendar(dateValue, referenceVal.calendar)
|
||||
: dateValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an ISO 8601 string to the `DateValue` type identified by `type`
|
||||
* (as returned by `getDateValueType`).
|
||||
*/
|
||||
export function parseAnyDateValue(
|
||||
value: string,
|
||||
type: 'date' | 'datetime' | 'zoneddatetime'
|
||||
): DateValue {
|
||||
switch (type) {
|
||||
case 'date':
|
||||
return parseDate(value);
|
||||
case 'datetime':
|
||||
return parseDateTime(value);
|
||||
case 'zoneddatetime':
|
||||
return parseZonedDateTime(value);
|
||||
default:
|
||||
throw new Error(`Unknown DateValue type: ${type}`);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Calendar systems. Consumers import a specific calendar when they need to
|
||||
* construct dates outside the default Gregorian system, or use
|
||||
* `createCalendar(identifier)` for dynamic resolution.
|
||||
*/
|
||||
|
||||
export { GregorianCalendar } from './_vendor/calendars/GregorianCalendar';
|
||||
export { JapaneseCalendar } from './_vendor/calendars/JapaneseCalendar';
|
||||
export { BuddhistCalendar } from './_vendor/calendars/BuddhistCalendar';
|
||||
export { TaiwanCalendar } from './_vendor/calendars/TaiwanCalendar';
|
||||
export { PersianCalendar } from './_vendor/calendars/PersianCalendar';
|
||||
export { IndianCalendar } from './_vendor/calendars/IndianCalendar';
|
||||
export {
|
||||
IslamicCivilCalendar,
|
||||
IslamicTabularCalendar,
|
||||
IslamicUmalquraCalendar
|
||||
} from './_vendor/calendars/IslamicCalendar';
|
||||
export { HebrewCalendar } from './_vendor/calendars/HebrewCalendar';
|
||||
export {
|
||||
EthiopicCalendar,
|
||||
EthiopicAmeteAlemCalendar,
|
||||
CopticCalendar
|
||||
} from './_vendor/calendars/EthiopicCalendar';
|
||||
|
||||
export { createCalendar } from './_vendor/core/createCalendar';
|
||||
@ -0,0 +1,87 @@
|
||||
/**
|
||||
* All date-related `Intl` lives here. Consumers that format, resolve a
|
||||
* locale-default preference or read a placeholder import from this module.
|
||||
* The `DateFormatter` class wraps `Intl.DateTimeFormat` with a cache so
|
||||
* recurrent calls with the same options are cheap.
|
||||
*
|
||||
* `ext/dates` (the reactive App service) is a thin layer on top of this
|
||||
* module: it owns reactive preferences and locale subscriptions, but
|
||||
* delegates every actual formatting call here.
|
||||
*/
|
||||
|
||||
import type { DateOrder, HourCycle } from './_vendor/core/types';
|
||||
|
||||
export { DateFormatter } from './_vendor/format/DateFormatter';
|
||||
export { getPlaceholder } from './_vendor/format/placeholders';
|
||||
export {
|
||||
getDefaultDate,
|
||||
getDefaultTime,
|
||||
inferGranularity,
|
||||
inferTimeGranularity,
|
||||
getDefaultHourCycle,
|
||||
DEFAULT_TIME_FORMAT,
|
||||
DEFAULT_DATE_ORDER
|
||||
} from './_vendor/format/defaults';
|
||||
export { timeFormatToHourCycle } from './_vendor/internal/utils';
|
||||
|
||||
// ── Locale-derived defaults ─────────────────────────────────────────────────
|
||||
//
|
||||
// Previously scattered under `ext/dates/locale-defaults.ts`. Moved here so
|
||||
// every date-related `Intl` hint lives in one place. These are pure
|
||||
// functions: no state, no reactivity. The App service (`ext/dates`) is
|
||||
// what makes them reactive by re-evaluating when the locale changes.
|
||||
|
||||
const MDY_LOCALES = new Set(['en-US', 'en-PH', 'en-BZ', 'en-MH', 'en-FM']);
|
||||
const YMD_BASES = new Set(['zh', 'ja', 'ko', 'hu', 'lt', 'fa']);
|
||||
const H12_BASES = new Set(['en', 'ko', 'hi', 'bn']);
|
||||
|
||||
/**
|
||||
* Best-effort mapping of a BCP 47 locale to the dominant date-field order
|
||||
* used in that locale. Returns `'DMY'` by default because it is the most
|
||||
* common worldwide. Override via user preference when needed.
|
||||
*/
|
||||
export function resolveDateOrder(locale: string): DateOrder {
|
||||
if (MDY_LOCALES.has(locale)) return 'MDY';
|
||||
const base = locale.split('-')[0].toLowerCase();
|
||||
if (YMD_BASES.has(base)) return 'YMD';
|
||||
return 'DMY';
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort mapping of a BCP 47 locale to the dominant clock convention.
|
||||
* Returns `24` by default; override via user preference when needed.
|
||||
*
|
||||
* Uses the canonical numeric `HourCycle = 12 | 24` that matches
|
||||
* `Intl.DateTimeFormat`'s `hour12` resolved option.
|
||||
*/
|
||||
export function resolveHourCycle(locale: string): HourCycle {
|
||||
if (locale === 'en-GB') return 24;
|
||||
const base = locale.split('-')[0].toLowerCase();
|
||||
return H12_BASES.has(base) ? 12 : 24;
|
||||
}
|
||||
|
||||
// ── Intl.DateTimeFormat cache ───────────────────────────────────────────────
|
||||
//
|
||||
// Shared across the framework. `new Intl.DateTimeFormat(...)` is slow enough
|
||||
// that memoising by locale+options is worthwhile when the same combination is
|
||||
// used repeatedly (e.g. rendering a calendar grid month after month).
|
||||
|
||||
const formatCache = new Map<string, Intl.DateTimeFormat>();
|
||||
|
||||
/**
|
||||
* Returns a cached `Intl.DateTimeFormat` for the given locale and options.
|
||||
* Cache key is the locale plus the JSON-serialised options. Callers that
|
||||
* pass identical options on every call will hit the cache.
|
||||
*/
|
||||
export function getCachedDateFormat(
|
||||
locale: string,
|
||||
options?: Intl.DateTimeFormatOptions
|
||||
): Intl.DateTimeFormat {
|
||||
const key = `${locale}:${JSON.stringify(options ?? {})}`;
|
||||
let fmt = formatCache.get(key);
|
||||
if (!fmt) {
|
||||
fmt = new Intl.DateTimeFormat(locale || undefined, options);
|
||||
formatCache.set(key, fmt);
|
||||
}
|
||||
return fmt;
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
/**
|
||||
* # dias — date & time library
|
||||
*
|
||||
* Single public entry point. Consumers should import from `$lib/util/dias`
|
||||
* (or `@/util/dias`) and nothing else. The `_vendor/` folder is internal:
|
||||
* it holds the vendored implementation under its original Apache 2.0
|
||||
* licence and must not be referenced by user code.
|
||||
*
|
||||
* The surface is split into small categorised modules (`types`, `values`,
|
||||
* `calendars`, `queries`, `operations`, `parse`, `format`) that are all
|
||||
* re-exported here. Import the categorised module when you only need one
|
||||
* slice; import from the index when you want everything.
|
||||
*/
|
||||
|
||||
export * from './types';
|
||||
export * from './values';
|
||||
export * from './calendars';
|
||||
export * from './queries';
|
||||
export * from './operations';
|
||||
export * from './parse';
|
||||
export * from './format';
|
||||
@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Conversions between value types (calendar ↔ calendar, with/without time,
|
||||
* with/without zone) and construction from JS `Date`.
|
||||
*
|
||||
* Arithmetic (`add`, `subtract`, `cycle`, `set`) lives on the value classes
|
||||
* themselves — see `values.ts`.
|
||||
*/
|
||||
|
||||
export {
|
||||
toCalendarDate,
|
||||
toCalendarDateTime,
|
||||
toTime,
|
||||
toCalendar,
|
||||
toZoned,
|
||||
toTimeZone,
|
||||
toLocalTimeZone,
|
||||
fromDate,
|
||||
fromDateToLocal,
|
||||
fromAbsolute,
|
||||
dateValueToDate,
|
||||
convertTimeValueToDateValue,
|
||||
convertTimeValueToTime,
|
||||
getISOTimeValue
|
||||
} from './_vendor/operations/conversion';
|
||||
@ -0,0 +1,22 @@
|
||||
/**
|
||||
* String → value parsers. Input format is ISO 8601 for `parseDate`,
|
||||
* `parseDateTime`, `parseTime`, `parseAbsolute`, `parseZonedDateTime`,
|
||||
* and a subset of ISO 8601 duration notation for `parseDuration`.
|
||||
*
|
||||
* The generic helpers (`parseStringToDateValue`, `parseAnyDateValue`,
|
||||
* `getDateValueType`) accept multiple shapes and are useful when the caller
|
||||
* does not know the granularity in advance.
|
||||
*/
|
||||
|
||||
export {
|
||||
parseDate,
|
||||
parseDateTime,
|
||||
parseTime,
|
||||
parseAbsolute,
|
||||
parseAbsoluteToLocal,
|
||||
parseZonedDateTime,
|
||||
parseDuration,
|
||||
parseStringToDateValue,
|
||||
parseAnyDateValue,
|
||||
getDateValueType
|
||||
} from './_vendor/operations/string';
|
||||
@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Non-mutating queries over date/time values: equality, week math, bounds
|
||||
* checks, "is weekend", "today", etc. Every function is pure.
|
||||
*/
|
||||
|
||||
export {
|
||||
isSameDay,
|
||||
isSameMonth,
|
||||
isSameYear,
|
||||
isEqualDay,
|
||||
isEqualMonth,
|
||||
isEqualYear,
|
||||
isEqualCalendar,
|
||||
isToday,
|
||||
isWeekend,
|
||||
isWeekday,
|
||||
isBefore,
|
||||
isAfter,
|
||||
isBetweenInclusive,
|
||||
isTimeBefore,
|
||||
isTimeAfter,
|
||||
isZonedDateTime,
|
||||
hasTime,
|
||||
getDayOfWeek,
|
||||
getHoursInDay,
|
||||
getDaysInMonth,
|
||||
getWeeksInMonth,
|
||||
getMinimumMonthInYear,
|
||||
getMinimumDayInMonth,
|
||||
getLastFirstDayOfWeek,
|
||||
getNextLastDayOfWeek,
|
||||
startOfMonth,
|
||||
startOfWeek,
|
||||
startOfYear,
|
||||
endOfMonth,
|
||||
endOfWeek,
|
||||
endOfYear,
|
||||
minDate,
|
||||
maxDate,
|
||||
areAllDaysBetweenValid,
|
||||
now,
|
||||
today,
|
||||
getLocalTimeZone,
|
||||
setLocalTimeZone,
|
||||
resetLocalTimeZone
|
||||
} from './_vendor/operations/queries';
|
||||
@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Public type surface of `dias`.
|
||||
*
|
||||
* Every type consumers need lives here. Nothing in this file touches the
|
||||
* vendored implementation beyond re-exporting declarations. If a type needs
|
||||
* to be added or reshaped for the public API, do it here — never edit
|
||||
* `_vendor/` for API reasons.
|
||||
*/
|
||||
|
||||
export type {
|
||||
AnyCalendarDate,
|
||||
AnyTime,
|
||||
AnyDateTime,
|
||||
Calendar,
|
||||
CalendarIdentifier,
|
||||
DateDuration,
|
||||
TimeDuration,
|
||||
DateTimeDuration,
|
||||
DateFields,
|
||||
TimeFields,
|
||||
DateField,
|
||||
TimeField,
|
||||
Disambiguation,
|
||||
CycleOptions,
|
||||
CycleTimeOptions,
|
||||
Granularity,
|
||||
TimeGranularity,
|
||||
HourCycle,
|
||||
WeekStartsOn,
|
||||
DateOrder
|
||||
} from './_vendor/core/types';
|
||||
|
||||
export type {
|
||||
DateValue,
|
||||
TimeValue,
|
||||
DateRange,
|
||||
TimeRange,
|
||||
DateMatcher,
|
||||
DateValidator,
|
||||
DateRangeValidator,
|
||||
TimeValidator,
|
||||
TimeRangeValidator,
|
||||
DateOnInvalid,
|
||||
TimeOnInvalid,
|
||||
Month
|
||||
} from './_vendor/operations/queries';
|
||||
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Immutable value classes: `CalendarDate`, `CalendarDateTime`, `Time`,
|
||||
* `ZonedDateTime`. Instances are the canonical currency across `dias` —
|
||||
* queries take them as input, operations return new ones, formatters
|
||||
* consume them.
|
||||
*/
|
||||
|
||||
export { CalendarDate, CalendarDateTime, Time, ZonedDateTime } from './_vendor/core/CalendarDate';
|
||||
@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '../../../reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import { CalendarCellProvider } from '../calendar-provider.svelte';
|
||||
import type { CalendarCellProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'calendar-cell'),
|
||||
date,
|
||||
month,
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: CalendarCellProps = $props();
|
||||
|
||||
const state = CalendarCellProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
date: readableActive(() => date),
|
||||
month: readableActive(() => month)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ ...state.snippetProps, props: mergedProps })}
|
||||
{:else}
|
||||
<td {...mergedProps}>
|
||||
{@render children?.(state.snippetProps)}
|
||||
</td>
|
||||
{/if}
|
||||
@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '../../../reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import { CalendarDayProvider } from '../calendar-provider.svelte';
|
||||
import type { CalendarDayProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'calendar-day'),
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: CalendarDayProps = $props();
|
||||
|
||||
const state = CalendarDayProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ ...state.snippetProps, props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps}>
|
||||
{#if children}
|
||||
{@render children(state.snippetProps)}
|
||||
{:else}
|
||||
{state.snippetProps.day}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '../../../reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import { CalendarGridBodyProvider } from '../calendar-provider.svelte';
|
||||
import type { CalendarGridBodyProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'calendar-grid-body'),
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: CalendarGridBodyProps = $props();
|
||||
|
||||
const state = CalendarGridBodyProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<tbody {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</tbody>
|
||||
{/if}
|
||||
@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '../../../reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import { CalendarGridHeadProvider } from '../calendar-provider.svelte';
|
||||
import type { CalendarGridHeadProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'calendar-grid-head'),
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: CalendarGridHeadProps = $props();
|
||||
|
||||
const state = CalendarGridHeadProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<thead {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</thead>
|
||||
{/if}
|
||||
@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '../../../reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import { CalendarGridRowProvider, CalendarProvider } from '../calendar-provider.svelte';
|
||||
import type { CalendarGridRowProps } from '../types';
|
||||
import type { DateValue } from '../../../external/dates';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'calendar-grid-row'),
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: CalendarGridRowProps & { week?: DateValue[] } = $props();
|
||||
|
||||
const state = CalendarGridRowProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
)
|
||||
});
|
||||
|
||||
// Week number is derived from the first DateValue in this row when the
|
||||
// consumer passes `week`. Purely informational; headless.
|
||||
const provider = CalendarProvider.require();
|
||||
const weekNumber = $derived.by(() => {
|
||||
const wk = (restProps as { week?: DateValue[] }).week;
|
||||
if (!wk?.length || !provider.opts.showWeekNumbers.current) return undefined;
|
||||
return provider.getWeekNumber(wk[0]);
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
const snippetProps = $derived({ weekNumber });
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ ...snippetProps, props: mergedProps })}
|
||||
{:else}
|
||||
<tr {...mergedProps}>
|
||||
{@render children?.(snippetProps)}
|
||||
</tr>
|
||||
{/if}
|
||||
@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '../../../reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import { CalendarGridProvider } from '../calendar-provider.svelte';
|
||||
import type { CalendarGridProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'calendar-grid'),
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: CalendarGridProps = $props();
|
||||
|
||||
const state = CalendarGridProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<table {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</table>
|
||||
{/if}
|
||||
@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '../../../reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import { CalendarHeadCellProvider } from '../calendar-provider.svelte';
|
||||
import type { CalendarHeadCellProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'calendar-head-cell'),
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: CalendarHeadCellProps = $props();
|
||||
|
||||
const state = CalendarHeadCellProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<th {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</th>
|
||||
{/if}
|
||||
@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '../../../reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import { CalendarHeaderProvider } from '../calendar-provider.svelte';
|
||||
import type { CalendarHeaderProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'calendar-header'),
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: CalendarHeaderProps = $props();
|
||||
|
||||
const state = CalendarHeaderProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<header {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</header>
|
||||
{/if}
|
||||
@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '../../../reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import { CalendarHeadingProvider } from '../calendar-provider.svelte';
|
||||
import type { CalendarHeadingProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'calendar-heading'),
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: CalendarHeadingProps = $props();
|
||||
|
||||
const state = CalendarHeadingProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
const snippetProps = $derived({ headingValue: state.headingValue });
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ ...snippetProps, props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps}>
|
||||
{#if children}
|
||||
{@render children(snippetProps)}
|
||||
{:else}
|
||||
{state.headingValue}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '../../../reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import { CalendarMonthSelectProvider } from '../calendar-provider.svelte';
|
||||
import type { CalendarMonthSelectProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'calendar-month-select'),
|
||||
months = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
|
||||
monthFormat,
|
||||
disabled = false,
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: CalendarMonthSelectProps = $props();
|
||||
|
||||
const state = CalendarMonthSelectProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
months: readableActive(() => months),
|
||||
monthFormat: readableActive(() => monthFormat),
|
||||
disabled: readableActive(() => disabled)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
const snippetProps = $derived({
|
||||
monthItems: state.monthItems,
|
||||
selectedMonthItem: state.selectedMonthItem
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ ...snippetProps, props: mergedProps })}
|
||||
{:else}
|
||||
<select {...mergedProps}>
|
||||
{#if children}
|
||||
{@render children(snippetProps)}
|
||||
{:else}
|
||||
{#each state.monthItems as item (item.value)}
|
||||
<option value={String(item.value)}>{item.label}</option>
|
||||
{/each}
|
||||
{/if}
|
||||
</select>
|
||||
{/if}
|
||||
@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '../../../reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import { CalendarNextButtonProvider } from '../calendar-provider.svelte';
|
||||
import type { CalendarNextButtonProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'calendar-next-button'),
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: CalendarNextButtonProps = $props();
|
||||
|
||||
const state = CalendarNextButtonProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<button {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</button>
|
||||
{/if}
|
||||
@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '../../../reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import { CalendarPrevButtonProvider } from '../calendar-provider.svelte';
|
||||
import type { CalendarPrevButtonProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'calendar-prev-button'),
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: CalendarPrevButtonProps = $props();
|
||||
|
||||
const state = CalendarPrevButtonProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<button {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</button>
|
||||
{/if}
|
||||
@ -0,0 +1,63 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '../../../reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import { CalendarYearSelectProvider } from '../calendar-provider.svelte';
|
||||
import type { CalendarYearSelectProps } from '../types';
|
||||
import { CalendarProvider } from '../calendar-provider.svelte';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'calendar-year-select'),
|
||||
years,
|
||||
yearFormat,
|
||||
disabled = false,
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: CalendarYearSelectProps = $props();
|
||||
|
||||
// Default range: placeholder year ± 50 so the user can always travel a
|
||||
// century without configuring this prop. Callers can pass `years` for
|
||||
// a custom set.
|
||||
const provider = CalendarProvider.require();
|
||||
const defaultYears = $derived.by(() => {
|
||||
const y = provider.opts.placeholder.current.year;
|
||||
const arr: number[] = [];
|
||||
for (let i = y - 50; i <= y + 50; i++) arr.push(i);
|
||||
return arr;
|
||||
});
|
||||
|
||||
const state = CalendarYearSelectProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
years: readableActive(() => years ?? defaultYears),
|
||||
yearFormat: readableActive(() => yearFormat),
|
||||
disabled: readableActive(() => disabled)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
const snippetProps = $derived({
|
||||
yearItems: state.yearItems,
|
||||
selectedYearItem: state.selectedYearItem
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ ...snippetProps, props: mergedProps })}
|
||||
{:else}
|
||||
<select {...mergedProps}>
|
||||
{#if children}
|
||||
{@render children(snippetProps)}
|
||||
{:else}
|
||||
{#each state.yearItems as item (item.value)}
|
||||
<option value={String(item.value)}>{item.label}</option>
|
||||
{/each}
|
||||
{/if}
|
||||
</select>
|
||||
{/if}
|
||||
@ -0,0 +1,145 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '../../../reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import { CalendarProvider } from '../calendar-provider.svelte';
|
||||
import type { CalendarProps } from '../types';
|
||||
import { Soma } from '../../../core/soma.svelte';
|
||||
import { CalendarDate, today, getLocalTimeZone } from '../../../external/dates';
|
||||
|
||||
const uid = $props.id();
|
||||
const soma = Soma.get();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'calendar'),
|
||||
type,
|
||||
value = $bindable(),
|
||||
onValueChange = () => {},
|
||||
placeholder = $bindable(),
|
||||
onPlaceholderChange = () => {},
|
||||
preventDeselect = false,
|
||||
minValue,
|
||||
maxValue,
|
||||
disabled = false,
|
||||
pagedNavigation = false,
|
||||
weekStartsOn,
|
||||
weekdayFormat = 'narrow',
|
||||
isDateDisabled,
|
||||
isDateUnavailable,
|
||||
isDateHoliday,
|
||||
fixedWeeks = false,
|
||||
numberOfMonths = 1,
|
||||
calendarLabel,
|
||||
readonly = false,
|
||||
initialFocus = false,
|
||||
disableDaysOutsideMonth = false,
|
||||
maxDays,
|
||||
monthFormat = 'long',
|
||||
yearFormat = 'numeric',
|
||||
showWeekNumbers = false,
|
||||
weekNumberFormat = 'iso',
|
||||
announceNavigation = true,
|
||||
dir,
|
||||
locale,
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: CalendarProps = $props();
|
||||
|
||||
// Ensure placeholder always resolves to a concrete DateValue even when
|
||||
// neither placeholder nor value is provided (fallback = today).
|
||||
function initialPlaceholder() {
|
||||
if (placeholder) return placeholder;
|
||||
if (type === 'single' && value) return value;
|
||||
if (type === 'multiple' && Array.isArray(value) && value.length > 0) {
|
||||
return value[value.length - 1];
|
||||
}
|
||||
const t = today(getLocalTimeZone());
|
||||
return new CalendarDate(t.year, t.month, t.day);
|
||||
}
|
||||
if (placeholder === undefined) placeholder = initialPlaceholder() as never;
|
||||
|
||||
const state = CalendarProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
placeholder: writableActive(
|
||||
() => placeholder!,
|
||||
(v) => {
|
||||
placeholder = v;
|
||||
onPlaceholderChange(v);
|
||||
}
|
||||
),
|
||||
value: writableActive(
|
||||
() => value,
|
||||
(v) => {
|
||||
value = v as never;
|
||||
onValueChange(v as never);
|
||||
}
|
||||
),
|
||||
type: readableActive(() => type),
|
||||
preventDeselect: readableActive(() => preventDeselect),
|
||||
minValue: readableActive(() => minValue),
|
||||
maxValue: readableActive(() => maxValue),
|
||||
disabled: readableActive(() => disabled),
|
||||
pagedNavigation: readableActive(() => pagedNavigation),
|
||||
weekStartsOn: readableActive(() => weekStartsOn),
|
||||
weekdayFormat: readableActive(() => weekdayFormat),
|
||||
isDateDisabled: readableActive(() => isDateDisabled),
|
||||
isDateUnavailable: readableActive(() => isDateUnavailable),
|
||||
isDateHoliday: readableActive(() => isDateHoliday),
|
||||
fixedWeeks: readableActive(() => fixedWeeks),
|
||||
numberOfMonths: readableActive(() => numberOfMonths),
|
||||
calendarLabel: readableActive(() => calendarLabel),
|
||||
readonly: readableActive(() => readonly),
|
||||
disableDaysOutsideMonth: readableActive(() => disableDaysOutsideMonth),
|
||||
maxDays: readableActive(() => maxDays),
|
||||
monthFormat: readableActive(() => monthFormat),
|
||||
yearFormat: readableActive(() => yearFormat),
|
||||
showWeekNumbers: readableActive(() => showWeekNumbers),
|
||||
weekNumberFormat: readableActive(() => weekNumberFormat),
|
||||
announceNavigation: readableActive(() => announceNavigation),
|
||||
dir: readableActive(() => dir ?? soma?.presentation.getDir() ?? 'ltr'),
|
||||
locale: readableActive(() => locale ?? '')
|
||||
});
|
||||
|
||||
// Initial focus on the placeholder day once the DOM is present.
|
||||
$effect(() => {
|
||||
if (!initialFocus) return;
|
||||
const root = ref;
|
||||
if (!root) return;
|
||||
queueMicrotask(() => {
|
||||
const el = root.querySelector<HTMLElement>(
|
||||
`[data-calendar-day][data-focused]`
|
||||
);
|
||||
el?.focus();
|
||||
});
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
|
||||
const snippetProps = $derived({
|
||||
months: state.months,
|
||||
weekdays: state.weekdays,
|
||||
dateOrder: state.dateOrder
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ ...snippetProps, props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps}>
|
||||
{@render children?.(snippetProps)}
|
||||
<!-- Live region for navigation + selection announcements. -->
|
||||
<div
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);"
|
||||
>
|
||||
{state.announcement}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,38 @@
|
||||
export { default as Provider } from './components/calendar.svelte';
|
||||
export { default as Header } from './components/calendar-header.svelte';
|
||||
export { default as Heading } from './components/calendar-heading.svelte';
|
||||
export { default as PrevButton } from './components/calendar-prev-button.svelte';
|
||||
export { default as NextButton } from './components/calendar-next-button.svelte';
|
||||
export { default as MonthSelect } from './components/calendar-month-select.svelte';
|
||||
export { default as YearSelect } from './components/calendar-year-select.svelte';
|
||||
export { default as Grid } from './components/calendar-grid.svelte';
|
||||
export { default as GridHead } from './components/calendar-grid-head.svelte';
|
||||
export { default as GridBody } from './components/calendar-grid-body.svelte';
|
||||
export { default as GridRow } from './components/calendar-grid-row.svelte';
|
||||
export { default as HeadCell } from './components/calendar-head-cell.svelte';
|
||||
export { default as Cell } from './components/calendar-cell.svelte';
|
||||
export { default as Day } from './components/calendar-day.svelte';
|
||||
|
||||
export type {
|
||||
CalendarProps,
|
||||
CalendarHeaderProps,
|
||||
CalendarHeadingProps,
|
||||
CalendarPrevButtonProps,
|
||||
CalendarNextButtonProps,
|
||||
CalendarMonthSelectProps,
|
||||
CalendarYearSelectProps,
|
||||
CalendarGridProps,
|
||||
CalendarGridHeadProps,
|
||||
CalendarGridBodyProps,
|
||||
CalendarGridRowProps,
|
||||
CalendarHeadCellProps,
|
||||
CalendarCellProps,
|
||||
CalendarDayProps,
|
||||
CalendarProviderSnippetProps,
|
||||
CalendarCellSnippetProps,
|
||||
CalendarDaySnippetProps,
|
||||
CalendarHeadingSnippetProps,
|
||||
CalendarMonthSelectSnippetProps,
|
||||
CalendarYearSelectSnippetProps,
|
||||
CalendarGridRowSnippetProps
|
||||
} from './types';
|
||||
@ -0,0 +1,298 @@
|
||||
/**
|
||||
* Pure helpers for Calendar. No reactivity, no DOM, no context access.
|
||||
* Each function takes everything it needs as parameters and returns a new
|
||||
* value. Kept outside the Provider so it is trivially testable.
|
||||
*/
|
||||
|
||||
import {
|
||||
CalendarDate,
|
||||
CalendarDateTime,
|
||||
endOfMonth,
|
||||
getDaysInMonth,
|
||||
getDayOfWeek,
|
||||
getLastFirstDayOfWeek,
|
||||
getNextLastDayOfWeek,
|
||||
getLocalTimeZone,
|
||||
isSameDay,
|
||||
isSameMonth,
|
||||
startOfMonth,
|
||||
today
|
||||
} from '../../external/dates';
|
||||
import type { DateValue, Month, WeekStartsOn } from '../../external/dates';
|
||||
|
||||
// ── Month generation ────────────────────────────────────────────────────────
|
||||
|
||||
export interface CreateMonthsOpts {
|
||||
numberOfMonths: number;
|
||||
weekStartsOn: WeekStartsOn | undefined;
|
||||
fixedWeeks: boolean;
|
||||
locale: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Produce the visible grids. Each grid walks from the week-start before the
|
||||
* 1st of the month through the week-end after the last day, chunked into
|
||||
* rows of 7. When `fixedWeeks` is on, the grid is padded up to 42 cells
|
||||
* (6 rows) so layout height stays constant.
|
||||
*/
|
||||
export function createMonths(
|
||||
placeholder: DateValue,
|
||||
opts: CreateMonthsOpts
|
||||
): Month<DateValue>[] {
|
||||
const months: Month<DateValue>[] = [];
|
||||
for (let i = 0; i < opts.numberOfMonths; i++) {
|
||||
const monthStart = startOfMonth(addMonthsPreserving(placeholder, i));
|
||||
months.push(buildMonth(monthStart, opts));
|
||||
}
|
||||
return months;
|
||||
}
|
||||
|
||||
function buildMonth(
|
||||
monthStart: DateValue,
|
||||
opts: Omit<CreateMonthsOpts, 'numberOfMonths'>
|
||||
): Month<DateValue> {
|
||||
const monthEnd = endOfMonth(monthStart);
|
||||
const daysInMonth = getDaysInMonth(monthStart);
|
||||
|
||||
// All 1..n dates in the current month.
|
||||
const inMonth: DateValue[] = [];
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
inMonth.push(monthStart.set({ day }));
|
||||
}
|
||||
|
||||
// Leading padding: days from the previous month that complete the first
|
||||
// visible week.
|
||||
const firstCell = opts.weekStartsOn === undefined
|
||||
? getLastFirstDayOfWeek(monthStart, 0, opts.locale)
|
||||
: getLastFirstDayOfWeek(monthStart, opts.weekStartsOn, 'en-US');
|
||||
const lead: DateValue[] = [];
|
||||
let cursor = firstCell;
|
||||
while (cursor.compare(monthStart) < 0) {
|
||||
lead.push(cursor);
|
||||
cursor = cursor.add({ days: 1 });
|
||||
}
|
||||
|
||||
// Trailing padding to complete the last visible week.
|
||||
const lastCell = opts.weekStartsOn === undefined
|
||||
? getNextLastDayOfWeek(monthEnd, 0, opts.locale)
|
||||
: getNextLastDayOfWeek(monthEnd, opts.weekStartsOn, 'en-US');
|
||||
const trail: DateValue[] = [];
|
||||
cursor = monthEnd.add({ days: 1 });
|
||||
while (cursor.compare(lastCell) <= 0) {
|
||||
trail.push(cursor);
|
||||
cursor = cursor.add({ days: 1 });
|
||||
}
|
||||
|
||||
let dates = [...lead, ...inMonth, ...trail];
|
||||
|
||||
if (opts.fixedWeeks && dates.length < 42) {
|
||||
const extra = 42 - dates.length;
|
||||
for (let i = 0; i < extra; i++) {
|
||||
dates.push(dates[dates.length - 1].add({ days: 1 }));
|
||||
}
|
||||
}
|
||||
|
||||
// Chunk into weeks of 7.
|
||||
const weeks: DateValue[][] = [];
|
||||
for (let i = 0; i < dates.length; i += 7) weeks.push(dates.slice(i, i + 7));
|
||||
|
||||
return { value: monthStart, dates, weeks };
|
||||
}
|
||||
|
||||
/**
|
||||
* `value.add({ months: n })` shifts day-of-month if the target month is
|
||||
* shorter; we only care about the year+month, so normalize by going through
|
||||
* `startOfMonth` first.
|
||||
*/
|
||||
function addMonthsPreserving(value: DateValue, months: number): DateValue {
|
||||
return startOfMonth(value).add({ months });
|
||||
}
|
||||
|
||||
// ── Week numbers ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* ISO 8601 week number: week 1 is the week containing the first Thursday
|
||||
* of the year. Independent of `weekStartsOn`.
|
||||
*/
|
||||
export function getIsoWeekNumber(date: DateValue): number {
|
||||
const d = date.toDate(getLocalTimeZone());
|
||||
// Make a UTC-normalized copy so DST does not shift us across days.
|
||||
const utc = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
|
||||
const dayNum = utc.getUTCDay() || 7; // Sunday → 7
|
||||
utc.setUTCDate(utc.getUTCDate() + 4 - dayNum);
|
||||
const yearStart = new Date(Date.UTC(utc.getUTCFullYear(), 0, 1));
|
||||
return Math.ceil(((utc.getTime() - yearStart.getTime()) / 86_400_000 + 1) / 7);
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple week number: 1-based count starting with the week containing
|
||||
* January 1, counted from the user's `weekStartsOn` (0 = Sunday).
|
||||
*/
|
||||
export function getSimpleWeekNumber(
|
||||
date: DateValue,
|
||||
weekStartsOn: number,
|
||||
locale: string
|
||||
): number {
|
||||
const yearStart = new CalendarDate(date.year, 1, 1);
|
||||
const firstCell = getLastFirstDayOfWeek(yearStart, weekStartsOn, locale);
|
||||
const d = date.toDate(getLocalTimeZone());
|
||||
const first = firstCell.toDate(getLocalTimeZone());
|
||||
const days = Math.floor((d.getTime() - first.getTime()) / 86_400_000);
|
||||
return Math.floor(days / 7) + 1;
|
||||
}
|
||||
|
||||
// ── Validation composition ──────────────────────────────────────────────────
|
||||
|
||||
export interface ComposeValidationOpts {
|
||||
minValue: DateValue | undefined;
|
||||
maxValue: DateValue | undefined;
|
||||
disabled: boolean;
|
||||
isDateDisabled: ((d: DateValue) => boolean) | undefined;
|
||||
isDateUnavailable: ((d: DateValue) => boolean) | undefined;
|
||||
isDateHoliday: ((d: DateValue) => boolean) | undefined;
|
||||
}
|
||||
|
||||
export interface DateFlags {
|
||||
disabled: boolean;
|
||||
unavailable: boolean;
|
||||
holiday: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute per-date flags. Order of precedence:
|
||||
* 1. root `disabled` → every date disabled.
|
||||
* 2. min/max bounds → disabled if outside.
|
||||
* 3. user matcher `isDateDisabled` → disabled.
|
||||
* 4. `isDateUnavailable` → unavailable (non-blocking for focus).
|
||||
* 5. `isDateHoliday` → informational flag only.
|
||||
*/
|
||||
export function computeDateFlags(date: DateValue, opts: ComposeValidationOpts): DateFlags {
|
||||
const disabled =
|
||||
opts.disabled ||
|
||||
(opts.minValue !== undefined && date.compare(opts.minValue) < 0) ||
|
||||
(opts.maxValue !== undefined && date.compare(opts.maxValue) > 0) ||
|
||||
(opts.isDateDisabled?.(date) ?? false);
|
||||
|
||||
const unavailable = opts.isDateUnavailable?.(date) ?? false;
|
||||
const holiday = opts.isDateHoliday?.(date) ?? false;
|
||||
|
||||
return { disabled, unavailable, holiday };
|
||||
}
|
||||
|
||||
// ── Selection ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Single-select toggle. Returns:
|
||||
* - `undefined` when the user clicked the currently selected date AND
|
||||
* `preventDeselect` is false (clear).
|
||||
* - the same date otherwise (noop, but keeps downstream logic uniform).
|
||||
* - `date` when it differs from the current value.
|
||||
*/
|
||||
export function applySingleSelection(
|
||||
prev: DateValue | undefined,
|
||||
date: DateValue,
|
||||
preventDeselect: boolean
|
||||
): DateValue | undefined {
|
||||
if (prev && isSameDay(prev, date)) return preventDeselect ? prev : undefined;
|
||||
return getDateWithPreviousTime(prev, date);
|
||||
}
|
||||
|
||||
/**
|
||||
* Preserve time-of-day when switching selected date, so a calendar+time
|
||||
* combo does not reset the hour on every date pick.
|
||||
*/
|
||||
export function getDateWithPreviousTime(
|
||||
prev: DateValue | undefined,
|
||||
next: DateValue
|
||||
): DateValue {
|
||||
if (!prev) return next;
|
||||
if (prev instanceof CalendarDateTime && next instanceof CalendarDateTime) return next;
|
||||
if (prev instanceof CalendarDateTime && next instanceof CalendarDate) {
|
||||
return new CalendarDateTime(
|
||||
next.year,
|
||||
next.month,
|
||||
next.day,
|
||||
prev.hour,
|
||||
prev.minute,
|
||||
prev.second,
|
||||
prev.millisecond
|
||||
);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-select toggle + cap. Returns the next array:
|
||||
* - Toggles the date in/out of the existing selection.
|
||||
* - If adding would exceed `maxDays`, resets to `[date]`.
|
||||
*/
|
||||
export function applyMultipleSelection(
|
||||
prev: DateValue[],
|
||||
date: DateValue,
|
||||
preventDeselect: boolean,
|
||||
maxDays: number | undefined
|
||||
): DateValue[] {
|
||||
const idx = prev.findIndex((d) => isSameDay(d, date));
|
||||
if (idx >= 0) {
|
||||
if (preventDeselect && prev.length === 1) return prev;
|
||||
const next = [...prev];
|
||||
next.splice(idx, 1);
|
||||
return next;
|
||||
}
|
||||
if (maxDays !== undefined && prev.length >= maxDays) return [date];
|
||||
return [...prev, date];
|
||||
}
|
||||
|
||||
// ── Focus navigation ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Given a keyboard step, compute the next date to focus. Pure — the caller
|
||||
* is responsible for actually moving focus in the DOM.
|
||||
*/
|
||||
export function shiftDate(date: DateValue, days: number): DateValue {
|
||||
return days >= 0 ? date.add({ days }) : date.subtract({ days: -days });
|
||||
}
|
||||
|
||||
// ── Weekday header generation ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Produce weekday labels for the head row. Uses `Intl.DateTimeFormat` via a
|
||||
* known anchor week (Jan 4 is always ISO week 1 and sits in the first
|
||||
* Thursday). Returns labels in display order starting from `weekStartsOn`.
|
||||
*/
|
||||
export function getWeekdays(
|
||||
locale: string,
|
||||
weekStartsOn: number,
|
||||
format: Intl.DateTimeFormatOptions['weekday']
|
||||
): string[] {
|
||||
const fmt = new Intl.DateTimeFormat(locale, { weekday: format ?? 'narrow' });
|
||||
// Jan 2024 week: 2024-01-01 was a Monday. Build a reference week.
|
||||
const anchorMonday = new Date(Date.UTC(2024, 0, 1));
|
||||
const labels: string[] = [];
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const idx = (weekStartsOn + i) % 7;
|
||||
const d = new Date(anchorMonday);
|
||||
// anchor is Monday (getUTCDay === 1). Shift to desired weekday:
|
||||
// target day-index (0=Sunday … 6=Saturday) → offset from Monday.
|
||||
const offset = (idx + 6) % 7; // 0 (Monday=0) → 0, …
|
||||
d.setUTCDate(anchorMonday.getUTCDate() + offset);
|
||||
labels.push(fmt.format(d));
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
// ── Miscellaneous ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Tuple of today / is-same helpers kept here to centralize TZ handling. */
|
||||
export function isToday(date: DateValue): boolean {
|
||||
return isSameDay(date, today(getLocalTimeZone()));
|
||||
}
|
||||
|
||||
export function isWeekend(date: DateValue): boolean {
|
||||
const dow = getDayOfWeek(date, 'en-US'); // 0 = Sunday
|
||||
return dow === 0 || dow === 6;
|
||||
}
|
||||
|
||||
export function isOutsideMonth(date: DateValue, monthStart: DateValue): boolean {
|
||||
return !isSameMonth(date, monthStart);
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export * from './exports';
|
||||
@ -0,0 +1,15 @@
|
||||
/** Idlangref constants for Calendar. Values are fallback defaults. */
|
||||
export const CALENDAR_LANGS = {
|
||||
LABEL: '#?components.calendar.label|Calendar',
|
||||
PREV_MONTH: '#?components.calendar.prevMonth|Previous month',
|
||||
NEXT_MONTH: '#?components.calendar.nextMonth|Next month',
|
||||
PREV_YEAR: '#?components.calendar.prevYear|Previous year',
|
||||
NEXT_YEAR: '#?components.calendar.nextYear|Next year',
|
||||
MONTH_SELECT: '#?components.calendar.monthSelect|Select month',
|
||||
YEAR_SELECT: '#?components.calendar.yearSelect|Select year',
|
||||
WEEK_LABEL: '#?components.calendar.weekLabel|Week',
|
||||
WEEK_NUMBER_ABBR: '#?components.calendar.weekNumberAbbr|Wk',
|
||||
HOLIDAY: '#?components.calendar.holiday|holiday',
|
||||
TODAY: '#?components.calendar.today|today',
|
||||
SELECTED: '#?components.calendar.selected|selected'
|
||||
} as const;
|
||||
@ -0,0 +1,289 @@
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { WithChild, Without, OnChangeFn } from '../../types';
|
||||
import type {
|
||||
PrimitiveButtonAttributes,
|
||||
PrimitiveDivAttributes,
|
||||
PrimitiveHeaderAttributes,
|
||||
PrimitiveSelectAttributes,
|
||||
PrimitiveTableAttributes,
|
||||
PrimitiveTBodyAttributes,
|
||||
PrimitiveTDAttributes,
|
||||
PrimitiveTHAttributes,
|
||||
PrimitiveTHeadAttributes,
|
||||
PrimitiveTRAttributes
|
||||
} from '../../types';
|
||||
import type {
|
||||
DateValue,
|
||||
DateMatcher,
|
||||
Month,
|
||||
WeekStartsOn,
|
||||
DateOrder
|
||||
} from '../../external/dates';
|
||||
|
||||
// ── Snippet props ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Snippet props exposed by the root `Calendar.Provider`. */
|
||||
export type CalendarProviderSnippetProps = {
|
||||
/** One `Month` per visible grid (length === `numberOfMonths`). */
|
||||
months: Month<DateValue>[];
|
||||
/** Locale-formatted weekday labels in display order. */
|
||||
weekdays: string[];
|
||||
/** Locale's canonical date-field order (`'DMY' | 'MDY' | 'YMD'`). */
|
||||
dateOrder: DateOrder;
|
||||
};
|
||||
|
||||
/** Snippet props exposed by `Calendar.Cell`. */
|
||||
export type CalendarCellSnippetProps = {
|
||||
/** True when the date is blocked for interaction. */
|
||||
disabled: boolean;
|
||||
/** True when the date is reachable but cannot be selected. */
|
||||
unavailable: boolean;
|
||||
/** True when the date is part of the current value. */
|
||||
selected: boolean;
|
||||
/** True when the date is `today()`. */
|
||||
today: boolean;
|
||||
/** True when the date is Saturday or Sunday (weekend in Gregorian). */
|
||||
weekend: boolean;
|
||||
/** True when the date matches `isDateHoliday`. */
|
||||
holiday: boolean;
|
||||
/** True when the cell renders a day outside the currently visible month. */
|
||||
outsideMonth: boolean;
|
||||
};
|
||||
|
||||
/** Snippet props exposed by `Calendar.Day`. */
|
||||
export type CalendarDaySnippetProps = CalendarCellSnippetProps & {
|
||||
/** Localized day-of-month number as a display string. */
|
||||
day: string;
|
||||
};
|
||||
|
||||
/** Snippet props exposed by `Calendar.Heading`. */
|
||||
export type CalendarHeadingSnippetProps = {
|
||||
/** Formatted heading — e.g. `"April 2026"`. */
|
||||
headingValue: string;
|
||||
};
|
||||
|
||||
/** Snippet props exposed by `Calendar.MonthSelect`. */
|
||||
export type CalendarMonthSelectSnippetProps = {
|
||||
/** One entry per option (`value`: 1-12, `label`: localized). */
|
||||
monthItems: Array<{ value: number; label: string }>;
|
||||
/** The currently selected month entry. */
|
||||
selectedMonthItem: { value: number; label: string };
|
||||
};
|
||||
|
||||
/** Snippet props exposed by `Calendar.YearSelect`. */
|
||||
export type CalendarYearSelectSnippetProps = {
|
||||
/** Full year range shown (`value`: year, `label`: localized). */
|
||||
yearItems: Array<{ value: number; label: string }>;
|
||||
/** The currently selected year entry. */
|
||||
selectedYearItem: { value: number; label: string };
|
||||
};
|
||||
|
||||
/** Snippet props exposed by `Calendar.GridRow` when `showWeekNumbers` is on. */
|
||||
export type CalendarGridRowSnippetProps = {
|
||||
/** ISO (or simple) week number for this row, `undefined` if disabled. */
|
||||
weekNumber: number | undefined;
|
||||
};
|
||||
|
||||
// ── Base provider props ──────────────────────────────────────────────────────
|
||||
|
||||
type CalendarBaseProps = {
|
||||
/** DOM id. Auto-generated when omitted. */
|
||||
id?: string;
|
||||
/**
|
||||
* Month currently visible. Drives navigation independently of `value`.
|
||||
* Bindable.
|
||||
*/
|
||||
placeholder?: DateValue;
|
||||
/** Called when `placeholder` changes (user nav or programmatic). */
|
||||
onPlaceholderChange?: OnChangeFn<DateValue>;
|
||||
/**
|
||||
* When `true`, clicking the already-selected date keeps it selected
|
||||
* instead of clearing.
|
||||
* @default false
|
||||
*/
|
||||
preventDeselect?: boolean;
|
||||
/** Earliest date the user can select. */
|
||||
minValue?: DateValue;
|
||||
/** Latest date the user can select. */
|
||||
maxValue?: DateValue;
|
||||
/** Disables the entire calendar. */
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* When `true`, navigation advances by `numberOfMonths` at a time.
|
||||
* @default false
|
||||
*/
|
||||
pagedNavigation?: boolean;
|
||||
/**
|
||||
* Override the first day of the week (`0`-Sunday … `6`-Saturday). Leave
|
||||
* undefined to use the locale's default.
|
||||
*/
|
||||
weekStartsOn?: WeekStartsOn;
|
||||
/** Intl weekday format for head cells. @default `'narrow'` */
|
||||
weekdayFormat?: Intl.DateTimeFormatOptions['weekday'];
|
||||
/** Matcher: return `true` to disable (blocks interaction). */
|
||||
isDateDisabled?: DateMatcher;
|
||||
/** Matcher: return `true` for valid-but-unavailable dates. */
|
||||
isDateUnavailable?: DateMatcher;
|
||||
/**
|
||||
* Matcher: return `true` to mark the date as a holiday. Rendered as
|
||||
* `data-holiday` on Cell and Day; purely informational. @default none
|
||||
*/
|
||||
isDateHoliday?: DateMatcher;
|
||||
/**
|
||||
* When `true`, every month grid always renders exactly 6 rows — useful
|
||||
* for keeping layout height constant.
|
||||
* @default false
|
||||
*/
|
||||
fixedWeeks?: boolean;
|
||||
/** Number of months to render side-by-side. @default 1 */
|
||||
numberOfMonths?: number;
|
||||
/** `aria-label` for the calendar root. Localized via translator if omitted. */
|
||||
calendarLabel?: string;
|
||||
/** Read-only calendar: focusable + navigable, but selection blocked. */
|
||||
readonly?: boolean;
|
||||
/** Focus the placeholder date on mount. @default false */
|
||||
initialFocus?: boolean;
|
||||
/**
|
||||
* When `true`, cells for days outside the visible month are disabled
|
||||
* (useful to avoid accidental "jump months" on keyboard nav).
|
||||
* @default false
|
||||
*/
|
||||
disableDaysOutsideMonth?: boolean;
|
||||
/**
|
||||
* Cap for multiple-selection: if the user would exceed this, the array
|
||||
* resets to `[date]`.
|
||||
*/
|
||||
maxDays?: number;
|
||||
/** Intl month format or a custom formatter. @default `'long'` */
|
||||
monthFormat?: Intl.DateTimeFormatOptions['month'] | ((month: number) => string);
|
||||
/** Intl year format or a custom formatter. @default `'numeric'` */
|
||||
yearFormat?: Intl.DateTimeFormatOptions['year'] | ((year: number) => string);
|
||||
/**
|
||||
* Render an extra leading column with week numbers.
|
||||
* @default false
|
||||
*/
|
||||
showWeekNumbers?: boolean;
|
||||
/**
|
||||
* Week-number convention. `'iso'` uses ISO 8601 (Monday-start, week 1
|
||||
* contains first Thursday); `'simple'` counts from 1 starting at the
|
||||
* first week containing day 1 of the year.
|
||||
* @default `'iso'`
|
||||
*/
|
||||
weekNumberFormat?: 'iso' | 'simple';
|
||||
/**
|
||||
* Announce navigation (not just selection) via `aria-live`. @default true
|
||||
*/
|
||||
announceNavigation?: boolean;
|
||||
/**
|
||||
* Writing direction. Inverts horizontal arrow navigation when `'rtl'`.
|
||||
* Falls back to `soma.presentation.getDir()`.
|
||||
*/
|
||||
dir?: 'ltr' | 'rtl';
|
||||
/**
|
||||
* Override the locale used to format month/weekday labels. Falls back to
|
||||
* `soma.langs.getLocale()`.
|
||||
*/
|
||||
locale?: string;
|
||||
};
|
||||
|
||||
// ── Root provider — discriminated union ─────────────────────────────────────
|
||||
|
||||
export type CalendarSingleProps = CalendarBaseProps & {
|
||||
/** Single-date selection. */
|
||||
type: 'single';
|
||||
/** Current value. Bindable. */
|
||||
value?: DateValue;
|
||||
/** Called when the selection changes. */
|
||||
onValueChange?: OnChangeFn<DateValue | undefined>;
|
||||
};
|
||||
|
||||
export type CalendarMultipleProps = CalendarBaseProps & {
|
||||
/** Multi-date selection. Toggling removes; `maxDays` resets. */
|
||||
type: 'multiple';
|
||||
/** Current value array. Bindable. */
|
||||
value?: DateValue[];
|
||||
/** Called when the selection changes. */
|
||||
onValueChange?: OnChangeFn<DateValue[]>;
|
||||
};
|
||||
|
||||
export type CalendarProps = WithChild<
|
||||
CalendarSingleProps | CalendarMultipleProps,
|
||||
CalendarProviderSnippetProps
|
||||
> &
|
||||
Without<PrimitiveDivAttributes, {}>;
|
||||
|
||||
// ── Child parts ──────────────────────────────────────────────────────────────
|
||||
|
||||
export type CalendarHeaderProps = WithChild<{ id?: string }> &
|
||||
Without<PrimitiveHeaderAttributes, {}>;
|
||||
|
||||
export type CalendarHeadingProps = WithChild<{ id?: string }, CalendarHeadingSnippetProps> &
|
||||
Without<PrimitiveDivAttributes, {}>;
|
||||
|
||||
export type CalendarPrevButtonProps = WithChild<{ id?: string }> &
|
||||
Without<PrimitiveButtonAttributes, {}>;
|
||||
|
||||
export type CalendarNextButtonProps = WithChild<{ id?: string }> &
|
||||
Without<PrimitiveButtonAttributes, {}>;
|
||||
|
||||
export type CalendarMonthSelectProps = WithChild<
|
||||
{
|
||||
id?: string;
|
||||
/** Restrict the option list (default: all 12 months). */
|
||||
months?: number[];
|
||||
/** Per-instance format override. */
|
||||
monthFormat?: Intl.DateTimeFormatOptions['month'] | ((month: number) => string);
|
||||
/** Disable the select independently of the calendar. */
|
||||
disabled?: boolean;
|
||||
},
|
||||
CalendarMonthSelectSnippetProps
|
||||
> &
|
||||
Without<PrimitiveSelectAttributes, {}>;
|
||||
|
||||
export type CalendarYearSelectProps = WithChild<
|
||||
{
|
||||
id?: string;
|
||||
/** Explicit year list. When omitted, derived from placeholder year ± 50. */
|
||||
years?: number[];
|
||||
/** Per-instance format override. */
|
||||
yearFormat?: Intl.DateTimeFormatOptions['year'] | ((year: number) => string);
|
||||
/** Disable the select independently of the calendar. */
|
||||
disabled?: boolean;
|
||||
},
|
||||
CalendarYearSelectSnippetProps
|
||||
> &
|
||||
Without<PrimitiveSelectAttributes, {}>;
|
||||
|
||||
export type CalendarGridProps = WithChild<{ id?: string }> &
|
||||
Without<PrimitiveTableAttributes, {}>;
|
||||
|
||||
export type CalendarGridHeadProps = WithChild<{ id?: string }> &
|
||||
Without<PrimitiveTHeadAttributes, {}>;
|
||||
|
||||
export type CalendarGridBodyProps = WithChild<{ id?: string }> &
|
||||
Without<PrimitiveTBodyAttributes, {}>;
|
||||
|
||||
export type CalendarGridRowProps = WithChild<{ id?: string }, CalendarGridRowSnippetProps> &
|
||||
Without<PrimitiveTRAttributes, {}>;
|
||||
|
||||
export type CalendarHeadCellProps = WithChild<{
|
||||
id?: string;
|
||||
/** Optional children override (e.g. to inject a week-number column header). */
|
||||
children?: Snippet;
|
||||
}> &
|
||||
Without<PrimitiveTHAttributes, {}>;
|
||||
|
||||
export type CalendarCellProps = WithChild<
|
||||
{
|
||||
id?: string;
|
||||
/** The date this cell represents. */
|
||||
date: DateValue;
|
||||
/** The month this grid belongs to (used to flag outside-month cells). */
|
||||
month: DateValue;
|
||||
},
|
||||
CalendarCellSnippetProps
|
||||
> &
|
||||
Without<PrimitiveTDAttributes, {}>;
|
||||
|
||||
export type CalendarDayProps = WithChild<{ id?: string }, CalendarDaySnippetProps> &
|
||||
Without<PrimitiveDivAttributes, {}>;
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,89 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import * as Dialog from '../../dialog';
|
||||
import Portal from '../../internal/portal.svelte';
|
||||
import Command from './command.svelte';
|
||||
import { computeCommandScore } from '../score';
|
||||
import type { CommandFilterFn, CommandSnippetProps } from '../types';
|
||||
import type { OnChangeFn } from '../../../types';
|
||||
|
||||
type CommandDialogProps = {
|
||||
/** Controls the dialog open state. Bindable. @default false */
|
||||
open?: boolean;
|
||||
/** Callback on dialog open/close. */
|
||||
onOpenChange?: OnChangeFn<boolean>;
|
||||
/** Whether the dialog blocks outside interaction. @default true */
|
||||
modal?: boolean;
|
||||
/** Portal target for the dialog content. @default 'body' */
|
||||
portalTo?: string | HTMLElement;
|
||||
|
||||
// ── Command pass-through ────────────────────────────────────────────────
|
||||
label?: string;
|
||||
value?: string;
|
||||
onValueChange?: OnChangeFn<string>;
|
||||
search?: string;
|
||||
onSearchChange?: OnChangeFn<string>;
|
||||
shouldFilter?: boolean;
|
||||
filter?: CommandFilterFn;
|
||||
loop?: boolean;
|
||||
vimBindings?: boolean;
|
||||
columns?: number;
|
||||
disablePointerSelection?: boolean;
|
||||
|
||||
/** Optional overlay content — if omitted, a default `Dialog.Overlay` is used. */
|
||||
overlay?: Snippet;
|
||||
children?: Snippet<[CommandSnippetProps]>;
|
||||
};
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
onOpenChange = () => {},
|
||||
modal = true,
|
||||
portalTo,
|
||||
// Command
|
||||
label,
|
||||
value = $bindable(''),
|
||||
onValueChange = () => {},
|
||||
search = $bindable(''),
|
||||
onSearchChange = () => {},
|
||||
shouldFilter = true,
|
||||
filter = computeCommandScore,
|
||||
loop = false,
|
||||
vimBindings = true,
|
||||
columns = 1,
|
||||
disablePointerSelection = false,
|
||||
overlay: overlaySnippet,
|
||||
// Rename to avoid shadowing the inner `children` snippet, which would
|
||||
// otherwise be a recursive reference and freeze the renderer.
|
||||
children: userChildren
|
||||
}: CommandDialogProps = $props();
|
||||
</script>
|
||||
|
||||
<Dialog.Provider bind:open {modal} onOpenChange={(v) => onOpenChange(v)}>
|
||||
<Portal to={portalTo}>
|
||||
{#if overlaySnippet}
|
||||
{@render overlaySnippet()}
|
||||
{:else}
|
||||
<Dialog.Overlay />
|
||||
{/if}
|
||||
<Dialog.Content>
|
||||
<Command
|
||||
{label}
|
||||
bind:value
|
||||
onValueChange={(v) => onValueChange(v)}
|
||||
bind:search
|
||||
onSearchChange={(v) => onSearchChange(v)}
|
||||
{shouldFilter}
|
||||
{filter}
|
||||
{loop}
|
||||
{vimBindings}
|
||||
{columns}
|
||||
{disablePointerSelection}
|
||||
>
|
||||
{#snippet children(sp)}
|
||||
{@render userChildren?.(sp)}
|
||||
{/snippet}
|
||||
</Command>
|
||||
</Dialog.Content>
|
||||
</Portal>
|
||||
</Dialog.Provider>
|
||||
@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '../../../reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import { CommandEmptyProvider } from '../command-provider.svelte';
|
||||
import type { CommandEmptyProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'command-empty'),
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: CommandEmptyProps = $props();
|
||||
|
||||
const state = CommandEmptyProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if state.shouldRender}
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '../../../reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import { CommandGroupHeadingProvider } from '../command-provider.svelte';
|
||||
import type { CommandGroupHeadingProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'command-group-heading'),
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: CommandGroupHeadingProps = $props();
|
||||
|
||||
const state = CommandGroupHeadingProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '../../../reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import { CommandGroupItemsProvider } from '../command-provider.svelte';
|
||||
import type { CommandGroupItemsProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'command-group-items'),
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: CommandGroupItemsProps = $props();
|
||||
|
||||
const state = CommandGroupItemsProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '../../../reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import { CommandGroupProvider } from '../command-provider.svelte';
|
||||
import type { CommandGroupProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'command-group'),
|
||||
value = id,
|
||||
forceMount = false,
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: CommandGroupProps = $props();
|
||||
|
||||
const state = CommandGroupProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
value: readableActive(() => value),
|
||||
forceMount: readableActive(() => forceMount)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
<!--
|
||||
Always render the group so its Items can mount and register. When the
|
||||
group has no visible items it exposes `data-empty` — the consumer's CSS
|
||||
(e.g. `[data-command-group][data-empty] { display: none; }`) handles the
|
||||
visual hiding. Otherwise we'd deadlock: no items registered → hasVisibleItems
|
||||
false → children never render → items never mount.
|
||||
-->
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '../../../reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import { CommandInputProvider } from '../command-provider.svelte';
|
||||
import type { CommandInputProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'command-input'),
|
||||
child,
|
||||
...restProps
|
||||
}: CommandInputProps = $props();
|
||||
|
||||
const state = CommandInputProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<input {...mergedProps} />
|
||||
{/if}
|
||||
@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '../../../reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import { CommandItemProvider } from '../command-provider.svelte';
|
||||
import type { CommandItemProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'command-item'),
|
||||
value,
|
||||
keywords = [] as readonly string[],
|
||||
disabled = false,
|
||||
forceMount = false,
|
||||
onSelect,
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: CommandItemProps = $props();
|
||||
|
||||
const state = CommandItemProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
value: readableActive(() => value),
|
||||
keywords: readableActive(() => keywords),
|
||||
disabled: readableActive(() => disabled),
|
||||
forceMount: readableActive(() => forceMount),
|
||||
onSelect: readableActive(() => onSelect)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if state.isVisible}
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '../../../reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import { CommandLinkItemProvider } from '../command-provider.svelte';
|
||||
import type { CommandLinkItemProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'command-link-item'),
|
||||
value,
|
||||
keywords = [] as readonly string[],
|
||||
disabled = false,
|
||||
forceMount = false,
|
||||
href,
|
||||
onSelect,
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: CommandLinkItemProps = $props();
|
||||
|
||||
const state = CommandLinkItemProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
value: readableActive(() => value),
|
||||
keywords: readableActive(() => keywords),
|
||||
disabled: readableActive(() => disabled),
|
||||
forceMount: readableActive(() => forceMount),
|
||||
href: readableActive(() => href),
|
||||
onSelect: readableActive(() => onSelect)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if state.isVisible}
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<a {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</a>
|
||||
{/if}
|
||||
{/if}
|
||||
@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '../../../reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import { CommandListProvider } from '../command-provider.svelte';
|
||||
import type { CommandListProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'command-list'),
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: CommandListProps = $props();
|
||||
|
||||
const state = CommandListProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
)
|
||||
});
|
||||
|
||||
const mergedProps = $derived({
|
||||
...mergeProps(restProps, state.props),
|
||||
'aria-label':
|
||||
(restProps as { 'aria-label'?: string })['aria-label'] ?? state.props['aria-label']
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '../../../reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import { CommandLoadingProvider } from '../command-provider.svelte';
|
||||
import type { CommandLoadingProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'command-loading'),
|
||||
progress,
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: CommandLoadingProps = $props();
|
||||
|
||||
const state = CommandLoadingProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
progress: readableActive(() => progress)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '../../../reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import { CommandSeparatorProvider } from '../command-provider.svelte';
|
||||
import type { CommandSeparatorProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'command-separator'),
|
||||
forceMount = false,
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: CommandSeparatorProps = $props();
|
||||
|
||||
const state = CommandSeparatorProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
forceMount: readableActive(() => forceMount)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if state.shouldRender}
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<span {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '../../../reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import { CommandViewportProvider } from '../command-provider.svelte';
|
||||
import type { CommandViewportProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'command-viewport'),
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: CommandViewportProps = $props();
|
||||
|
||||
const state = CommandViewportProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,72 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '../../../reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import { CommandProvider } from '../command-provider.svelte';
|
||||
import type { CommandProps } from '../types';
|
||||
import { computeCommandScore } from '../score';
|
||||
import { Soma } from '../../../core/soma.svelte';
|
||||
|
||||
const uid = $props.id();
|
||||
const soma = Soma.get();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'command'),
|
||||
label,
|
||||
value = $bindable(''),
|
||||
onValueChange = () => {},
|
||||
search = $bindable(''),
|
||||
onSearchChange = () => {},
|
||||
shouldFilter = true,
|
||||
filter = computeCommandScore,
|
||||
loop = false,
|
||||
vimBindings = true,
|
||||
columns = 1,
|
||||
disablePointerSelection = false,
|
||||
dir,
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: CommandProps = $props();
|
||||
|
||||
const state = CommandProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
value: writableActive(
|
||||
() => value,
|
||||
(v) => {
|
||||
value = v;
|
||||
onValueChange(v);
|
||||
}
|
||||
),
|
||||
search: writableActive(
|
||||
() => search,
|
||||
(v) => {
|
||||
search = v;
|
||||
onSearchChange(v);
|
||||
}
|
||||
),
|
||||
label: readableActive(() => label),
|
||||
shouldFilter: readableActive(() => shouldFilter),
|
||||
filter: readableActive(() => filter),
|
||||
loop: readableActive(() => loop),
|
||||
vimBindings: readableActive(() => vimBindings),
|
||||
columns: readableActive(() => columns),
|
||||
disablePointerSelection: readableActive(() => disablePointerSelection),
|
||||
dir: readableActive(() => dir ?? soma?.presentation.getDir() ?? 'ltr')
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ ...state.snippetProps, props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps}>
|
||||
{@render children?.(state.snippetProps)}
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,34 @@
|
||||
export { default as Provider } from './components/command.svelte';
|
||||
/** Composition helper: Dialog + Command in a single component. */
|
||||
export { default as Dialog } from './components/command-dialog.svelte';
|
||||
export { default as Input } from './components/command-input.svelte';
|
||||
export { default as List } from './components/command-list.svelte';
|
||||
export { default as Viewport } from './components/command-viewport.svelte';
|
||||
export { default as Item } from './components/command-item.svelte';
|
||||
export { default as LinkItem } from './components/command-link-item.svelte';
|
||||
export { default as Group } from './components/command-group.svelte';
|
||||
export { default as GroupHeading } from './components/command-group-heading.svelte';
|
||||
export { default as GroupItems } from './components/command-group-items.svelte';
|
||||
export { default as Empty } from './components/command-empty.svelte';
|
||||
export { default as Loading } from './components/command-loading.svelte';
|
||||
export { default as Separator } from './components/command-separator.svelte';
|
||||
|
||||
export { computeCommandScore } from './score';
|
||||
|
||||
export type {
|
||||
CommandFilterFn,
|
||||
CommandApi,
|
||||
CommandSnippetProps,
|
||||
CommandProps as ProviderProps,
|
||||
CommandInputProps as InputProps,
|
||||
CommandListProps as ListProps,
|
||||
CommandViewportProps as ViewportProps,
|
||||
CommandItemProps as ItemProps,
|
||||
CommandLinkItemProps as LinkItemProps,
|
||||
CommandGroupProps as GroupProps,
|
||||
CommandGroupHeadingProps as GroupHeadingProps,
|
||||
CommandGroupItemsProps as GroupItemsProps,
|
||||
CommandEmptyProps as EmptyProps,
|
||||
CommandLoadingProps as LoadingProps,
|
||||
CommandSeparatorProps as SeparatorProps
|
||||
} from './types';
|
||||
@ -0,0 +1 @@
|
||||
export * from './exports';
|
||||
@ -0,0 +1,4 @@
|
||||
/** Idlangref constants for Command. */
|
||||
export const COMMAND_LANGS = {
|
||||
LIST: '#?components.command.list|Command palette'
|
||||
} as const;
|
||||
@ -0,0 +1,89 @@
|
||||
// Public `cmdk` scoring algorithm. A score of 1 is a continuous match;
|
||||
// 0 is no match. Matches at the start of a word (space / symbol jump) are
|
||||
// rewarded; character-by-character gaps are penalised.
|
||||
|
||||
const SCORE_CONTINUE_MATCH = 1;
|
||||
const SCORE_SPACE_WORD_JUMP = 0.9;
|
||||
const SCORE_NON_SPACE_WORD_JUMP = 0.8;
|
||||
const SCORE_CHARACTER_JUMP = 0.17;
|
||||
const SCORE_TRANSPOSITION = 0.1;
|
||||
const PENALTY_SKIPPED = 0.999;
|
||||
const PENALTY_CASE_MISMATCH = 0.9999;
|
||||
const PENALTY_NOT_COMPLETE = 0.99;
|
||||
|
||||
const IS_GAP_REGEXP = /[\\/_+.#"@[({&]/;
|
||||
const COUNT_GAPS_REGEXP = /[\\/_+.#"@[({&]/g;
|
||||
const IS_SPACE_REGEXP = /[\s-]/;
|
||||
const COUNT_SPACE_REGEXP = /[\s-]/g;
|
||||
|
||||
function scoreInner(
|
||||
s: string,
|
||||
abbr: string,
|
||||
ls: string,
|
||||
la: string,
|
||||
sIdx: number,
|
||||
aIdx: number,
|
||||
memo: Record<string, number>
|
||||
): number {
|
||||
if (aIdx === abbr.length) return sIdx === s.length ? SCORE_CONTINUE_MATCH : PENALTY_NOT_COMPLETE;
|
||||
|
||||
const key = `${sIdx},${aIdx}`;
|
||||
if (memo[key] !== undefined) return memo[key];
|
||||
|
||||
const abbrChar = la.charAt(aIdx);
|
||||
let i = ls.indexOf(abbrChar, sIdx);
|
||||
let best = 0;
|
||||
|
||||
while (i >= 0) {
|
||||
let score = scoreInner(s, abbr, ls, la, i + 1, aIdx + 1, memo);
|
||||
if (score > best) {
|
||||
if (i === sIdx) {
|
||||
score *= SCORE_CONTINUE_MATCH;
|
||||
} else if (IS_GAP_REGEXP.test(s.charAt(i - 1))) {
|
||||
score *= SCORE_NON_SPACE_WORD_JUMP;
|
||||
const gaps = s.slice(sIdx, i - 1).match(COUNT_GAPS_REGEXP);
|
||||
if (gaps && sIdx > 0) score *= PENALTY_SKIPPED ** gaps.length;
|
||||
} else if (IS_SPACE_REGEXP.test(s.charAt(i - 1))) {
|
||||
score *= SCORE_SPACE_WORD_JUMP;
|
||||
const spaces = s.slice(sIdx, i - 1).match(COUNT_SPACE_REGEXP);
|
||||
if (spaces && sIdx > 0) score *= PENALTY_SKIPPED ** spaces.length;
|
||||
} else {
|
||||
score *= SCORE_CHARACTER_JUMP;
|
||||
if (sIdx > 0) score *= PENALTY_SKIPPED ** (i - sIdx);
|
||||
}
|
||||
if (s.charAt(i) !== abbr.charAt(aIdx)) score *= PENALTY_CASE_MISMATCH;
|
||||
}
|
||||
|
||||
if (
|
||||
(score < SCORE_TRANSPOSITION && ls.charAt(i - 1) === la.charAt(aIdx + 1)) ||
|
||||
(la.charAt(aIdx + 1) === la.charAt(aIdx) && ls.charAt(i - 1) !== la.charAt(aIdx))
|
||||
) {
|
||||
const transposed = scoreInner(s, abbr, ls, la, i + 1, aIdx + 2, memo);
|
||||
if (transposed * SCORE_TRANSPOSITION > score) score = transposed * SCORE_TRANSPOSITION;
|
||||
}
|
||||
|
||||
if (score > best) best = score;
|
||||
i = ls.indexOf(abbrChar, i + 1);
|
||||
}
|
||||
|
||||
memo[key] = best;
|
||||
return best;
|
||||
}
|
||||
|
||||
function normalize(s: string): string {
|
||||
return s.toLowerCase().replace(COUNT_SPACE_REGEXP, ' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Default scorer. Returns a float in [0, 1]; 0 means no match.
|
||||
* Keywords are joined with the value and scored as a single string.
|
||||
*/
|
||||
export function computeCommandScore(
|
||||
value: string,
|
||||
search: string,
|
||||
keywords?: readonly string[]
|
||||
): number {
|
||||
if (!search) return 1;
|
||||
const combined = keywords && keywords.length > 0 ? `${value} ${keywords.join(' ')}` : value;
|
||||
return scoreInner(combined, search, normalize(combined), normalize(search), 0, 0, {});
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { writableActive } from '../../../reactive';
|
||||
import { MenuCheckboxGroupProvider } from '../dropdown-menu-provider.svelte';
|
||||
import type { MenuCheckboxGroupProps } from '../types';
|
||||
|
||||
let {
|
||||
value = $bindable<string[]>([]),
|
||||
onValueChange = () => {},
|
||||
children
|
||||
}: MenuCheckboxGroupProps = $props();
|
||||
|
||||
MenuCheckboxGroupProvider.create({
|
||||
value: writableActive(
|
||||
() => value,
|
||||
(v) => {
|
||||
value = v;
|
||||
onValueChange(v);
|
||||
}
|
||||
)
|
||||
});
|
||||
</script>
|
||||
|
||||
{@render children?.()}
|
||||
@ -0,0 +1,188 @@
|
||||
# Menubar
|
||||
|
||||
A horizontal menu bar of dropdown menus. Supports submenus, checkbox items, radio groups, keyboard navigation, typeahead, and cross-menu arrow navigation. Built on top of DropdownMenu.
|
||||
|
||||
## Anatomy
|
||||
|
||||
```svelte
|
||||
<Menubar.Provider>
|
||||
<Menubar.Menu value="file">
|
||||
<Menubar.Trigger>File</Menubar.Trigger>
|
||||
<Menubar.Content>
|
||||
<Menubar.Item>New File</Menubar.Item>
|
||||
<Menubar.Item>Open…</Menubar.Item>
|
||||
<Menubar.Separator />
|
||||
<Menubar.Item>Save</Menubar.Item>
|
||||
</Menubar.Content>
|
||||
</Menubar.Menu>
|
||||
|
||||
<Menubar.Menu value="edit">
|
||||
<Menubar.Trigger>Edit</Menubar.Trigger>
|
||||
<Menubar.Content>
|
||||
<Menubar.Item>Undo</Menubar.Item>
|
||||
<Menubar.Item>Redo</Menubar.Item>
|
||||
<Menubar.Separator />
|
||||
<Menubar.CheckboxItem bind:checked={showToolbar}>Show Toolbar</Menubar.CheckboxItem>
|
||||
</Menubar.Content>
|
||||
</Menubar.Menu>
|
||||
</Menubar.Provider>
|
||||
```
|
||||
|
||||
## Parts
|
||||
|
||||
| Part | Element | Description |
|
||||
| --------------- | ---------- | ------------------------------------------------------------ |
|
||||
| `Provider` | `<div>` | Root context. Manages which menu is open. |
|
||||
| `Menu` | none | Wrapper for a single menu. Creates an internal DropdownMenu. |
|
||||
| `Trigger` | `<button>` | Opens the menu on click or keyboard. Roving tabindex. |
|
||||
| `Content` | `<div>` | Menu container. Floating positioning, cross-menu navigation. |
|
||||
| `Arrow` | `<svg>` | Optional arrow (re-exported from DropdownMenu). |
|
||||
| `Item` | `<div>` | Action item (re-exported from DropdownMenu). |
|
||||
| `Group` | `<div>` | Groups related items (re-exported from DropdownMenu). |
|
||||
| `GroupHeading` | `<div>` | Group label (re-exported from DropdownMenu). |
|
||||
| `Separator` | `<div>` | Visual separator (re-exported from DropdownMenu). |
|
||||
| `CheckboxItem` | `<div>` | Toggleable item (re-exported from DropdownMenu). |
|
||||
| `CheckboxGroup` | `<div>` | Multi-select checkbox group (re-exported from DropdownMenu). |
|
||||
| `RadioGroup` | `<div>` | Mutually exclusive group (re-exported from DropdownMenu). |
|
||||
| `RadioItem` | `<div>` | Radio option (re-exported from DropdownMenu). |
|
||||
| `Sub` | none | Submenu context (re-exported from DropdownMenu). |
|
||||
| `SubTrigger` | `<div>` | Opens a submenu (re-exported from DropdownMenu). |
|
||||
| `SubContent` | `<div>` | Submenu container (re-exported from DropdownMenu). |
|
||||
|
||||
## ARIA
|
||||
|
||||
| Part | Attribute | Value |
|
||||
| -------- | ------------------ | ----------------- |
|
||||
| Provider | `role` | `menubar` |
|
||||
| Provider | `aria-orientation` | `horizontal` |
|
||||
| Trigger | `role` | `menuitem` |
|
||||
| Trigger | `aria-haspopup` | `menu` |
|
||||
| Trigger | `aria-expanded` | `true` \| `false` |
|
||||
| Trigger | `aria-controls` | ID of Content |
|
||||
| Content | `role` | `menu` |
|
||||
| Content | `aria-labelledby` | ID of Trigger |
|
||||
| Content | `aria-orientation` | `vertical` |
|
||||
|
||||
Item, CheckboxItem, RadioGroup, RadioItem, SubTrigger, SubContent, Group, Separator, and Arrow inherit ARIA attributes from DropdownMenu.
|
||||
|
||||
## Data Attributes
|
||||
|
||||
| Part | Attribute | Values |
|
||||
| -------- | ---------------------- | -------------------------------------- |
|
||||
| Provider | `data-menubar` | Always present |
|
||||
| Trigger | `data-menubar-trigger` | Always present |
|
||||
| Trigger | `data-state` | `open` \| `closed` |
|
||||
| Trigger | `data-disabled` | Present when disabled |
|
||||
| Trigger | `data-highlighted` | Present when focused |
|
||||
| Trigger | `data-menubar-value` | The parent Menu's value |
|
||||
| Content | `data-state` | `open` \| `closed` |
|
||||
| Content | `data-side` | `top` \| `right` \| `bottom` \| `left` |
|
||||
| Content | `data-align` | `start` \| `center` \| `end` |
|
||||
|
||||
Item, CheckboxItem, RadioItem, SubTrigger, SubContent, Group, Separator, and Arrow inherit data attributes from DropdownMenu.
|
||||
|
||||
## CSS Variables
|
||||
|
||||
| Variable | Part | Description |
|
||||
| ---------------------------------- | ------- | ------------------------------- |
|
||||
| `--soma-floating-transform-origin` | Content | Transform origin for animations |
|
||||
| `--soma-floating-available-width` | Content | Available viewport width |
|
||||
| `--soma-floating-available-height` | Content | Available viewport height |
|
||||
| `--soma-floating-anchor-width` | Content | Anchor element width |
|
||||
| `--soma-floating-anchor-height` | Content | Anchor element height |
|
||||
|
||||
## Keyboard
|
||||
|
||||
### On Trigger (menubar level)
|
||||
|
||||
| Key | Action |
|
||||
| ------------------ | ------------------------------ |
|
||||
| `Enter` / `Space` | Open the associated menu |
|
||||
| `ArrowDown` | Open the associated menu |
|
||||
| `ArrowRight` (LTR) | Move focus to next trigger |
|
||||
| `ArrowLeft` (LTR) | Move focus to previous trigger |
|
||||
| `Home` | Focus first trigger |
|
||||
| `End` | Focus last trigger |
|
||||
|
||||
### On Content (menu level)
|
||||
|
||||
| Key | Action |
|
||||
| ------------------ | --------------------------------- |
|
||||
| `ArrowDown` | Move to next item |
|
||||
| `ArrowUp` | Move to previous item |
|
||||
| `Home` | Move to first item |
|
||||
| `End` | Move to last item |
|
||||
| `Enter` / `Space` | Activate focused item |
|
||||
| `Escape` | Close menu, focus trigger |
|
||||
| `ArrowRight` (LTR) | Open next menu (cross-menu nav) |
|
||||
| `ArrowLeft` (LTR) | Open previous menu (cross-menu) |
|
||||
| Character keys | Typeahead — jump to matching item |
|
||||
|
||||
Horizontal arrow keys switch between menus while a menu is open. Directional keys are flipped in RTL mode.
|
||||
|
||||
Triggers use roving tabindex: one trigger has `tabindex="0"`, all others have `tabindex="-1"`.
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
import { Menubar } from '$soma/components';
|
||||
</script>
|
||||
|
||||
<Menubar.Provider>
|
||||
<Menubar.Menu value="file">
|
||||
<Menubar.Trigger>File</Menubar.Trigger>
|
||||
<Menubar.Content>
|
||||
<Menubar.Item>New File</Menubar.Item>
|
||||
<Menubar.Item>Open</Menubar.Item>
|
||||
<Menubar.Separator />
|
||||
<Menubar.Item>Save</Menubar.Item>
|
||||
</Menubar.Content>
|
||||
</Menubar.Menu>
|
||||
|
||||
<Menubar.Menu value="edit">
|
||||
<Menubar.Trigger>Edit</Menubar.Trigger>
|
||||
<Menubar.Content>
|
||||
<Menubar.Item>Undo</Menubar.Item>
|
||||
<Menubar.Item>Redo</Menubar.Item>
|
||||
</Menubar.Content>
|
||||
</Menubar.Menu>
|
||||
</Menubar.Provider>
|
||||
```
|
||||
|
||||
### Checkbox item
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let showToolbar = $state(true);
|
||||
</script>
|
||||
|
||||
<Menubar.CheckboxItem bind:checked={showToolbar}>Show Toolbar</Menubar.CheckboxItem>
|
||||
```
|
||||
|
||||
### Radio group
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
let theme = $state('light');
|
||||
</script>
|
||||
|
||||
<Menubar.RadioGroup bind:value={theme}>
|
||||
<Menubar.RadioItem value="light">Light</Menubar.RadioItem>
|
||||
<Menubar.RadioItem value="dark">Dark</Menubar.RadioItem>
|
||||
</Menubar.RadioGroup>
|
||||
```
|
||||
|
||||
### Submenu
|
||||
|
||||
```svelte
|
||||
<Menubar.Sub>
|
||||
<Menubar.SubTrigger>Recent</Menubar.SubTrigger>
|
||||
<Menubar.SubContent>
|
||||
<Menubar.Item>File 1</Menubar.Item>
|
||||
<Menubar.Item>File 2</Menubar.Item>
|
||||
</Menubar.SubContent>
|
||||
</Menubar.Sub>
|
||||
```
|
||||
@ -0,0 +1,81 @@
|
||||
<script lang="ts">
|
||||
import DropdownMenuContent from '../../dropdown-menu/components/dropdown-menu-content.svelte';
|
||||
import type { MenuContentProps } from '../../dropdown-menu/types';
|
||||
import { MenubarMenuProvider } from '../menubar-provider.svelte';
|
||||
import { KEYS, getDirectionalKeys } from '../../../keyboard';
|
||||
|
||||
let {
|
||||
children: outerChildren,
|
||||
child: outerChild,
|
||||
onkeydown: userKeydown,
|
||||
...rest
|
||||
}: MenuContentProps & { onkeydown?: (e: KeyboardEvent) => void } = $props();
|
||||
|
||||
const menu = MenubarMenuProvider.require();
|
||||
const provider = menu.provider;
|
||||
|
||||
/**
|
||||
* Cross-menu arrow navigation while focus is inside the open menu's content.
|
||||
* - `ArrowRight` (LTR): close current, open next menu — unless focus is on a
|
||||
* SubTrigger (opens submenu instead) or already inside a SubContent.
|
||||
* - `ArrowLeft` (LTR): close current, open previous menu — unless focus is
|
||||
* inside a SubContent (closes submenu instead).
|
||||
* Runs BEFORE DropdownMenu's own onkeydown; preventDefault suppresses the
|
||||
* item-level vertical-navigation handler only for these two keys.
|
||||
*/
|
||||
const crossMenuNav = (e: KeyboardEvent) => {
|
||||
const dir = provider.opts.dir.current;
|
||||
const { nextKey, prevKey } = getDirectionalKeys(dir, 'horizontal');
|
||||
if (e.key !== nextKey && e.key !== prevKey) return;
|
||||
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (!target) return;
|
||||
|
||||
const isSubTrigger = target.hasAttribute('data-dropdown-menu-sub-trigger');
|
||||
const isInsideSub = !!target.closest('[data-dropdown-menu-sub-content]');
|
||||
|
||||
if (e.key === nextKey && isSubTrigger) return;
|
||||
if (e.key === prevKey && isInsideSub) return;
|
||||
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) return;
|
||||
|
||||
e.preventDefault();
|
||||
const currentValue = menu.opts.value.current;
|
||||
const delta: 1 | -1 = e.key === nextKey ? 1 : -1;
|
||||
const values = provider.getOrderedValues();
|
||||
if (values.length === 0) return;
|
||||
|
||||
const idx = values.indexOf(currentValue);
|
||||
let nextIdx: number;
|
||||
if (idx === -1) {
|
||||
nextIdx = delta === 1 ? 0 : values.length - 1;
|
||||
} else if (provider.opts.loop.current) {
|
||||
nextIdx = (idx + delta + values.length) % values.length;
|
||||
} else {
|
||||
nextIdx = Math.max(0, Math.min(values.length - 1, idx + delta));
|
||||
}
|
||||
const nextValue = values[nextIdx];
|
||||
if (!nextValue || nextValue === currentValue) return;
|
||||
|
||||
provider.open(nextValue);
|
||||
provider.focusTrigger(nextValue);
|
||||
};
|
||||
|
||||
const composedKeydown = (e: KeyboardEvent) => {
|
||||
if (e.key === KEYS.ESCAPE) {
|
||||
const current = menu.opts.value.current;
|
||||
queueMicrotask(() => provider.focusTrigger(current));
|
||||
}
|
||||
crossMenuNav(e);
|
||||
if (!e.defaultPrevented) userKeydown?.(e);
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if outerChild}
|
||||
<DropdownMenuContent {...rest} onkeydown={composedKeydown} child={outerChild} />
|
||||
{:else}
|
||||
<DropdownMenuContent {...rest} onkeydown={composedKeydown}>
|
||||
{#snippet children(sp)}
|
||||
{@render outerChildren?.(sp)}
|
||||
{/snippet}
|
||||
</DropdownMenuContent>
|
||||
{/if}
|
||||
@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { readableActive } from '../../../reactive';
|
||||
import { MenubarMenuProvider } from '../menubar-provider.svelte';
|
||||
import type { MenubarMenuProps } from '../types';
|
||||
|
||||
let {
|
||||
value,
|
||||
onOpenChange = () => {},
|
||||
onOpenChangeComplete = () => {},
|
||||
children
|
||||
}: MenubarMenuProps = $props();
|
||||
|
||||
MenubarMenuProvider.create({
|
||||
value: readableActive(() => value),
|
||||
onOpenChange: readableActive(() => onOpenChange),
|
||||
onOpenChangeComplete: readableActive(() => onOpenChangeComplete)
|
||||
});
|
||||
</script>
|
||||
|
||||
{@render children?.()}
|
||||
@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '../../../reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import { MenubarTriggerProvider } from '../menubar-provider.svelte';
|
||||
import type { MenubarTriggerProps } from '../types';
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'menubar-trigger'),
|
||||
disabled = false,
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: MenubarTriggerProps = $props();
|
||||
|
||||
const state = MenubarTriggerProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
disabled: readableActive(() => disabled)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<button {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</button>
|
||||
{/if}
|
||||
@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import { readableActive, writableActive } from '../../../reactive';
|
||||
import { mergeProps } from '../../../props';
|
||||
import { createId } from '../../../id';
|
||||
import { MenubarProvider } from '../menubar-provider.svelte';
|
||||
import type { MenubarProps } from '../types';
|
||||
import { Soma } from '../../../core/soma.svelte';
|
||||
|
||||
const uid = $props.id();
|
||||
const soma = Soma.get();
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
id = createId(uid, 'menubar'),
|
||||
value = $bindable(''),
|
||||
onValueChange = () => {},
|
||||
dir,
|
||||
loop = true,
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: MenubarProps = $props();
|
||||
|
||||
const state = MenubarProvider.create({
|
||||
id: readableActive(() => id),
|
||||
ref: writableActive(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
value: writableActive(
|
||||
() => value,
|
||||
(v) => {
|
||||
value = v;
|
||||
onValueChange(v);
|
||||
}
|
||||
),
|
||||
dir: readableActive(() => dir ?? soma?.presentation.getDir() ?? 'ltr'),
|
||||
loop: readableActive(() => loop)
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, state.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,44 @@
|
||||
export { default as Provider } from './components/menubar.svelte';
|
||||
export { default as Menu } from './components/menubar-menu.svelte';
|
||||
export { default as Trigger } from './components/menubar-trigger.svelte';
|
||||
|
||||
// Menubar-specific Content adds cross-menu ArrowLeft/Right navigation on top
|
||||
// of DropdownMenu.Content (which supplies Floating, Presence, Dismissal, etc.).
|
||||
export { default as Content } from './components/menubar-content.svelte';
|
||||
// Reused DropdownMenu parts — they read from the nested MenuProvider context
|
||||
// that MenubarMenu establishes automatically.
|
||||
export { default as Arrow } from '../dropdown-menu/components/dropdown-menu-arrow.svelte';
|
||||
export { default as Item } from '../dropdown-menu/components/dropdown-menu-item.svelte';
|
||||
export { default as Group } from '../dropdown-menu/components/dropdown-menu-group.svelte';
|
||||
export { default as GroupHeading } from '../dropdown-menu/components/dropdown-menu-group-heading.svelte';
|
||||
export { default as Separator } from '../dropdown-menu/components/dropdown-menu-separator.svelte';
|
||||
export { default as CheckboxItem } from '../dropdown-menu/components/dropdown-menu-checkbox-item.svelte';
|
||||
export { default as CheckboxGroup } from '../dropdown-menu/components/dropdown-menu-checkbox-group.svelte';
|
||||
export { default as RadioGroup } from '../dropdown-menu/components/dropdown-menu-radio-group.svelte';
|
||||
export { default as RadioItem } from '../dropdown-menu/components/dropdown-menu-radio-item.svelte';
|
||||
export { default as Sub } from '../dropdown-menu/components/dropdown-menu-sub.svelte';
|
||||
export { default as SubTrigger } from '../dropdown-menu/components/dropdown-menu-sub-trigger.svelte';
|
||||
export { default as SubContent } from '../dropdown-menu/components/dropdown-menu-sub-content.svelte';
|
||||
|
||||
export type {
|
||||
MenubarProps as ProviderProps,
|
||||
MenubarMenuProps as MenuProps,
|
||||
MenubarTriggerProps as TriggerProps
|
||||
} from './types';
|
||||
|
||||
// Re-export Menu part prop types so Menubar consumers get a complete API.
|
||||
export type {
|
||||
MenuContentProps as ContentProps,
|
||||
MenuArrowProps as ArrowProps,
|
||||
MenuItemProps as ItemProps,
|
||||
MenuGroupProps as GroupProps,
|
||||
MenuGroupHeadingProps as GroupHeadingProps,
|
||||
MenuSeparatorProps as SeparatorProps,
|
||||
MenuCheckboxItemProps as CheckboxItemProps,
|
||||
MenuCheckboxGroupProps as CheckboxGroupProps,
|
||||
MenuRadioGroupProps as RadioGroupProps,
|
||||
MenuRadioItemProps as RadioItemProps,
|
||||
MenuSubProps as SubProps,
|
||||
MenuSubTriggerProps as SubTriggerProps,
|
||||
MenuSubContentProps as SubContentProps
|
||||
} from '../dropdown-menu/types';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue