From d7059169709ce20e2ce36f553fc88b196fbb1616 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Wed, 14 Jul 2021 17:04:57 -0700 Subject: [PATCH] Internationalization support for Calendar (#1804) Also fixes a bug in ListLayout: When an item moved from one section to another, cached nodes and layout infos were not updated properly. --- NOTICE.txt | 87 ++++ packages/@internationalized/date/README.md | 3 + packages/@internationalized/date/index.ts | 13 + packages/@internationalized/date/package.json | 23 + .../date/scripts/generate-umalqura.js | 169 ++++++++ .../date/src/CalendarDate.ts | 82 ++++ .../date/src/calendars/BuddhistCalendar.ts | 44 ++ .../date/src/calendars/EthiopicCalendar.ts | 169 ++++++++ .../date/src/calendars/GregorianCalendar.ts | 97 +++++ .../date/src/calendars/HebrewCalendar.ts | 181 ++++++++ .../date/src/calendars/IndianCalendar.ts | 115 +++++ .../date/src/calendars/IslamicCalendar.ts | 199 +++++++++ .../date/src/calendars/JapaneseCalendar.ts | 94 ++++ .../date/src/calendars/PersianCalendar.ts | 88 ++++ .../date/src/calendars/TaiwanCalendar.ts | 73 ++++ .../@internationalized/date/src/conversion.ts | 203 +++++++++ .../date/src/createCalendar.ts | 54 +++ packages/@internationalized/date/src/index.ts | 27 ++ .../date/src/manipulation.ts | 136 ++++++ .../@internationalized/date/src/queries.ts | 58 +++ packages/@internationalized/date/src/types.ts | 41 ++ packages/@internationalized/date/src/utils.ts | 29 ++ .../date/tests/conversion.test.js | 403 ++++++++++++++++++ .../date/tests/manipulation.test.js | 357 ++++++++++++++++ packages/@react-aria/calendar/package.json | 1 + .../@react-aria/calendar/src/useCalendar.ts | 5 +- .../calendar/src/useCalendarBase.ts | 3 +- .../calendar/src/useCalendarCell.ts | 22 +- .../calendar/src/useRangeCalendar.ts | 8 +- .../@react-aria/i18n/src/useDateFormatter.ts | 11 +- .../@react-spectrum/calendar/package.json | 1 + .../@react-spectrum/calendar/src/Calendar.tsx | 6 +- .../calendar/src/CalendarBase.tsx | 10 +- .../calendar/src/CalendarCell.tsx | 17 +- .../calendar/src/CalendarTableHeader.tsx | 7 +- .../calendar/src/RangeCalendar.tsx | 6 +- .../calendar/stories/Calendar.stories.tsx | 77 +++- .../combobox/test/ComboBox.test.js | 4 + packages/@react-stately/calendar/package.json | 1 + packages/@react-stately/calendar/src/types.ts | 34 +- .../calendar/src/useCalendarState.ts | 110 +++-- .../calendar/src/useRangeCalendarState.ts | 37 +- .../calendar/src/useWeekStart.ts | 6 +- .../collections/src/CollectionBuilder.ts | 1 + .../@react-stately/layout/src/ListLayout.ts | 49 +-- packages/@react-types/calendar/src/index.d.ts | 1 + 46 files changed, 3034 insertions(+), 128 deletions(-) create mode 100644 packages/@internationalized/date/README.md create mode 100644 packages/@internationalized/date/index.ts create mode 100644 packages/@internationalized/date/package.json create mode 100644 packages/@internationalized/date/scripts/generate-umalqura.js create mode 100644 packages/@internationalized/date/src/CalendarDate.ts create mode 100644 packages/@internationalized/date/src/calendars/BuddhistCalendar.ts create mode 100644 packages/@internationalized/date/src/calendars/EthiopicCalendar.ts create mode 100644 packages/@internationalized/date/src/calendars/GregorianCalendar.ts create mode 100644 packages/@internationalized/date/src/calendars/HebrewCalendar.ts create mode 100644 packages/@internationalized/date/src/calendars/IndianCalendar.ts create mode 100644 packages/@internationalized/date/src/calendars/IslamicCalendar.ts create mode 100644 packages/@internationalized/date/src/calendars/JapaneseCalendar.ts create mode 100644 packages/@internationalized/date/src/calendars/PersianCalendar.ts create mode 100644 packages/@internationalized/date/src/calendars/TaiwanCalendar.ts create mode 100644 packages/@internationalized/date/src/conversion.ts create mode 100644 packages/@internationalized/date/src/createCalendar.ts create mode 100644 packages/@internationalized/date/src/index.ts create mode 100644 packages/@internationalized/date/src/manipulation.ts create mode 100644 packages/@internationalized/date/src/queries.ts create mode 100644 packages/@internationalized/date/src/types.ts create mode 100644 packages/@internationalized/date/src/utils.ts create mode 100644 packages/@internationalized/date/tests/conversion.test.js create mode 100644 packages/@internationalized/date/tests/manipulation.test.js diff --git a/NOTICE.txt b/NOTICE.txt index 75ce1d748b8..63782c099d6 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -127,3 +127,90 @@ This codebase contains a portion of code that vuejs adapted from jest-dom which * https://github.com/testing-library/jest-dom/blob/main/LICENSE ------------------------------------------------------------------------------ +This codebase contains a modified portion of code from ICU which can be obtained at: + * SOURCE: + * https://github.com/unicode-org/icu + + * LICENSE: + COPYRIGHT AND PERMISSION NOTICE (ICU 58 and later) + + Copyright © 1991-2020 Unicode, Inc. All rights reserved. + Distributed under the Terms of Use in https://www.unicode.org/copyright.html. + + Permission is hereby granted, free of charge, to any person obtaining + a copy of the Unicode data files and any associated documentation + (the "Data Files") or Unicode software and any associated documentation + (the "Software") to deal in the Data Files or Software + without restriction, including without limitation the rights to use, + copy, modify, merge, publish, distribute, and/or sell copies of + the Data Files or Software, and to permit persons to whom the Data Files + or Software are furnished to do so, provided that either + (a) this copyright and permission notice appear with all copies + of the Data Files or Software, or + (b) this copyright and permission notice appear in associated + Documentation. + + THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF + ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE + WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + NONINFRINGEMENT OF THIRD PARTY RIGHTS. + IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS + NOTICE BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL + DAMAGES, OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, + DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER + TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR + PERFORMANCE OF THE DATA FILES OR SOFTWARE. + + Except as contained in this notice, the name of a copyright holder + shall not be used in advertising or otherwise to promote the sale, + use or other dealings in these Data Files or Software without prior + written authorization of the copyright holder. + +------------------------------------------------------------------------------- +This codebase contains a modified portion of code from the TC39 Temporal proposal which can be obtained at: + * SOURCE: + * https://github.com/tc39/proposal-temporal + + * LICENSE: + Copyright (c) 2017, 2018, 2019, 2020 + Ecma International. All rights reserved. + + All Software contained in this document ("Software") is protected by copyright + and is being made available under the "BSD License", included below. + + This Software may be subject to third party rights (rights from parties other + than Ecma International), including patent rights, and no licenses under such + third party rights are granted under this license even if the third party + concerned is a member of Ecma International. + + SEE THE ECMA CODE OF CONDUCT IN PATENT MATTERS AVAILABLE AT + https://ecma-international.org/memento/codeofconduct.htm + FOR INFORMATION REGARDING THE LICENSING OF PATENT CLAIMS THAT ARE REQUIRED TO + IMPLEMENT ECMA INTERNATIONAL STANDARDS. + + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + + 3. Neither the name of the authors nor Ecma International may be used to + endorse or promote products derived from this software without specific prior + written permission. + + THIS SOFTWARE IS PROVIDED BY THE ECMA INTERNATIONAL "AS IS" AND + ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + ARE DISCLAIMED. IN NO EVENT SHALL ECMA INTERNATIONAL BE LIABLE + FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS + OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) + HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT + LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY + OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF + SUCH DAMAGE. diff --git a/packages/@internationalized/date/README.md b/packages/@internationalized/date/README.md new file mode 100644 index 00000000000..e046718fbe9 --- /dev/null +++ b/packages/@internationalized/date/README.md @@ -0,0 +1,3 @@ +# @internationalized/date + +This package is part of [react-spectrum](https://github.com/adobe/react-spectrum). See the repo for more details. diff --git a/packages/@internationalized/date/index.ts b/packages/@internationalized/date/index.ts new file mode 100644 index 00000000000..1210ae1e402 --- /dev/null +++ b/packages/@internationalized/date/index.ts @@ -0,0 +1,13 @@ +/* + * 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. + */ + +export * from './src'; diff --git a/packages/@internationalized/date/package.json b/packages/@internationalized/date/package.json new file mode 100644 index 00000000000..b7ccdca471c --- /dev/null +++ b/packages/@internationalized/date/package.json @@ -0,0 +1,23 @@ +{ + "name": "@internationalized/date", + "version": "3.0.0-alpha.1", + "description": "Internationalized calendar and date manipulation utilities", + "license": "Apache-2.0", + "private": true, + "main": "dist/main.js", + "module": "dist/module.js", + "types": "dist/types.d.ts", + "source": "src/index.ts", + "files": ["dist"], + "sideEffects": false, + "repository": { + "type": "git", + "url": "https://github.com/adobe/react-spectrum" + }, + "dependencies": { + "@babel/runtime": "^7.6.2" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/@internationalized/date/scripts/generate-umalqura.js b/packages/@internationalized/date/scripts/generate-umalqura.js new file mode 100644 index 00000000000..50fe9839107 --- /dev/null +++ b/packages/@internationalized/date/scripts/generate-umalqura.js @@ -0,0 +1,169 @@ +/* + * 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. + */ + +// Copied from ICU's IslamicCalendar.java +// Original licensing can be found in the NOTICE file in the root directory of this source tree. + +const UMALQURA_MONTHLENGTH = [ + //* 1300 -1302 */ + // "1010 1010 1010", "1101 0101 0100", "1110 1100 1001", + 0x0AAA, 0x0D54, 0x0EC9, + //* 1303 -1307 */ + // "0110 1101 0100", "0110 1110 1010", "0011 0110 1100", "1010 1010 1101", "0101 0101 0101", + 0x06D4, 0x06EA, 0x036C, 0x0AAD, 0x0555, + //* 1308 -1312 */ + // "0110 1010 1001", "0111 1001 0010", "1011 1010 1001", "0101 1101 0100", "1010 1101 1010", + 0x06A9, 0x0792, 0x0BA9, 0x05D4, 0x0ADA, + //* 1313 -1317 */ + // "0101 0101 1100", "1101 0010 1101", "0110 1001 0101", "0111 0100 1010", "1011 0101 0100", + 0x055C, 0x0D2D, 0x0695, 0x074A, 0x0B54, + //* 1318 -1322 */ + // "1011 0110 1010", "0101 1010 1101", "0100 1010 1110", "1010 0100 1111", "0101 0001 0111", + 0x0B6A, 0x05AD, 0x04AE, 0x0A4F, 0x0517, + //* 1323 -1327 */ + // "0110 1000 1011", "0110 1010 0101", "1010 1101 0101", "0010 1101 0110", "1001 0101 1011", + 0x068B, 0x06A5, 0x0AD5, 0x02D6, 0x095B, + //* 1328 -1332 */ + // "0100 1001 1101", "1010 0100 1101", "1101 0010 0110", "1101 1001 0101", "0101 1010 1100", + 0x049D, 0x0A4D, 0x0D26, 0x0D95, 0x05AC, + //* 1333 -1337 */ + // "1001 1011 0110", "0010 1011 1010", "1010 0101 1011", "0101 0010 1011", "1010 1001 0101", + 0x09B6, 0x02BA, 0x0A5B, 0x052B, 0x0A95, + //* 1338 -1342 */ + // "0110 1100 1010", "1010 1110 1001", "0010 1111 0100", "1001 0111 0110", "0010 1011 0110", + 0x06CA, 0x0AE9, 0x02F4, 0x0976, 0x02B6, + //* 1343 -1347 */ + // "1001 0101 0110", "1010 1100 1010", "1011 1010 0100", "1011 1101 0010", "0101 1101 1001", + 0x0956, 0x0ACA, 0x0BA4, 0x0BD2, 0x05D9, + //* 1348 -1352 */ + // "0010 1101 1100", "1001 0110 1101", "0101 0100 1101", "1010 1010 0101", "1011 0101 0010", + 0x02DC, 0x096D, 0x054D, 0x0AA5, 0x0B52, + //* 1353 -1357 */ + // "1011 1010 0101", "0101 1011 0100", "1001 1011 0110", "0101 0101 0111", "0010 1001 0111", + 0x0BA5, 0x05B4, 0x09B6, 0x0557, 0x0297, + //* 1358 -1362 */ + // "0101 0100 1011", "0110 1010 0011", "0111 0101 0010", "1011 0110 0101", "0101 0110 1010", + 0x054B, 0x06A3, 0x0752, 0x0B65, 0x056A, + //* 1363 -1367 */ + // "1010 1010 1011", "0101 0010 1011", "1100 1001 0101", "1101 0100 1010", "1101 1010 0101", + 0x0AAB, 0x052B, 0x0C95, 0x0D4A, 0x0DA5, + //* 1368 -1372 */ + // "0101 1100 1010", "1010 1101 0110", "1001 0101 0111", "0100 1010 1011", "1001 0100 1011", + 0x05CA, 0x0AD6, 0x0957, 0x04AB, 0x094B, + //* 1373 -1377 */ + // "1010 1010 0101", "1011 0101 0010", "1011 0110 1010", "0101 0111 0101", "0010 0111 0110", + 0x0AA5, 0x0B52, 0x0B6A, 0x0575, 0x0276, + //* 1378 -1382 */ + // "1000 1011 0111", "0100 0101 1011", "0101 0101 0101", "0101 1010 1001", "0101 1011 0100", + 0x08B7, 0x045B, 0x0555, 0x05A9, 0x05B4, + //* 1383 -1387 */ + // "1001 1101 1010", "0100 1101 1101", "0010 0110 1110", "1001 0011 0110", "1010 1010 1010", + 0x09DA, 0x04DD, 0x026E, 0x0936, 0x0AAA, + //* 1388 -1392 */ + // "1101 0101 0100", "1101 1011 0010", "0101 1101 0101", "0010 1101 1010", "1001 0101 1011", + 0x0D54, 0x0DB2, 0x05D5, 0x02DA, 0x095B, + //* 1393 -1397 */ + // "0100 1010 1011", "1010 0101 0101", "1011 0100 1001", "1011 0110 0100", "1011 0111 0001", + 0x04AB, 0x0A55, 0x0B49, 0x0B64, 0x0B71, + //* 1398 -1402 */ + // "0101 1011 0100", "1010 1011 0101", "1010 0101 0101", "1101 0010 0101", "1110 1001 0010", + 0x05B4, 0x0AB5, 0x0A55, 0x0D25, 0x0E92, + //* 1403 -1407 */ + // "1110 1100 1001", "0110 1101 0100", "1010 1110 1001", "1001 0110 1011", "0100 1010 1011", + 0x0EC9, 0x06D4, 0x0AE9, 0x096B, 0x04AB, + //* 1408 -1412 */ + // "1010 1001 0011", "1101 0100 1001", "1101 1010 0100", "1101 1011 0010", "1010 1011 1001", + 0x0A93, 0x0D49, 0x0DA4, 0x0DB2, 0x0AB9, + //* 1413 -1417 */ + // "0100 1011 1010", "1010 0101 1011", "0101 0010 1011", "1010 1001 0101", "1011 0010 1010", + 0x04BA, 0x0A5B, 0x052B, 0x0A95, 0x0B2A, + //* 1418 -1422 */ + // "1011 0101 0101", "0101 0101 1100", "0100 1011 1101", "0010 0011 1101", "1001 0001 1101", + 0x0B55, 0x055C, 0x04BD, 0x023D, 0x091D, + //* 1423 -1427 */ + // "1010 1001 0101", "1011 0100 1010", "1011 0101 1010", "0101 0110 1101", "0010 1011 0110", + 0x0A95, 0x0B4A, 0x0B5A, 0x056D, 0x02B6, + //* 1428 -1432 */ + // "1001 0011 1011", "0100 1001 1011", "0110 0101 0101", "0110 1010 1001", "0111 0101 0100", + 0x093B, 0x049B, 0x0655, 0x06A9, 0x0754, + //* 1433 -1437 */ + // "1011 0110 1010", "0101 0110 1100", "1010 1010 1101", "0101 0101 0101", "1011 0010 1001", + 0x0B6A, 0x056C, 0x0AAD, 0x0555, 0x0B29, + //* 1438 -1442 */ + // "1011 1001 0010", "1011 1010 1001", "0101 1101 0100", "1010 1101 1010", "0101 0101 1010", + 0x0B92, 0x0BA9, 0x05D4, 0x0ADA, 0x055A, + //* 1443 -1447 */ + // "1010 1010 1011", "0101 1001 0101", "0111 0100 1001", "0111 0110 0100", "1011 1010 1010", + 0x0AAB, 0x0595, 0x0749, 0x0764, 0x0BAA, + //* 1448 -1452 */ + // "0101 1011 0101", "0010 1011 0110", "1010 0101 0110", "1110 0100 1101", "1011 0010 0101", + 0x05B5, 0x02B6, 0x0A56, 0x0E4D, 0x0B25, + //* 1453 -1457 */ + // "1011 0101 0010", "1011 0110 1010", "0101 1010 1101", "0010 1010 1110", "1001 0010 1111", + 0x0B52, 0x0B6A, 0x05AD, 0x02AE, 0x092F, + //* 1458 -1462 */ + // "0100 1001 0111", "0110 0100 1011", "0110 1010 0101", "0110 1010 1100", "1010 1101 0110", + 0x0497, 0x064B, 0x06A5, 0x06AC, 0x0AD6, + //* 1463 -1467 */ + // "0101 0101 1101", "0100 1001 1101", "1010 0100 1101", "1101 0001 0110", "1101 1001 0101", + 0x055D, 0x049D, 0x0A4D, 0x0D16, 0x0D95, + //* 1468 -1472 */ + // "0101 1010 1010", "0101 1011 0101", "0010 1101 1010", "1001 0101 1011", "0100 1010 1101", + 0x05AA, 0x05B5, 0x02DA, 0x095B, 0x04AD, + //* 1473 -1477 */ + // "0101 1001 0101", "0110 1100 1010", "0110 1110 0100", "1010 1110 1010", "0100 1111 0101", + 0x0595, 0x06CA, 0x06E4, 0x0AEA, 0x04F5, + //* 1478 -1482 */ + // "0010 1011 0110", "1001 0101 0110", "1010 1010 1010", "1011 0101 0100", "1011 1101 0010", + 0x02B6, 0x0956, 0x0AAA, 0x0B54, 0x0BD2, + //* 1483 -1487 */ + // "0101 1101 1001", "0010 1110 1010", "1001 0110 1101", "0100 1010 1101", "1010 1001 0101", + 0x05D9, 0x02EA, 0x096D, 0x04AD, 0x0A95, + //* 1488 -1492 */ + // "1011 0100 1010", "1011 1010 0101", "0101 1011 0010", "1001 1011 0101", "0100 1101 0110", + 0x0B4A, 0x0BA5, 0x05B2, 0x09B5, 0x04D6, + //* 1493 -1497 */ + // "1010 1001 0111", "0101 0100 0111", "0110 1001 0011", "0111 0100 1001", "1011 0101 0101", + 0x0A97, 0x0547, 0x0693, 0x0749, 0x0B55, + //* 1498 -1508 */ + // "0101 0110 1010", "1010 0110 1011", "0101 0010 1011", "1010 1000 1011", "1101 0100 0110", "1101 1010 0011", "0101 1100 1010", "1010 1101 0110", "0100 1101 1011", "0010 0110 1011", "1001 0100 1011", + 0x056A, 0x0A6B, 0x052B, 0x0A8B, 0x0D46, 0x0DA3, 0x05CA, 0x0AD6, 0x04DB, 0x026B, 0x094B, + //* 1509 -1519 */ + // "1010 1010 0101", "1011 0101 0010", "1011 0110 1001", "0101 0111 0101", "0001 0111 0110", "1000 1011 0111", "0010 0101 1011", "0101 0010 1011", "0101 0110 0101", "0101 1011 0100", "1001 1101 1010", + 0x0AA5, 0x0B52, 0x0B69, 0x0575, 0x0176, 0x08B7, 0x025B, 0x052B, 0x0565, 0x05B4, 0x09DA, + //* 1520 -1530 */ + // "0100 1110 1101", "0001 0110 1101", "1000 1011 0110", "1010 1010 0110", "1101 0101 0010", "1101 1010 1001", "0101 1101 0100", "1010 1101 1010", "1001 0101 1011", "0100 1010 1011", "0110 0101 0011", + 0x04ED, 0x016D, 0x08B6, 0x0AA6, 0x0D52, 0x0DA9, 0x05D4, 0x0ADA, 0x095B, 0x04AB, 0x0653, + //* 1531 -1541 */ + // "0111 0010 1001", "0111 0110 0010", "1011 1010 1001", "0101 1011 0010", "1010 1011 0101", "0101 0101 0101", "1011 0010 0101", "1101 1001 0010", "1110 1100 1001", "0110 1101 0010", "1010 1110 1001", + 0x0729, 0x0762, 0x0BA9, 0x05B2, 0x0AB5, 0x0555, 0x0B25, 0x0D92, 0x0EC9, 0x06D2, 0x0AE9, + //* 1542 -1552 */ + // "0101 0110 1011", "0100 1010 1011", "1010 0101 0101", "1101 0010 1001", "1101 0101 0100", "1101 1010 1010", "1001 1011 0101", "0100 1011 1010", "1010 0011 1011", "0100 1001 1011", "1010 0100 1101", + 0x056B, 0x04AB, 0x0A55, 0x0D29, 0x0D54, 0x0DAA, 0x09B5, 0x04BA, 0x0A3B, 0x049B, 0x0A4D, + //* 1553 -1563 */ + // "1010 1010 1010", "1010 1101 0101", "0010 1101 1010", "1001 0101 1101", "0100 0101 1110", "1010 0010 1110", "1100 1001 1010", "1101 0101 0101", "0110 1011 0010", "0110 1011 1001", "0100 1011 1010", + 0x0AAA, 0x0AD5, 0x02DA, 0x095D, 0x045E, 0x0A2E, 0x0C9A, 0x0D55, 0x06B2, 0x06B9, 0x04BA, + //* 1564 -1574 */ + // "1010 0101 1101", "0101 0010 1101", "1010 1001 0101", "1011 0101 0010", "1011 1010 1000", "1011 1011 0100", "0101 1011 1001", "0010 1101 1010", "1001 0101 1010", "1011 0100 1010", "1101 1010 0100", + 0x0A5D, 0x052D, 0x0A95, 0x0B52, 0x0BA8, 0x0BB4, 0x05B9, 0x02DA, 0x095A, 0x0B4A, 0x0DA4, + //* 1575 -1585 */ + // "1110 1101 0001", "0110 1110 1000", "1011 0110 1010", "0101 0110 1101", "0101 0011 0101", "0110 1001 0101", "1101 0100 1010", "1101 1010 1000", "1101 1101 0100", "0110 1101 1010", "0101 0101 1011", + 0x0ED1, 0x06E8, 0x0B6A, 0x056D, 0x0535, 0x0695, 0x0D4A, 0x0DA8, 0x0DD4, 0x06DA, 0x055B, + //* 1586 -1596 */ + // "0010 1001 1101", "0110 0010 1011", "1011 0001 0101", "1011 0100 1010", "1011 1001 0101", "0101 1010 1010", "1010 1010 1110", "1001 0010 1110", "1100 1000 1111", "0101 0010 0111", "0110 1001 0101", + 0x029D, 0x062B, 0x0B15, 0x0B4A, 0x0B95, 0x05AA, 0x0AAE, 0x092E, 0x0C8F, 0x0527, 0x0695, + //* 1597 -1600 */ + // "0110 1010 1010", "1010 1101 0110", "0101 0101 1101", "0010 1001 1101", }; + 0x06AA, 0x0AD6, 0x055D, 0x029D +]; + +console.log(Buffer.from(new Uint16Array(UMALQURA_MONTHLENGTH).buffer).toString('base64')); diff --git a/packages/@internationalized/date/src/CalendarDate.ts b/packages/@internationalized/date/src/CalendarDate.ts new file mode 100644 index 00000000000..8adfabe4f6c --- /dev/null +++ b/packages/@internationalized/date/src/CalendarDate.ts @@ -0,0 +1,82 @@ +/* + * 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 {Calendar} from './types'; +import {GregorianCalendar} from './calendars/GregorianCalendar'; + +function shiftArgs(args: any[]) { + let calendar: Calendar = typeof args[0] === 'object' + ? args.shift() + : new GregorianCalendar(); + + let era = typeof args[0] === 'string' + ? args.shift() + : calendar.getCurrentEra(); + + let year = args.shift(); + let month = args.shift(); + let day = args.shift(); + + return [calendar, era, year, month, day]; +} + +export class CalendarDate { + public readonly calendar: Calendar; + public readonly era: string; + public readonly year: number; + public readonly month: number; + public readonly day: number; + + constructor(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; + + if (this.calendar.balanceDate) { + this.calendar.balanceDate(this); + } + } +} + +export class Time { + constructor( + public readonly hour: number = 0, + public readonly minute: number = 0, + public readonly second: number = 0, + public readonly millisecond: number = 0 + ) {} +} + +export class CalendarDateTime extends CalendarDate { + public readonly hour: number; + public readonly minute: number; + public readonly second: number; + public readonly millisecond: number; + + constructor(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); + super(calendar, era, year, month, day); + this.hour = args.shift() || 0; + this.minute = args.shift() || 0; + this.second = args.shift() || 0; + this.millisecond = args.shift() || 0; + } +} diff --git a/packages/@internationalized/date/src/calendars/BuddhistCalendar.ts b/packages/@internationalized/date/src/calendars/BuddhistCalendar.ts new file mode 100644 index 00000000000..378da822e10 --- /dev/null +++ b/packages/@internationalized/date/src/calendars/BuddhistCalendar.ts @@ -0,0 +1,44 @@ +/* + * 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 {CalendarDate} from '../CalendarDate'; +import {GregorianCalendar} from './GregorianCalendar'; +import {Mutable} from '../utils'; + +const BUDDHIST_ERA_START = -543; + +export class BuddhistCalendar extends GregorianCalendar { + identifier = 'buddhist'; + + fromJulianDay(jd: number): CalendarDate { + let date = super.fromJulianDay(jd) as Mutable; + date.year -= BUDDHIST_ERA_START; + return date; + } + + toJulianDay(date: CalendarDate) { + return super.toJulianDay( + new CalendarDate( + date.year + BUDDHIST_ERA_START, + date.month, + date.day + ) + ); + } + + getCurrentEra() { + return 'BE'; + } +} diff --git a/packages/@internationalized/date/src/calendars/EthiopicCalendar.ts b/packages/@internationalized/date/src/calendars/EthiopicCalendar.ts new file mode 100644 index 00000000000..fd98f9d94a5 --- /dev/null +++ b/packages/@internationalized/date/src/calendars/EthiopicCalendar.ts @@ -0,0 +1,169 @@ +/* + * 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 {Calendar} from '../types'; +import {CalendarDate} from '../CalendarDate'; +import {Mutable} from '../utils'; + +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(calendar: Calendar, epoch: number, jd: number): Mutable { + 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 new CalendarDate(calendar, 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; + } +} + +export class EthiopicCalendar implements Calendar { + identifier = 'ethiopic'; + + fromJulianDay(jd: number): CalendarDate { + let date = julianDayToCE(this, ETHIOPIC_EPOCH, jd); + if (date.year > 0) { + date.era = 'AM'; + } else { + date.era = 'AA'; + date.year += AMETE_MIHRET_DELTA; + } + + return date; + } + + toJulianDay(date: CalendarDate) { + let year = date.year; + if (date.era === 'AA') { + year -= AMETE_MIHRET_DELTA; + } + + return ceToJulianDay(ETHIOPIC_EPOCH, year, date.month, date.day); + } + + getDaysInMonth(date: CalendarDate): number { + let year = date.year; + if (date.era === 'AA') { + year -= AMETE_MIHRET_DELTA; + } + + return getDaysInMonth(year, date.month); + } + + getMonthsInYear(): number { + return 13; + } + + getDaysInYear(date: CalendarDate): number { + return 365 + getLeapDay(date.year); + } + + getCurrentEra() { + return 'AM'; + } +} + +export class EthiopicAmeteAlemCalendar extends EthiopicCalendar { + identifier = 'ethioaa'; // also known as 'ethiopic-amete-alem' in ICU + + fromJulianDay(jd: number): CalendarDate { + let date = julianDayToCE(this, ETHIOPIC_EPOCH, jd); + date.era = 'AA'; + date.year += AMETE_MIHRET_DELTA; + return date; + } + + getCurrentEra() { + return 'AA'; + } +} + +export class CopticCalendar extends EthiopicCalendar { + identifier = 'coptic'; + + fromJulianDay(jd: number): CalendarDate { + let date = julianDayToCE(this, COPTIC_EPOCH, jd); + if (date.year <= 0) { + date.era = 'BCE'; + date.year = 1 - date.year; + } else { + date.era = 'CE'; + } + + return date; + } + + toJulianDay(date: CalendarDate) { + let year = date.year; + if (date.era === 'BCE') { + year = 1 - year; + } + + return ceToJulianDay(COPTIC_EPOCH, year, date.month, date.day); + } + + getDaysInMonth(date: CalendarDate): number { + let year = date.year; + if (date.era === 'BCE') { + year = 1 - year; + } + + return getDaysInMonth(year, date.month); + } + + addYears(date: Mutable, years: number) { + if (date.era === 'BCE') { + years = -years; + } + + date.year += years; + } + + getCurrentEra() { + return 'CE'; + } +} diff --git a/packages/@internationalized/date/src/calendars/GregorianCalendar.ts b/packages/@internationalized/date/src/calendars/GregorianCalendar.ts new file mode 100644 index 00000000000..e28739dfe96 --- /dev/null +++ b/packages/@internationalized/date/src/calendars/GregorianCalendar.ts @@ -0,0 +1,97 @@ +/* + * 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 {Calendar} from '../types'; +import {CalendarDate} from '../CalendarDate'; +import {mod} from '../utils'; + +const EPOCH = 1721426; // 001/01/03 Julian C.E. +export function gregorianToJulianDay(year: number, month: number, day: number): number { + 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); +} + +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] +}; + +export class GregorianCalendar implements Calendar { + identifier = '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 year = quadricent * 400 + cent * 100 + quad * 4 + yindex + (cent !== 4 && yindex !== 4 ? 1 : 0); + let yearDay = jd0 - gregorianToJulianDay(year, 1, 1); + let leapAdj = 2; + if (jd0 < gregorianToJulianDay(year, 3, 1)) { + leapAdj = 0; + } else if (isLeapYear(year)) { + leapAdj = 1; + } + let month = Math.floor(((yearDay + leapAdj) * 12 + 373) / 367); + let day = jd0 - gregorianToJulianDay(year, month, 1) + 1; + + return new CalendarDate(this, year, month, day); + } + + toJulianDay(date: CalendarDate): number { + return gregorianToJulianDay(date.year, date.month, date.day); + } + + getDaysInMonth(date: CalendarDate): number { + return daysInMonth[isLeapYear(date.year) ? 'leapyear' : 'standard'][date.month - 1]; + } + + getMonthsInYear(): number { + return 12; + } + + getDaysInYear(date: CalendarDate): number { + return isLeapYear(date.year) ? 366 : 365; + } + + getCurrentEra() { + return 'AD'; + } +} diff --git a/packages/@internationalized/date/src/calendars/HebrewCalendar.ts b/packages/@internationalized/date/src/calendars/HebrewCalendar.ts new file mode 100644 index 00000000000..da7dc9c5388 --- /dev/null +++ b/packages/@internationalized/date/src/calendars/HebrewCalendar.ts @@ -0,0 +1,181 @@ +/* + * 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 {Calendar} from '../types'; +import {CalendarDate} from '../CalendarDate'; +import {mod} from '../utils'; + +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; +} + +export class HebrewCalendar implements Calendar { + identifier = '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: CalendarDate) { + 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: CalendarDate): number { + return getDaysInMonth(date.year, date.month); + } + + getMonthsInYear(date: CalendarDate): number { + return isLeapYear(date.year) ? 13 : 12; + } + + getDaysInYear(date: CalendarDate): number { + return getDaysInYear(date.year); + } + + getCurrentEra() { + return 'AM'; + } +} diff --git a/packages/@internationalized/date/src/calendars/IndianCalendar.ts b/packages/@internationalized/date/src/calendars/IndianCalendar.ts new file mode 100644 index 00000000000..54fe1dfe91f --- /dev/null +++ b/packages/@internationalized/date/src/calendars/IndianCalendar.ts @@ -0,0 +1,115 @@ +/* + * 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 {CalendarDate} from '../CalendarDate'; +import {GregorianCalendar, gregorianToJulianDay, isLeapYear} from './GregorianCalendar'; +import {Mutable} from '../utils'; + +// 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; + +export class IndianCalendar extends GregorianCalendar { + identifier = 'indian'; + + fromJulianDay(jd: number): CalendarDate { + // Gregorian date for Julian day + let date = super.fromJulianDay(jd) as Mutable; + + // Year in Saka era + let indianYear = date.year - INDIAN_ERA_START; + + // Day number in Gregorian year (starting from 0) + let yDay = jd - gregorianToJulianDay(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: CalendarDate) { + let year = date.year + INDIAN_ERA_START; + + let leapMonth: number; + let jd: number; + if (isLeapYear(year)) { + leapMonth = 31; + jd = gregorianToJulianDay(year, 3, 21); + } else { + leapMonth = 30; + jd = gregorianToJulianDay(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: CalendarDate): number { + if (date.month === 1 && isLeapYear(date.year + INDIAN_ERA_START)) { + return 31; + } + + if (date.month >= 2 && date.month <= 6) { + return 31; + } + + return 30; + } + + getCurrentEra() { + return 'saka'; + } +} diff --git a/packages/@internationalized/date/src/calendars/IslamicCalendar.ts b/packages/@internationalized/date/src/calendars/IslamicCalendar.ts new file mode 100644 index 00000000000..8c6f3f326d8 --- /dev/null +++ b/packages/@internationalized/date/src/calendars/IslamicCalendar.ts @@ -0,0 +1,199 @@ +/* + * 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 {Calendar} from '../types'; +import {CalendarDate} from '../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; +} + +export class IslamicCivilCalendar implements Calendar { + identifier = 'islamic-civil'; + + fromJulianDay(jd: number): CalendarDate { + return julianDayToIslamic(this, CIVIL_EPOC, jd); + } + + toJulianDay(date: CalendarDate) { + return islamicToJulianDay(CIVIL_EPOC, date.year, date.month, date.day); + } + + getDaysInMonth(date: CalendarDate): number { + let length = 29 + date.month % 2; + if (date.month === 12 && isLeapYear(date.year)) { + length++; + } + + return length; + } + + getMonthsInYear(): number { + return 12; + } + + getDaysInYear(date: CalendarDate): number { + return isLeapYear(date.year) ? 355 : 354; + } + + getCurrentEra() { + return 'AH'; + } +} + +export class IslamicTabularCalendar extends IslamicCivilCalendar { + identifier = 'islamic-tbla'; + + fromJulianDay(jd: number): CalendarDate { + return julianDayToIslamic(this, ASTRONOMICAL_EPOC, jd); + } + + toJulianDay(date: CalendarDate) { + 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]; +} + +export class IslamicUmalquraCalendar extends IslamicCivilCalendar { + identifier = '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: CalendarDate): 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: CalendarDate): number { + if (date.year < UMALQURA_YEAR_START || date.year > UMALQURA_YEAR_END) { + return super.getDaysInMonth(date); + } + + return umalquraMonthLength(date.year, date.month); + } + + getDaysInYear(date: CalendarDate): number { + if (date.year < UMALQURA_YEAR_START || date.year > UMALQURA_YEAR_END) { + return super.getDaysInYear(date); + } + + return umalquraYearLength(date.year); + } +} diff --git a/packages/@internationalized/date/src/calendars/JapaneseCalendar.ts b/packages/@internationalized/date/src/calendars/JapaneseCalendar.ts new file mode 100644 index 00000000000..2f9ec5b23dc --- /dev/null +++ b/packages/@internationalized/date/src/calendars/JapaneseCalendar.ts @@ -0,0 +1,94 @@ +/* + * 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 {CalendarDate} from '../CalendarDate'; +import {GregorianCalendar} from './GregorianCalendar'; +import {Mutable} from '../utils'; + +const ERA_START_DATES = [[1868, 9, 8], [1912, 7, 30], [1926, 12, 25], [1989, 1, 8], [2019, 5, 1]]; +const ERA_ADDENDS = [1867, 1911, 1925, 1988, 2018]; +const ERA_NAMES = ['meiji', 'taisho', 'showa', 'heisei', 'reiwa']; + +function findEraFromGregorianDate(date: CalendarDate) { + 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: CalendarDate) { + 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 + ); +} + +export class JapaneseCalendar extends GregorianCalendar { + identifier = 'japanese'; + + fromJulianDay(jd: number): CalendarDate { + let date = super.fromJulianDay(jd) as Mutable; + + let era = findEraFromGregorianDate(date); + date.era = ERA_NAMES[era]; + date.year -= ERA_ADDENDS[era]; + return date; + } + + toJulianDay(date: CalendarDate) { + return super.toJulianDay(toGregorian(date)); + } + + balanceDate(date: Mutable) { + 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]; + } + } + + getCurrentEra() { + return ERA_NAMES[ERA_NAMES.length - 1]; + } +} diff --git a/packages/@internationalized/date/src/calendars/PersianCalendar.ts b/packages/@internationalized/date/src/calendars/PersianCalendar.ts new file mode 100644 index 00000000000..94621e70a4c --- /dev/null +++ b/packages/@internationalized/date/src/calendars/PersianCalendar.ts @@ -0,0 +1,88 @@ +/* + * 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 {Calendar} from '../types'; +import {CalendarDate} from '../CalendarDate'; +import {mod} from '../utils'; + +const PERSIAN_EPOCH = 1948321; // 622/03/19 Julian C.E. + +function isLeapYear(year: number): boolean { + let y0 = year > 0 ? year - 474 : year - 473; + let y1 = mod(y0, 2820) + 474; + + return mod((y1 + 38) * 31, 128) < 31; +} + +function persianToJulianDay(year: number, month: number, day: number): number { + let y0 = year > 0 ? year - 474 : year - 473; + let y1 = mod(y0, 2820) + 474; + let offset = month <= 7 ? 31 * (month - 1) : 30 * (month - 1) + 6; + + return ( + PERSIAN_EPOCH - + 1 + + 1029983 * Math.floor(y0 / 2820) + + 365 * (y1 - 1) + + Math.floor((31 * y1 - 5) / 128) + + offset + + day + ); +} + +export class PersianCalendar implements Calendar { + identifier = 'persian'; + + fromJulianDay(jd: number): CalendarDate { + let d0 = jd - persianToJulianDay(475, 1, 1); + let n2820 = Math.floor(d0 / 1029983); + let d1 = mod(d0, 1029983); + let y2820 = d1 === 1029982 ? 2820 : Math.floor((128 * d1 + 46878) / 46751); + let year = 474 + 2820 * n2820 + y2820; + if (year <= 0) { + year--; + } + + let yDay = jd - persianToJulianDay(year, 1, 1) + 1; + let month = yDay <= 186 ? Math.ceil(yDay / 31) : Math.ceil((yDay - 6) / 31); + let day = jd - persianToJulianDay(year, month, 1) + 1; + + return new CalendarDate(this, year, month, day); + } + + toJulianDay(date: CalendarDate): number { + return persianToJulianDay(date.year, date.month, date.day); + } + + getMonthsInYear(): number { + return 12; + } + + getDaysInMonth(date: CalendarDate): number { + if (date.month <= 6) { + return 31; + } + + if (date.month <= 11) { + return 30; + } + + return isLeapYear(date.year) ? 30 : 29; + } + + getCurrentEra() { + return 'AP'; + } +} diff --git a/packages/@internationalized/date/src/calendars/TaiwanCalendar.ts b/packages/@internationalized/date/src/calendars/TaiwanCalendar.ts new file mode 100644 index 00000000000..2cbac1da952 --- /dev/null +++ b/packages/@internationalized/date/src/calendars/TaiwanCalendar.ts @@ -0,0 +1,73 @@ +/* + * 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 {CalendarDate} from '../CalendarDate'; +import {GregorianCalendar} from './GregorianCalendar'; +import {Mutable} from '../utils'; + +const TAIWAN_ERA_START = 1911; + +function gregorianYear(date: CalendarDate) { + return date.era === 'minguo' + ? date.year + TAIWAN_ERA_START + : 1 - date.year + TAIWAN_ERA_START; +} + +function gregorianToTaiwan(year: number, date: Mutable) { + let y = year - TAIWAN_ERA_START; + if (y > 0) { + date.era = 'minguo'; + date.year = y; + } else { + date.era = 'before_minguo'; + date.year = 1 - y; + } +} + +export class TaiwanCalendar extends GregorianCalendar { + identifier = 'roc'; // Republic of China + + fromJulianDay(jd: number): CalendarDate { + let date = super.fromJulianDay(jd) as Mutable; + gregorianToTaiwan(date.year, date); + return date; + } + + toJulianDay(date: CalendarDate) { + return super.toJulianDay( + new CalendarDate( + gregorianYear(date), + date.month, + date.day + ) + ); + } + + getCurrentEra() { + return 'minguo'; + } + + balanceDate(date: Mutable) { + gregorianToTaiwan(gregorianYear(date), date); + } + + addYears(date: Mutable, years: number) { + if (date.era === 'before_minguo') { + years = -years; + } + + date.year += years; + } +} diff --git a/packages/@internationalized/date/src/conversion.ts b/packages/@internationalized/date/src/conversion.ts new file mode 100644 index 00000000000..e549d79d7fd --- /dev/null +++ b/packages/@internationalized/date/src/conversion.ts @@ -0,0 +1,203 @@ +/* + * 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 {Calendar} from './types'; +import {CalendarDate, CalendarDateTime, Time} from './CalendarDate'; +import {GregorianCalendar} from './calendars/GregorianCalendar'; + +function epochFromDate(date: CalendarDateTime) { + date = toCalendar(date, new GregorianCalendar()); + return epochFromParts(date.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) { + // 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) { + let {year, month, day, hour, minute, second} = getTimeZoneParts(ms, timeZone); + let utc = epochFromParts(year, month, day, hour, minute, second, 0); + return utc - ms; +} + +const formattersByTimeZone = new Map(); + +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 { + year: namedParts.era === 'BC' ? -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; +} + +type Disambiguation = 'compatible' | 'earlier' | 'later' | 'reject'; + +export function toAbsolute(date: CalendarDate, timeZone: string, disambiguation: Disambiguation = 'compatible'): number { + let dateTime = toCalendarDateTime(date); + 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, timeZone: string, disambiguation: Disambiguation = 'compatible'): Date { + return new Date(toAbsolute(dateTime, timeZone, disambiguation)); +} + +export function fromAbsolute(ms: number, timeZone: string): CalendarDateTime { + 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 CalendarDateTime(year, month, day, hour, minute, second, millisecond); +} + +export function toCalendarDate(dateTime: CalendarDateTime): CalendarDate { + return new CalendarDate(dateTime.calendar, dateTime.era, dateTime.year, dateTime.month, dateTime.day); +} + +export function toCalendarDateTime(date: CalendarDate, time?: Time): CalendarDateTime { + if (date instanceof CalendarDateTime && !time) { + return date; + } + + if (time) { + return new CalendarDateTime( + date.calendar, + date.era, + date.year, + date.month, + date.day, + time.hour, + time.minute, + time.second, + time.millisecond + ); + } + + return new CalendarDateTime(date.calendar, date.era, date.year, date.month, date.day); +} + +export function toTime(dateTime: CalendarDateTime): Time { + return new Time(dateTime.hour, dateTime.minute, dateTime.second, dateTime.millisecond); +} + +/* eslint-disable no-redeclare */ +export function toCalendar(date: CalendarDateTime, calendar: Calendar): CalendarDateTime; +export function toCalendar(date: CalendarDate, calendar: Calendar): CalendarDate; +export function toCalendar(date: CalendarDate | CalendarDateTime, calendar: Calendar): CalendarDate | CalendarDateTime { +/* eslint-enable no-redeclare */ + if (date.calendar.identifier === calendar.identifier) { + return date; + } + + let calendarDate = calendar.fromJulianDay(date.calendar.toJulianDay(date)); + if (date instanceof CalendarDateTime) { + return toCalendarDateTime(calendarDate, toTime(date)); + } + + return calendarDate; +} diff --git a/packages/@internationalized/date/src/createCalendar.ts b/packages/@internationalized/date/src/createCalendar.ts new file mode 100644 index 00000000000..6695c29fee3 --- /dev/null +++ b/packages/@internationalized/date/src/createCalendar.ts @@ -0,0 +1,54 @@ +/* + * 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 {Calendar} 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'; + +export function createCalendar(name: string): 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 'taiwan': + return new TaiwanCalendar(); + case 'gregory': + default: + return new GregorianCalendar(); + } +} diff --git a/packages/@internationalized/date/src/index.ts b/packages/@internationalized/date/src/index.ts new file mode 100644 index 00000000000..20cd0094352 --- /dev/null +++ b/packages/@internationalized/date/src/index.ts @@ -0,0 +1,27 @@ +/* + * 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. + */ + +export * from './CalendarDate'; +export {GregorianCalendar} from './calendars/GregorianCalendar'; +export {JapaneseCalendar} from './calendars/JapaneseCalendar'; +export {BuddhistCalendar} from './calendars/BuddhistCalendar'; +export {TaiwanCalendar} from './calendars/TaiwanCalendar'; +export {PersianCalendar} from './calendars/PersianCalendar'; +export {IndianCalendar} from './calendars/IndianCalendar'; +export {IslamicCivilCalendar, IslamicTabularCalendar, IslamicUmalquraCalendar} from './calendars/IslamicCalendar'; +export {HebrewCalendar} from './calendars/HebrewCalendar'; +export {EthiopicCalendar, EthiopicAmeteAlemCalendar, CopticCalendar} from './calendars/EthiopicCalendar'; +export {createCalendar} from './createCalendar'; +export * from './manipulation'; +export * from './conversion'; +export * from './queries'; +export * from './types'; diff --git a/packages/@internationalized/date/src/manipulation.ts b/packages/@internationalized/date/src/manipulation.ts new file mode 100644 index 00000000000..970925b6e87 --- /dev/null +++ b/packages/@internationalized/date/src/manipulation.ts @@ -0,0 +1,136 @@ +/* + * 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'; +import {copy, Mutable} from './utils'; +import {DateFields, Duration} from './types'; + +export function add(date: CalendarDate, duration: Duration): CalendarDate { + let mutableDate = copy(date); + addYears(mutableDate, duration.years || 0); + mutableDate.month += duration.months || 0; + + balanceYearMonth(mutableDate); + constrain(mutableDate); + + mutableDate.day += (duration.weeks || 0) * 7; + mutableDate.day += duration.days || 0; + + balanceDay(mutableDate); + + if (mutableDate.calendar.balanceDate) { + mutableDate.calendar.balanceDate(mutableDate); + } + + return mutableDate; +} + +function addYears(date: Mutable, years: number) { + if (date.calendar.addYears) { + date.calendar.addYears(date, years); + } else { + date.year += years; + } +} + +function balanceYearMonth(date: Mutable) { + while (date.month < 1) { + addYears(date, -1); + date.month += date.calendar.getMonthsInYear(date); + } + + let monthsInYear = 0; + while (date.month > (monthsInYear = date.calendar.getMonthsInYear(date))) { + addYears(date, 1); + date.month -= monthsInYear; + } +} + +function balanceDay(date: Mutable) { + 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 balance(date: Mutable) { + balanceYearMonth(date); + balanceDay(date); + + if (date.calendar.balanceDate) { + date.calendar.balanceDate(date); + } +} + +function constrain(date: Mutable) { + 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 subtract(date: CalendarDate, duration: Duration): CalendarDate { + let inverseDuration = {}; + for (let key in duration) { + if (typeof duration[key] === 'number') { + inverseDuration[key] = -duration[key]; + } + } + + return add(date, inverseDuration); +} + +export function set(date: CalendarDate, fields: DateFields, behavior: 'balance' | 'constrain' = 'balance'): CalendarDate { + let mutableDate = copy(date); + + if (fields.year != null) { + mutableDate.year = fields.year; + } + + if (fields.month != null) { + mutableDate.month = fields.month; + } + + if (fields.day != null) { + mutableDate.day = fields.day; + } + + switch (behavior) { + case 'balance': + balance(mutableDate); + break; + case 'constrain': + constrain(mutableDate); + break; + default: + throw new Error(`Invalid behavior: ${behavior}. Must be either 'balance' or 'constrain'.`); + } + + return mutableDate; +} + +export function startOfMonth(date: CalendarDate): CalendarDate { + let mutableDate = copy(date); + mutableDate.day = 1; + return mutableDate; +} + +export function endOfMonth(date: CalendarDate): CalendarDate { + let mutableDate = copy(date); + mutableDate.day = date.calendar.getDaysInMonth(date); + return mutableDate; +} diff --git a/packages/@internationalized/date/src/queries.ts b/packages/@internationalized/date/src/queries.ts new file mode 100644 index 00000000000..7d5dca5c30a --- /dev/null +++ b/packages/@internationalized/date/src/queries.ts @@ -0,0 +1,58 @@ +/* + * 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, CalendarDateTime} from './CalendarDate'; +import {fromAbsolute, toCalendar, toCalendarDate} from './conversion'; + +export function isSameDay(a: CalendarDate, b: CalendarDate): boolean { + b = toCalendar(b, a.calendar); + return a.era === b.era && a.year === b.year && a.month === b.month && a.day === b.day; +} + +export function isSameMonth(a: CalendarDate, b: CalendarDate): boolean { + b = toCalendar(b, a.calendar); + return a.era === b.era && a.year === b.year && a.month === b.month; +} + +export function isSameYear(a: CalendarDate, b: CalendarDate): boolean { + b = toCalendar(b, a.calendar); + return a.era === b.era && a.year === b.year; +} + +export function isToday(date: CalendarDate, timeZone: string): boolean { + return isSameDay(date, today(timeZone)); +} + +export function getDayOfWeek(date: CalendarDate) { + 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; + } + + return dayOfWeek; +} + +export function now(timeZone: string): CalendarDateTime { + return fromAbsolute(Date.now(), timeZone); +} + +export function today(timeZone: string): CalendarDate { + return toCalendarDate(now(timeZone)); +} + +export function compare(a: CalendarDate, b: CalendarDate): number { + return a.calendar.toJulianDay(a) - b.calendar.toJulianDay(b); +} diff --git a/packages/@internationalized/date/src/types.ts b/packages/@internationalized/date/src/types.ts new file mode 100644 index 00000000000..52e67a810ba --- /dev/null +++ b/packages/@internationalized/date/src/types.ts @@ -0,0 +1,41 @@ +/* + * 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'; + +export interface Calendar { + identifier: string, + + fromJulianDay(jd: number): CalendarDate, + toJulianDay(date: CalendarDate): number, + + getDaysInMonth(date: CalendarDate): number, + getMonthsInYear(date: CalendarDate): number, + + getCurrentEra(): string, + + balanceDate?(date: CalendarDate): void, + addYears?(date: CalendarDate, years: number): void +} + +export interface Duration { + years?: number, + months?: number, + weeks?: number, + days?: number +} + +export interface DateFields { + year?: number, + month?: number, + day?: number +} diff --git a/packages/@internationalized/date/src/utils.ts b/packages/@internationalized/date/src/utils.ts new file mode 100644 index 00000000000..8c3c20198ae --- /dev/null +++ b/packages/@internationalized/date/src/utils.ts @@ -0,0 +1,29 @@ +/* + * 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'; + +export type Mutable = { + -readonly[P in keyof T]: T[P] +}; + +export function mod(amount: number, numerator: number): number { + return amount - numerator * Math.floor(amount / numerator); +} + +export function copy(date: CalendarDate): Mutable { + if (date.era) { + return new CalendarDate(date.calendar, date.era, date.year, date.month, date.day); + } else { + return new CalendarDate(date.calendar, date.year, date.month, date.day); + } +} diff --git a/packages/@internationalized/date/tests/conversion.test.js b/packages/@internationalized/date/tests/conversion.test.js new file mode 100644 index 00000000000..8984cb3ff81 --- /dev/null +++ b/packages/@internationalized/date/tests/conversion.test.js @@ -0,0 +1,403 @@ +/* + * 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, CalendarDate, CalendarDateTime, fromAbsolute, GregorianCalendar, HebrewCalendar, IndianCalendar, IslamicCivilCalendar, IslamicTabularCalendar, IslamicUmalquraCalendar, JapaneseCalendar, PersianCalendar, possibleAbsolutes, TaiwanCalendar, Time, toAbsolute, toCalendar, toCalendarDate, toCalendarDateTime, toDate, toTime} from '..'; + +describe('CalendarDate conversion', function () { + describe('toAbsolute', function () { + it('should handle a normal date', function () { + let date = new CalendarDateTime(2020, 2, 3, 2); + expect(toAbsolute(date, 'America/Los_Angeles')).toBe(new Date('2020-02-03T10:00Z').getTime()); + }); + + it('should handle daylight saving time start', function () { + let date = new CalendarDateTime(2020, 3, 8, 2); + expect(toAbsolute(date, 'America/Los_Angeles')).toBe(new Date('2020-03-08T10:00Z').getTime()); + }); + + it('should handle daylight saving time start with disambiguation = earlier', function () { + let date = new CalendarDateTime(2020, 3, 8, 2); + expect(toAbsolute(date, 'America/Los_Angeles', 'earlier')).toBe(new Date('2020-03-08T09:00Z').getTime()); + }); + + it('should throw with daylight saving time start if disambiguation = reject', function () { + let date = new CalendarDateTime(2020, 3, 8, 2); + expect(() => { + toAbsolute(date, 'America/Los_Angeles', 'reject'); + }).toThrow('No such absolute time found'); + }); + + it('should handle daylight saving time end', function () { + let date = new CalendarDateTime(2020, 11, 1, 1); + expect(toAbsolute(date, 'America/Los_Angeles')).toBe(new Date('2020-11-01T08:00:00.000Z').getTime()); + }); + + it('should handle daylight saving time end with disambiguation = later', function () { + let date = new CalendarDateTime(2020, 11, 1, 1); + expect(toAbsolute(date, 'America/Los_Angeles', 'later')).toBe(new Date('2020-11-01T09:00:00.000Z').getTime()); + }); + + it('should throw with daylight saving time end if disambiguation = reject', function () { + let date = new CalendarDateTime(2020, 11, 1, 1); + expect(() => { + toAbsolute(date, 'America/Los_Angeles', 'reject'); + }).toThrow('Multiple possible absolute times found'); + }); + + it('should support passing a CalendarDate without a time', function () { + let date = new CalendarDate(2020, 2, 3); + expect(toAbsolute(date, 'America/Los_Angeles')).toBe(new Date('2020-02-03T08:00Z').getTime()); + }); + }); + + describe('toDate', function () { + it('should handle a normal date', function () { + let date = new CalendarDateTime(2020, 2, 3, 2); + expect(toDate(date, 'America/Los_Angeles')).toEqual(new Date('2020-02-03T10:00Z')); + }); + + it('should handle daylight saving time start', function () { + let date = new CalendarDateTime(2020, 3, 8, 2); + expect(toDate(date, 'America/Los_Angeles')).toEqual(new Date('2020-03-08T10:00Z')); + }); + + it('should handle daylight saving time start with disambiguation = earlier', function () { + let date = new CalendarDateTime(2020, 3, 8, 2); + expect(toDate(date, 'America/Los_Angeles', 'earlier')).toEqual(new Date('2020-03-08T09:00Z')); + }); + + it('should throw with daylight saving time start if disambiguation = reject', function () { + let date = new CalendarDateTime(2020, 3, 8, 2); + expect(() => { + toDate(date, 'America/Los_Angeles', 'reject'); + }).toThrow('No such absolute time found'); + }); + + it('should handle daylight saving time end', function () { + let date = new CalendarDateTime(2020, 11, 1, 1); + expect(toDate(date, 'America/Los_Angeles')).toEqual(new Date('2020-11-01T08:00:00.000Z')); + }); + + it('should handle daylight saving time end with disambiguation = later', function () { + let date = new CalendarDateTime(2020, 11, 1, 1); + expect(toDate(date, 'America/Los_Angeles', 'later')).toEqual(new Date('2020-11-01T09:00:00.000Z')); + }); + + it('should throw with daylight saving time end if disambiguation = reject', function () { + let date = new CalendarDateTime(2020, 11, 1, 1); + expect(() => { + toDate(date, 'America/Los_Angeles', 'reject'); + }).toThrow('Multiple possible absolute times found'); + }); + + it('should support passing a CalendarDate without a time', function () { + let date = new CalendarDate(2020, 2, 3); + expect(toDate(date, 'America/Los_Angeles')).toEqual(new Date('2020-02-03T08:00Z')); + }); + }); + + describe('possibleAbsolutes', function () { + it('should handle a normal date', function () { + let date = new CalendarDateTime(2020, 2, 3, 2); + expect(possibleAbsolutes(date, 'America/Los_Angeles')).toEqual([ + new Date('2020-02-03T10:00Z').getTime() + ]); + }); + + it('should handle daylight saving time start', function () { + let date = new CalendarDateTime(2020, 3, 8, 2); + expect(possibleAbsolutes(date, 'America/Los_Angeles')).toEqual([]); + }); + + it('should handle daylight saving time end', function () { + let date = new CalendarDateTime(2020, 11, 1, 1); + expect(possibleAbsolutes(date, 'America/Los_Angeles')).toEqual([ + new Date('2020-11-01T08:00:00.000Z').getTime(), + new Date('2020-11-01T09:00:00.000Z').getTime() + ]); + }); + }); + + describe('fromAbsolute', function () { + it('should convert a date from absolute using a timezone', function () { + let date = fromAbsolute(new Date('2020-02-03T10:00Z').getTime(), 'America/Los_Angeles'); + expect(date).toEqual(new CalendarDateTime(2020, 2, 3, 2)); + + date = fromAbsolute(new Date('2020-02-03T10:00Z').getTime(), 'America/New_York'); + expect(date).toEqual(new CalendarDateTime(2020, 2, 3, 5)); + }); + }); + + describe('toCalendar', function () { + it('should support converting a CalendarDateTime between calendars', function () { + let date = new CalendarDateTime(new JapaneseCalendar(), 'heisei', 31, 4, 30, 8, 20, 30, 80); + expect(toCalendar(date, new GregorianCalendar())).toEqual(new CalendarDateTime(2019, 4, 30, 8, 20, 30, 80)); + }); + + it('should round trip to the same date in gregorian', function () { + let date = new CalendarDate(2020, 9, 1); + expect(date.calendar.fromJulianDay(date.calendar.toJulianDay(date))).toEqual(new CalendarDate(2020, 9, 1)); + }); + + describe('japanese', function () { + it('japanese to gregorian', function () { + let date = new CalendarDate(new JapaneseCalendar(), 'heisei', 31, 4, 30); + expect(toCalendar(date, new GregorianCalendar())).toEqual(new CalendarDate(2019, 4, 30)); + + date = new CalendarDate(new JapaneseCalendar(), 'reiwa', 2, 4, 30); + expect(toCalendar(date, new GregorianCalendar())).toEqual(new CalendarDate(2020, 4, 30)); + }); + + it('gregorian to japanese', function () { + let date = new CalendarDate(2019, 4, 30); + expect(toCalendar(date, new JapaneseCalendar())).toEqual(new CalendarDate(new JapaneseCalendar(), 'heisei', 31, 4, 30)); + + date = new CalendarDate(2020, 4, 30); + expect(toCalendar(date, new JapaneseCalendar())).toEqual(new CalendarDate(new JapaneseCalendar(), 'reiwa', 2, 4, 30)); + }); + }); + + describe('taiwan', function () { + it('taiwan to gregorian', function () { + let date = new CalendarDate(new TaiwanCalendar(), 'minguo', 109, 2, 3); + expect(toCalendar(date, new GregorianCalendar())).toEqual(new CalendarDate(2020, 2, 3)); + }); + + it('gregorian to taiwan', function () { + let date = new CalendarDate(2020, 2, 3); + expect(toCalendar(date, new TaiwanCalendar())).toEqual(new CalendarDate(new TaiwanCalendar(), 'minguo', 109, 2, 3)); + }); + + it('taiwan to gregorian at era boundaries', function () { + let date = new CalendarDate(new TaiwanCalendar(), 'minguo', 1, 1, 1); + expect(toCalendar(date, new GregorianCalendar())).toEqual(new CalendarDate(1912, 1, 1)); + + date = new CalendarDate(new TaiwanCalendar(), 'before_minguo', 1, 1, 1); + expect(toCalendar(date, new GregorianCalendar())).toEqual(new CalendarDate(1911, 1, 1)); + }); + + it('gregorian to taiwan at era boundaries', function () { + let date = new CalendarDate(1912, 1, 1); + expect(toCalendar(date, new TaiwanCalendar())).toEqual(new CalendarDate(new TaiwanCalendar(), 'minguo', 1, 1, 1)); + + date = new CalendarDate(1911, 1, 1); + expect(toCalendar(date, new TaiwanCalendar())).toEqual(new CalendarDate(new TaiwanCalendar(), 'before_minguo', 1, 1, 1)); + }); + }); + + describe('buddhist', function () { + it('buddhist to gregorian', function () { + let date = new CalendarDate(new BuddhistCalendar(), 2563, 4, 30); + expect(toCalendar(date, new GregorianCalendar())).toEqual(new CalendarDate(2020, 4, 30)); + }); + + it('gregorian to buddhist', function () { + let date = new CalendarDate(2020, 4, 30); + expect(toCalendar(date, new BuddhistCalendar())).toEqual(new CalendarDate(new BuddhistCalendar(), 2563, 4, 30)); + }); + }); + + describe('indian', function () { + it('indian to gregorian', function () { + let date = new CalendarDate(new IndianCalendar(), 1941, 4, 30); + expect(toCalendar(date, new GregorianCalendar())).toEqual(new CalendarDate(2019, 7, 21)); + + date = new CalendarDate(new IndianCalendar(), 1941, 1, 1); + expect(toCalendar(date, new GregorianCalendar())).toEqual(new CalendarDate(2019, 3, 22)); + + date = new CalendarDate(new IndianCalendar(), 1941, 9, 1); + expect(toCalendar(date, new GregorianCalendar())).toEqual(new CalendarDate(2019, 11, 22)); + }); + + it('indian to gregorian in a leap year', function () { + let date = new CalendarDate(new IndianCalendar(), 1942, 4, 30); + expect(toCalendar(date, new GregorianCalendar())).toEqual(new CalendarDate(2020, 7, 21)); + + date = new CalendarDate(new IndianCalendar(), 1942, 1, 1); + expect(toCalendar(date, new GregorianCalendar())).toEqual(new CalendarDate(2020, 3, 21)); + + date = new CalendarDate(new IndianCalendar(), 1942, 9, 1); + expect(toCalendar(date, new GregorianCalendar())).toEqual(new CalendarDate(2020, 11, 22)); + }); + + it('gregorian to indian', function () { + let date = new CalendarDate(2019, 7, 21); + expect(toCalendar(date, new IndianCalendar())).toEqual(new CalendarDate(new IndianCalendar(), 1941, 4, 30)); + + date = new CalendarDate(2019, 1, 22); + expect(toCalendar(date, new IndianCalendar())).toEqual(new CalendarDate(new IndianCalendar(), 1940, 11, 2)); + + date = new CalendarDate(2019, 3, 22); + expect(toCalendar(date, new IndianCalendar())).toEqual(new CalendarDate(new IndianCalendar(), 1941, 1, 1)); + + date = new CalendarDate(2019, 11, 22); + expect(toCalendar(date, new IndianCalendar())).toEqual(new CalendarDate(new IndianCalendar(), 1941, 9, 1)); + }); + + it('gregorian to indian in a leap year', function () { + let date = new CalendarDate(2020, 7, 21); + expect(toCalendar(date, new IndianCalendar())).toEqual(new CalendarDate(new IndianCalendar(), 1942, 4, 30)); + + date = new CalendarDate(2021, 1, 22); + expect(toCalendar(date, new IndianCalendar())).toEqual(new CalendarDate(new IndianCalendar(), 1942, 11, 2)); + + date = new CalendarDate(2020, 3, 21); + expect(toCalendar(date, new IndianCalendar())).toEqual(new CalendarDate(new IndianCalendar(), 1942, 1, 1)); + + date = new CalendarDate(2020, 11, 22); + expect(toCalendar(date, new IndianCalendar())).toEqual(new CalendarDate(new IndianCalendar(), 1942, 9, 1)); + }); + }); + + describe('islamic-civil', function () { + it('islamic-civil to gregorian', function () { + let date = new CalendarDate(new IslamicCivilCalendar(), 1442, 2, 4); + expect(toCalendar(date, new GregorianCalendar())).toEqual(new CalendarDate(2020, 9, 22)); + }); + + it('gregorian to islamic-civil', function () { + let date = new CalendarDate(2020, 9, 22); + expect(toCalendar(date, new IslamicCivilCalendar())).toEqual(new CalendarDate(new IslamicCivilCalendar(), 1442, 2, 4)); + }); + }); + + describe('islamic-tbla', function () { + it('islamic-tbla to gregorian', function () { + let date = new CalendarDate(new IslamicTabularCalendar(), 1442, 2, 4); + expect(toCalendar(date, new GregorianCalendar())).toEqual(new CalendarDate(2020, 9, 21)); + }); + + it('gregorian to islamic-tbla', function () { + let date = new CalendarDate(2020, 9, 21); + expect(toCalendar(date, new IslamicTabularCalendar())).toEqual(new CalendarDate(new IslamicTabularCalendar(), 1442, 2, 4)); + }); + }); + + describe('islamic-umalqura', function () { + it('islamic-umalqura to gregorian', function () { + let date = new CalendarDate(new IslamicUmalquraCalendar(), 1442, 9, 4); + expect(toCalendar(date, new GregorianCalendar())).toEqual(new CalendarDate(2021, 4, 16)); + + date = new CalendarDate(new IslamicUmalquraCalendar(), 1600, 9, 4); + expect(toCalendar(date, new GregorianCalendar())).toEqual(new CalendarDate(2174, 8, 2)); + + date = new CalendarDate(new IslamicUmalquraCalendar(), 1601, 9, 4); + expect(toCalendar(date, new GregorianCalendar())).toEqual(new CalendarDate(2175, 7, 23)); + + date = new CalendarDate(new IslamicUmalquraCalendar(), 1200, 9, 4); + expect(toCalendar(date, new GregorianCalendar())).toEqual(new CalendarDate(1786, 7, 1)); + }); + + it('gregorian to islamic-umalqura', function () { + let date = new CalendarDate(2021, 4, 16); + expect(toCalendar(date, new IslamicUmalquraCalendar())).toEqual(new CalendarDate(new IslamicUmalquraCalendar(), 1442, 9, 4)); + + date = new CalendarDate(2174, 8, 2); + expect(toCalendar(date, new IslamicUmalquraCalendar())).toEqual(new CalendarDate(new IslamicUmalquraCalendar(), 1600, 9, 4)); + + date = new CalendarDate(2175, 7, 23); + expect(toCalendar(date, new IslamicUmalquraCalendar())).toEqual(new CalendarDate(new IslamicUmalquraCalendar(), 1601, 9, 4)); + + date = new CalendarDate(1786, 7, 1); + expect(toCalendar(date, new IslamicUmalquraCalendar())).toEqual(new CalendarDate(new IslamicUmalquraCalendar(), 1200, 9, 4)); + }); + }); + + describe('persian', function () { + it('persian to gregorian', function () { + let date = new CalendarDate(new PersianCalendar(), 1399, 6, 12); + expect(toCalendar(date, new GregorianCalendar())).toEqual(new CalendarDate(2020, 9, 2)); + }); + + it('gregorian to persian', function () { + let date = new CalendarDate(2020, 9, 2); + expect(toCalendar(date, new PersianCalendar())).toEqual(new CalendarDate(new PersianCalendar(), 1399, 6, 12)); + }); + }); + + describe('hebrew', function () { + it('hebrew to gregorian', function () { + let date = new CalendarDate(new HebrewCalendar(), 5781, 1, 1); + expect(toCalendar(date, new GregorianCalendar())).toEqual(new CalendarDate(2020, 9, 19)); + }); + + it('hebrew to gregorian in a leap year', function () { + let date = new CalendarDate(new HebrewCalendar(), 5782, 6, 1); + expect(toCalendar(date, new GregorianCalendar())).toEqual(new CalendarDate(2022, 2, 2)); + }); + + it('gregorian to hebrew', function () { + let date = new CalendarDate(2020, 9, 19); + expect(toCalendar(date, new HebrewCalendar())).toEqual(new CalendarDate(new HebrewCalendar(), 5781, 1, 1)); + }); + + it('gregorian to hebrew in a leap year', function () { + let date = new CalendarDate(2022, 2, 2); + expect(toCalendar(date, new HebrewCalendar())).toEqual(new CalendarDate(new HebrewCalendar(), 5782, 6, 1)); + }); + }); + }); + + describe('toCalendarDate', function () { + it('should convert a CalendarDateTime to a CalendarDate', function () { + let dateTime = new CalendarDateTime(2020, 2, 3, 8, 23, 10, 80); + expect(toCalendarDate(dateTime)).toEqual(new CalendarDate(2020, 2, 3)); + }); + + it('should preserve calendar', function () { + let dateTime = new CalendarDateTime(new TaiwanCalendar(), 1912, 2, 3, 8, 23, 10, 80); + expect(toCalendarDate(dateTime)).toEqual(new CalendarDate(new TaiwanCalendar(), 1912, 2, 3)); + }); + }); + + describe('toCalendarDateTime', function () { + it('should convert a CalendarDate to a CalendarDateTime', function () { + let date = new CalendarDate(2020, 2, 3); + expect(toCalendarDateTime(date)).toEqual(new CalendarDateTime(2020, 2, 3)); + }); + + it('should preserve calendar', function () { + let date = new CalendarDate(new TaiwanCalendar(), 1912, 2, 3); + expect(toCalendarDateTime(date)).toEqual(new CalendarDateTime(new TaiwanCalendar(), 1912, 2, 3)); + }); + + it('should return the same instance if it is already a CalendarDateTime', function () { + let dateTime = new CalendarDateTime(2020, 2, 3, 8, 23, 10, 80); + expect(toCalendarDateTime(dateTime)).toBe(dateTime); + }); + + it('should combine a CalendarDate with a Time', function () { + let date = new CalendarDate(2020, 2, 3); + let time = new Time(8, 23, 10, 80); + expect(toCalendarDateTime(date, time)).toEqual(new CalendarDateTime(2020, 2, 3, 8, 23, 10, 80)); + }); + + it('should combine a CalendarDate with a Time and preserve calendar', function () { + let date = new CalendarDate(new TaiwanCalendar(), 1912, 2, 3); + let time = new Time(8, 23, 10, 80); + expect(toCalendarDateTime(date, time)).toEqual(new CalendarDateTime(new TaiwanCalendar(), 1912, 2, 3, 8, 23, 10, 80)); + }); + + it('should override the time of an existing CalendarDateTime', function () { + let date = new CalendarDateTime(2020, 2, 3, 10, 11, 50, 80); + let time = new Time(8, 23, 10, 80); + expect(toCalendarDateTime(date, time)).toEqual(new CalendarDateTime(2020, 2, 3, 8, 23, 10, 80)); + }); + }); + + describe('toTime', function () { + it('should convert a CalendarDateTime to a Time', function () { + let dateTime = new CalendarDateTime(2020, 2, 3, 8, 23, 10, 80); + expect(toTime(dateTime)).toEqual(new Time(8, 23, 10, 80)); + }); + }); +}); diff --git a/packages/@internationalized/date/tests/manipulation.test.js b/packages/@internationalized/date/tests/manipulation.test.js new file mode 100644 index 00000000000..5ce6edea08e --- /dev/null +++ b/packages/@internationalized/date/tests/manipulation.test.js @@ -0,0 +1,357 @@ +/* + * 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, CalendarDate, HebrewCalendar, JapaneseCalendar, set, subtract, TaiwanCalendar} from '..'; + +describe('CalendarDate manipulation', function () { + describe('add', function () { + it('should add years', function () { + let date = new CalendarDate(2020, 1, 1); + expect(add(date, {years: 5})).toEqual(new CalendarDate(2025, 1, 1)); + }); + + it('should add months', function () { + let date = new CalendarDate(2020, 1, 1); + expect(add(date, {months: 5})).toEqual(new CalendarDate(2020, 6, 1)); + }); + + it('should add months across years', function () { + let date = new CalendarDate(2020, 9, 1); + expect(add(date, {months: 5})).toEqual(new CalendarDate(2021, 2, 1)); + }); + + it('should add months across multiple years', function () { + let date = new CalendarDate(2020, 9, 1); + expect(add(date, {months: 17})).toEqual(new CalendarDate(2022, 2, 1)); + }); + + it('should add months and constrain days', function () { + let date = new CalendarDate(2020, 8, 31); + expect(add(date, {months: 1})).toEqual(new CalendarDate(2020, 9, 30)); + }); + + it('should add days', function () { + let date = new CalendarDate(2020, 9, 1); + expect(add(date, {days: 5})).toEqual(new CalendarDate(2020, 9, 6)); + }); + + it('should add days across months', function () { + let date = new CalendarDate(2020, 9, 20); + expect(add(date, {days: 15})).toEqual(new CalendarDate(2020, 10, 5)); + }); + + it('should add days across multiple months', function () { + let date = new CalendarDate(2020, 9, 20); + expect(add(date, {days: 46})).toEqual(new CalendarDate(2020, 11, 5)); + }); + + it('should add days across years', function () { + let date = new CalendarDate(2020, 12, 20); + expect(add(date, {days: 15})).toEqual(new CalendarDate(2021, 1, 4)); + }); + + it('should add days across multiple years', function () { + let date = new CalendarDate(2020, 12, 20); + expect(add(date, {days: 380})).toEqual(new CalendarDate(2022, 1, 4)); + }); + + it('should handle leap years', function () { + let date = new CalendarDate(2020, 2, 28); + expect(add(date, {days: 1})).toEqual(new CalendarDate(2020, 2, 29)); + expect(add(date, {days: 2})).toEqual(new CalendarDate(2020, 3, 1)); + }); + + it('should handle non-leap years', function () { + let date = new CalendarDate(2019, 2, 28); + expect(add(date, {days: 1})).toEqual(new CalendarDate(2019, 3, 1)); + }); + + it('should add weeks', function () { + let date = new CalendarDate(2020, 9, 1); + expect(add(date, {weeks: 5})).toEqual(new CalendarDate(2020, 10, 6)); + }); + + it('should add years, months, and days together', function () { + let date = new CalendarDate(2020, 10, 25); + expect(add(date, {years: 2, months: 3, days: 10})).toEqual(new CalendarDate(2023, 2, 4)); + }); + + describe('Japanese calendar', function () { + it('should add years and rebalance era', function () { + let date = new CalendarDate(new JapaneseCalendar(), 'heisei', 31, 4, 30); + expect(add(date, {years: 1})).toEqual(new CalendarDate(new JapaneseCalendar(), 'reiwa', 2, 4, 30)); + }); + + it('should add months and rebalance era', function () { + let date = new CalendarDate(new JapaneseCalendar(), 'heisei', 31, 4, 30); + expect(add(date, {months: 1})).toEqual(new CalendarDate(new JapaneseCalendar(), 'reiwa', 1, 5, 30)); + }); + + it('should add days and rebalance era', function () { + let date = new CalendarDate(new JapaneseCalendar(), 'heisei', 31, 4, 30); + expect(add(date, {days: 1})).toEqual(new CalendarDate(new JapaneseCalendar(), 'reiwa', 1, 5, 1)); + }); + }); + + describe('Taiwan calendar', function () { + it('should add years and rebalance era', function () { + let date = new CalendarDate(new TaiwanCalendar(), 'before_minguo', 1, 4, 30); + expect(add(date, {years: 1})).toEqual(new CalendarDate(new TaiwanCalendar(), 'minguo', 1, 4, 30)); + }); + + it('should add years in before_minguo era', function () { + let date = new CalendarDate(new TaiwanCalendar(), 'before_minguo', 3, 4, 30); + expect(add(date, {years: 1})).toEqual(new CalendarDate(new TaiwanCalendar(), 'before_minguo', 2, 4, 30)); + }); + + it('should add months and rebalance era', function () { + let date = new CalendarDate(new TaiwanCalendar(), 'before_minguo', 1, 12, 30); + expect(add(date, {months: 1})).toEqual(new CalendarDate(new TaiwanCalendar(), 'minguo', 1, 1, 30)); + }); + + it('should add days and rebalance era', function () { + let date = new CalendarDate(new TaiwanCalendar(), 'before_minguo', 1, 12, 31); + expect(add(date, {days: 1})).toEqual(new CalendarDate(new TaiwanCalendar(), 'minguo', 1, 1, 1)); + }); + }); + + describe('Hebrew calendar', function () { + it('should add months in a non-leap year', function () { + let date = new CalendarDate(new HebrewCalendar(), 5781, 5, 1); + expect(add(date, {months: 1})).toEqual(new CalendarDate(new HebrewCalendar(), 5781, 6, 1)); + + date = new CalendarDate(new HebrewCalendar(), 5781, 12, 1); + expect(add(date, {months: 1})).toEqual(new CalendarDate(new HebrewCalendar(), 5782, 1, 1)); + }); + + it('should add months in a leap year', function () { + let date = new CalendarDate(new HebrewCalendar(), 5782, 5, 1); + expect(add(date, {months: 1})).toEqual(new CalendarDate(new HebrewCalendar(), 5782, 6, 1)); + + date = new CalendarDate(new HebrewCalendar(), 5782, 12, 1); + expect(add(date, {months: 1})).toEqual(new CalendarDate(new HebrewCalendar(), 5782, 13, 1)); + }); + + it('should add years in a leap year', function () { + let date = new CalendarDate(new HebrewCalendar(), 5782, 13, 1); + expect(add(date, {years: 1})).toEqual(new CalendarDate(new HebrewCalendar(), 5784, 1, 1)); + }); + }); + }); + + describe('subtract', function () { + it('should subtract years', function () { + let date = new CalendarDate(2025, 1, 1); + expect(subtract(date, {years: 5})).toEqual(new CalendarDate(2020, 1, 1)); + }); + + it('should subtract months', function () { + let date = new CalendarDate(2020, 6, 1); + expect(subtract(date, {months: 5})).toEqual(new CalendarDate(2020, 1, 1)); + }); + + it('should subtract months across years', function () { + let date = new CalendarDate(2021, 2, 1); + expect(subtract(date, {months: 5})).toEqual(new CalendarDate(2020, 9, 1)); + }); + + it('should subtract months across multiple years', function () { + let date = new CalendarDate(2022, 2, 1); + expect(subtract(date, {months: 17})).toEqual(new CalendarDate(2020, 9, 1)); + }); + + it('should subtract months and constrain days', function () { + let date = new CalendarDate(2020, 10, 31); + expect(subtract(date, {months: 1})).toEqual(new CalendarDate(2020, 9, 30)); + }); + + it('should subtract days', function () { + let date = new CalendarDate(2020, 9, 6); + expect(subtract(date, {days: 5})).toEqual(new CalendarDate(2020, 9, 1)); + }); + + it('should subtract days across months', function () { + let date = new CalendarDate(2020, 10, 5); + expect(subtract(date, {days: 15})).toEqual(new CalendarDate(2020, 9, 20)); + }); + + it('should subtract days across multiple months', function () { + let date = new CalendarDate(2020, 11, 5); + expect(subtract(date, {days: 46})).toEqual(new CalendarDate(2020, 9, 20)); + }); + + it('should subtract days across years', function () { + let date = new CalendarDate(2021, 1, 4); + expect(subtract(date, {days: 15})).toEqual(new CalendarDate(2020, 12, 20)); + }); + + it('should subtract days across multiple years', function () { + let date = new CalendarDate(2022, 1, 4); + expect(subtract(date, {days: 380})).toEqual(new CalendarDate(2020, 12, 20)); + }); + + it('should handle leap years', function () { + let date = new CalendarDate(2020, 2, 28); + expect(subtract(new CalendarDate(2020, 2, 29), {days: 1})).toEqual(date); + expect(subtract(new CalendarDate(2020, 3, 1), {days: 2})).toEqual(date); + }); + + it('should handle non-leap years', function () { + let date = new CalendarDate(2019, 2, 28); + expect(subtract(new CalendarDate(2019, 3, 1), {days: 1})).toEqual(date); + }); + + it('should subtract weeks', function () { + let date = new CalendarDate(2020, 10, 6); + expect(subtract(date, {weeks: 5})).toEqual(new CalendarDate(2020, 9, 1)); + }); + + describe('Japanese calendar', function () { + it('should subtract years and rebalance era', function () { + let date = new CalendarDate(new JapaneseCalendar(), 'reiwa', 1, 5, 30); + expect(subtract(date, {years: 1})).toEqual(new CalendarDate(new JapaneseCalendar(), 'heisei', 30, 5, 30)); + }); + + it('should subtract months and rebalance era', function () { + let date = new CalendarDate(new JapaneseCalendar(), 'reiwa', 1, 5, 30); + expect(subtract(date, {months: 1})).toEqual(new CalendarDate(new JapaneseCalendar(), 'heisei', 31, 4, 30)); + }); + + it('should subtract days and rebalance era', function () { + let date = new CalendarDate(new JapaneseCalendar(), 'reiwa', 1, 5, 1); + expect(subtract(date, {days: 1})).toEqual(new CalendarDate(new JapaneseCalendar(), 'heisei', 31, 4, 30)); + }); + }); + + describe('Taiwan calendar', function () { + it('should subtract years and rebalance era', function () { + let date = new CalendarDate(new TaiwanCalendar(), 'minguo', 1, 4, 30); + expect(subtract(date, {years: 1})).toEqual(new CalendarDate(new TaiwanCalendar(), 'before_minguo', 1, 4, 30)); + }); + + it('should subtract years in before_minguo era', function () { + let date = new CalendarDate(new TaiwanCalendar(), 'before_minguo', 2, 4, 30); + expect(subtract(date, {years: 1})).toEqual(new CalendarDate(new TaiwanCalendar(), 'before_minguo', 3, 4, 30)); + }); + + it('should subtract months and rebalance era', function () { + let date = new CalendarDate(new TaiwanCalendar(), 'minguo', 1, 1, 30); + expect(subtract(date, {months: 1})).toEqual(new CalendarDate(new TaiwanCalendar(), 'before_minguo', 1, 12, 30)); + }); + + it('should subtract days and rebalance era', function () { + let date = new CalendarDate(new TaiwanCalendar(), 'minguo', 1, 1, 1); + expect(subtract(date, {days: 1})).toEqual(new CalendarDate(new TaiwanCalendar(), 'before_minguo', 1, 12, 31)); + }); + }); + + describe('Hebrew calendar', function () { + it('should subtract months in a non-leap year', function () { + let date = new CalendarDate(new HebrewCalendar(), 5781, 6, 1); + expect(subtract(date, {months: 1})).toEqual(new CalendarDate(new HebrewCalendar(), 5781, 5, 1)); + + date = new CalendarDate(new HebrewCalendar(), 5782, 1, 1); + expect(subtract(date, {months: 1})).toEqual(new CalendarDate(new HebrewCalendar(), 5781, 12, 1)); + }); + + it('should subtract months in a leap year', function () { + let date = new CalendarDate(new HebrewCalendar(), 5782, 6, 1); + expect(subtract(date, {months: 1})).toEqual(new CalendarDate(new HebrewCalendar(), 5782, 5, 1)); + + date = new CalendarDate(new HebrewCalendar(), 5782, 13, 1); + expect(subtract(date, {months: 1})).toEqual(new CalendarDate(new HebrewCalendar(), 5782, 12, 1)); + }); + + it('should subtract years in a leap year', function () { + let date = new CalendarDate(new HebrewCalendar(), 5782, 13, 1); + expect(subtract(date, {years: 1})).toEqual(new CalendarDate(new HebrewCalendar(), 5782, 1, 1)); + }); + }); + }); + + describe('set', function () { + it('should set year', function () { + let date = new CalendarDate(2020, 2, 3); + expect(set(date, {year: 2022})).toEqual(new CalendarDate(2022, 2, 3)); + }); + + it('should set month', function () { + let date = new CalendarDate(2020, 2, 3); + expect(set(date, {month: 5})).toEqual(new CalendarDate(2020, 5, 3)); + }); + + it('should balance month', function () { + let date = new CalendarDate(2020, 2, 3); + expect(set(date, {month: 13})).toEqual(new CalendarDate(2021, 1, 3)); + }); + + it('should constrain month', function () { + let date = new CalendarDate(2020, 2, 3); + expect(set(date, {month: 13}, 'constrain')).toEqual(new CalendarDate(2020, 12, 3)); + }); + + it('should set month and balance day', function () { + let date = new CalendarDate(2020, 2, 31); + expect(set(date, {month: 9})).toEqual(new CalendarDate(2020, 10, 1)); + }); + + it('should set month and constrain day', function () { + let date = new CalendarDate(2020, 2, 31); + expect(set(date, {month: 9}, 'constrain')).toEqual(new CalendarDate(2020, 9, 30)); + }); + + it('should set day', function () { + let date = new CalendarDate(2020, 2, 3); + expect(set(date, {day: 9})).toEqual(new CalendarDate(2020, 2, 9)); + }); + + it('should balance day', function () { + let date = new CalendarDate(2020, 9, 3); + expect(set(date, {day: 31})).toEqual(new CalendarDate(2020, 10, 1)); + }); + + it('should constrain day', function () { + let date = new CalendarDate(2020, 9, 3); + expect(set(date, {day: 31}, 'constrain')).toEqual(new CalendarDate(2020, 9, 30)); + }); + + it('should balance day on leap years', function () { + let date = new CalendarDate(2020, 2, 3); + expect(set(date, {day: 31})).toEqual(new CalendarDate(2020, 3, 2)); + + date = new CalendarDate(2019, 2, 3); + expect(set(date, {day: 31})).toEqual(new CalendarDate(2019, 3, 3)); + }); + + it('should constrain day on leap years', function () { + let date = new CalendarDate(2020, 2, 3); + expect(set(date, {day: 31}, 'constrain')).toEqual(new CalendarDate(2020, 2, 29)); + + date = new CalendarDate(2019, 2, 3); + expect(set(date, {day: 31}, 'constrain')).toEqual(new CalendarDate(2019, 2, 28)); + }); + + describe('Japanese calendar', function () { + it('should rebalance era', function () { + let date = new CalendarDate(new JapaneseCalendar(), 'heisei', 31, 4, 30); + expect(set(date, {month: 5})).toEqual(new CalendarDate(new JapaneseCalendar(), 'reiwa', 1, 5, 30)); + }); + }); + + describe('Taiwan calendar', function () { + it('should rebalance era', function () { + let date = new CalendarDate(new TaiwanCalendar(), 'before_minguo', 5, 4, 30); + expect(set(date, {year: -2})).toEqual(new CalendarDate(new TaiwanCalendar(), 'minguo', 3, 4, 30)); + }); + }); + }); +}); diff --git a/packages/@react-aria/calendar/package.json b/packages/@react-aria/calendar/package.json index 5215df0f77b..6140e26b5a1 100644 --- a/packages/@react-aria/calendar/package.json +++ b/packages/@react-aria/calendar/package.json @@ -19,6 +19,7 @@ }, "dependencies": { "@babel/runtime": "^7.6.2", + "@internationalized/date": "3.0.0-alpha.1", "@react-aria/i18n": "^3.1.0", "@react-aria/interactions": "^3.1.0", "@react-aria/live-announcer": "^3.0.0", diff --git a/packages/@react-aria/calendar/src/useCalendar.ts b/packages/@react-aria/calendar/src/useCalendar.ts index f37bf610c12..274bae09ea1 100644 --- a/packages/@react-aria/calendar/src/useCalendar.ts +++ b/packages/@react-aria/calendar/src/useCalendar.ts @@ -15,6 +15,7 @@ import {CalendarProps} from '@react-types/calendar'; import {CalendarState} from '@react-stately/calendar'; // @ts-ignore import intlMessages from '../intl/*.json'; +import {toDate} from '@internationalized/date'; import {useCalendarBase} from './useCalendarBase'; import {useMemo} from 'react'; import {useMessageFormatter} from '@react-aria/i18n'; @@ -23,8 +24,8 @@ export function useCalendar(props: CalendarProps, state: CalendarState): Calenda // Compute localized message for the selected date let formatMessage = useMessageFormatter(intlMessages); let selectedDateDescription = useMemo( - () => state.value ? formatMessage('selectedDateDescription', {date: state.value}) : '', - [formatMessage, state.value] + () => state.value ? formatMessage('selectedDateDescription', {date: toDate(state.value, state.timeZone)}) : '', + [formatMessage, state.value, state.timeZone] ); return useCalendarBase(props, state, selectedDateDescription); diff --git a/packages/@react-aria/calendar/src/useCalendarBase.ts b/packages/@react-aria/calendar/src/useCalendarBase.ts index 26dfc468958..ea78b166f72 100644 --- a/packages/@react-aria/calendar/src/useCalendarBase.ts +++ b/packages/@react-aria/calendar/src/useCalendarBase.ts @@ -19,6 +19,7 @@ import {filterDOMProps, mergeProps, useId, useLabels, useUpdateEffect} from '@re // @ts-ignore import intlMessages from '../intl/*.json'; import {KeyboardEvent, useRef} from 'react'; +import {toDate} from '@internationalized/date'; import {useDateFormatter, useLocale, useMessageFormatter} from '@react-aria/i18n'; export function useCalendarBase(props: CalendarPropsBase & DOMProps, state: CalendarStateBase, selectedDateDescription: string): CalendarAria { @@ -40,7 +41,7 @@ export function useCalendarBase(props: CalendarPropsBase & DOMProps, state: Cale useUpdateEffect(() => { // announce the new month with a change from the Previous or Next button if (!state.isFocused) { - announce(monthFormatter.format(state.currentMonth)); + announce(monthFormatter.format(toDate(state.currentMonth, state.timeZone))); } // handle an update to the current month from the Previous or Next button // rather than move focus, we announce the new month value diff --git a/packages/@react-aria/calendar/src/useCalendarCell.ts b/packages/@react-aria/calendar/src/useCalendarCell.ts index 724cc2460c6..f65556ae7a5 100644 --- a/packages/@react-aria/calendar/src/useCalendarCell.ts +++ b/packages/@react-aria/calendar/src/useCalendarCell.ts @@ -10,16 +10,16 @@ * governing permissions and limitations under the License. */ +import {CalendarDate, isSameDay, isToday, toDate} from '@internationalized/date'; import {CalendarState, RangeCalendarState} from '@react-stately/calendar'; import {HTMLAttributes, RefObject, useEffect} from 'react'; // @ts-ignore import intlMessages from '../intl/*.json'; -import {isSameDay, isToday} from 'date-fns'; import {PressProps, usePress} from '@react-aria/interactions'; import {useDateFormatter, useMessageFormatter} from '@react-aria/i18n'; export interface AriaCalendarCellProps { - date: Date + date: CalendarDate } interface CalendarCellAria { @@ -30,22 +30,30 @@ interface CalendarCellAria { export function useCalendarCell(props: AriaCalendarCellProps, state: CalendarState | RangeCalendarState, ref: RefObject): CalendarCellAria { let {date} = props; let formatMessage = useMessageFormatter(intlMessages); - let dateFormatter = useDateFormatter({weekday: 'long', day: 'numeric', month: 'long', year: 'numeric'}); + let dateFormatter = useDateFormatter({ + weekday: 'long', + day: 'numeric', + month: 'long', + year: 'numeric', + era: date.calendar.identifier !== 'gregory' ? 'long' : undefined, + timeZone: state.timeZone + }); let isSelected = state.isSelected(date); let isFocused = state.isCellFocused(date); let isDisabled = state.isCellDisabled(date); // aria-label should be localize Day of week, Month, Day and Year without Time. - let label = dateFormatter.format(date); - if (isToday(date)) { + let nativeDate = toDate(date, state.timeZone); + let label = dateFormatter.format(nativeDate); + if (isToday(date, state.timeZone)) { // If date is today, set appropriate string depending on selected state: label = formatMessage(isSelected ? 'todayDateSelected' : 'todayDate', { - date + date: nativeDate }); } else if (isSelected) { // If date is selected but not today: label = formatMessage('dateSelected', { - date + date: nativeDate }); } diff --git a/packages/@react-aria/calendar/src/useRangeCalendar.ts b/packages/@react-aria/calendar/src/useRangeCalendar.ts index e38a1e997d4..907c7fad363 100644 --- a/packages/@react-aria/calendar/src/useRangeCalendar.ts +++ b/packages/@react-aria/calendar/src/useRangeCalendar.ts @@ -13,7 +13,7 @@ import {CalendarAria} from './types'; // @ts-ignore import intlMessages from '../intl/*.json'; -import {isSameDay} from 'date-fns'; +import {isSameDay, toDate} from '@internationalized/date'; import {mergeProps} from '@react-aria/utils'; import {RangeCalendarProps} from '@react-types/calendar'; import {RangeCalendarState} from '@react-stately/calendar'; @@ -31,13 +31,13 @@ export function useRangeCalendar(props: RangeCalendarProps, state: RangeCalendar // Use a single date message if the start and end dates are the same day, // otherwise include both dates. if (isSameDay(start, end)) { - return formatMessage('selectedDateDescription', {date: start}); + return formatMessage('selectedDateDescription', {date: toDate(start, state.timeZone)}); } else { - return formatMessage('selectedRangeDescription', {start, end}); + return formatMessage('selectedRangeDescription', {start: toDate(start, state.timeZone), end: toDate(end, state.timeZone)}); } } return ''; - }, [start, end, state.anchorDate, formatMessage]); + }, [start, end, state.anchorDate, state.timeZone, formatMessage]); let onKeyDown = (e: KeyboardEvent) => { switch (e.key) { diff --git a/packages/@react-aria/i18n/src/useDateFormatter.ts b/packages/@react-aria/i18n/src/useDateFormatter.ts index 76c7c894cc0..8357834b397 100644 --- a/packages/@react-aria/i18n/src/useDateFormatter.ts +++ b/packages/@react-aria/i18n/src/useDateFormatter.ts @@ -12,6 +12,10 @@ import {useLocale} from './context'; +interface DateFormatterOptions extends Intl.DateTimeFormatOptions { + calendar?: string +} + let formatterCache = new Map(); /** @@ -19,9 +23,14 @@ let formatterCache = new Map(); * and handles caching of the date formatter for performance. * @param options - Formatting options. */ -export function useDateFormatter(options?: Intl.DateTimeFormatOptions): Intl.DateTimeFormat { +export function useDateFormatter(options?: DateFormatterOptions): Intl.DateTimeFormat { let {locale} = useLocale(); + // Polyfill the `calendar` option - not supported in Safari. + if (options?.calendar && !locale.includes('-u-ca-')) { + locale += '-u-ca-' + options.calendar; + } + let cacheKey = locale + (options ? Object.entries(options).sort((a, b) => a[0] < b[0] ? -1 : 1).join() : ''); if (formatterCache.has(cacheKey)) { return formatterCache.get(cacheKey); diff --git a/packages/@react-spectrum/calendar/package.json b/packages/@react-spectrum/calendar/package.json index 4e899f20461..bfd0181e8df 100644 --- a/packages/@react-spectrum/calendar/package.json +++ b/packages/@react-spectrum/calendar/package.json @@ -33,6 +33,7 @@ }, "dependencies": { "@babel/runtime": "^7.6.2", + "@internationalized/date": "3.0.0-alpha.1", "@react-aria/calendar": "3.0.0-alpha.1", "@react-aria/focus": "^3.1.0", "@react-aria/i18n": "^3.1.0", diff --git a/packages/@react-spectrum/calendar/src/Calendar.tsx b/packages/@react-spectrum/calendar/src/Calendar.tsx index d448b6cabce..f3644ef65b9 100644 --- a/packages/@react-spectrum/calendar/src/Calendar.tsx +++ b/packages/@react-spectrum/calendar/src/Calendar.tsx @@ -11,13 +11,17 @@ */ import {CalendarBase} from './CalendarBase'; +import {createCalendar} from '@internationalized/date'; import React from 'react'; import {SpectrumCalendarProps} from '@react-types/calendar'; import {useCalendar} from '@react-aria/calendar'; import {useCalendarState} from '@react-stately/calendar'; export function Calendar(props: SpectrumCalendarProps) { - let state = useCalendarState(props); + let state = useCalendarState({ + ...props, + createCalendar + }); let aria = useCalendar(props, state); return ( - {monthDateFormatter.format(state.currentMonth)} + {monthDateFormatter.format(toDate(state.currentMonth, state.timeZone))} (); let {cellProps, buttonProps} = useCalendarCell(props, state, ref); let {hoverProps, isHovered} = useHover({}); - let dateFormatter = useDateFormatter({day: 'numeric'}); + let dateFormatter = useDateFormatter({ + day: 'numeric', + timeZone: state.timeZone, + calendar: state.currentMonth.calendar.identifier + }); let isSelected = state.isSelected(props.date); let highlightedRange = 'highlightedRange' in state && state.highlightedRange; let isSelectionStart = highlightedRange && isSameDay(props.date, highlightedRange.start); let isSelectionEnd = highlightedRange && isSameDay(props.date, highlightedRange.end); - let isRangeStart = isSelected && (props.date.getDay() === 0 || props.date.getDate() === 1); - let isRangeEnd = isSelected && (props.date.getDay() === 6 || props.date.getDate() === state.daysInMonth); + let dayOfWeek = getDayOfWeek(props.date); + let isRangeStart = isSelected && (dayOfWeek === 0 || props.date.day === 1); + let isRangeEnd = isSelected && (dayOfWeek === 6 || props.date.day === state.daysInMonth); return ( - {dateFormatter.format(props.date)} + {dateFormatter.format(toDate(props.date, state.timeZone))} ); diff --git a/packages/@react-spectrum/calendar/src/CalendarTableHeader.tsx b/packages/@react-spectrum/calendar/src/CalendarTableHeader.tsx index b26563a8ffb..1ce7c2ef8fc 100644 --- a/packages/@react-spectrum/calendar/src/CalendarTableHeader.tsx +++ b/packages/@react-spectrum/calendar/src/CalendarTableHeader.tsx @@ -10,6 +10,7 @@ * governing permissions and limitations under the License. */ +import {CalendarDate, toDate} from '@internationalized/date'; import {classNames} from '@react-spectrum/utils'; import React from 'react'; import styles from '@adobe/spectrum-css-temp/components/calendar/vars.css'; @@ -17,7 +18,7 @@ import {useDateFormatter} from '@react-aria/i18n'; import {VisuallyHidden} from '@react-aria/visually-hidden'; interface CalendarTableHeaderProps { - weekDays: Array + weekDays: Array } export function CalendarTableHeader({weekDays}: CalendarTableHeaderProps) { @@ -27,7 +28,9 @@ export function CalendarTableHeader({weekDays}: CalendarTableHeaderProps) { { - weekDays.map((dateDay, index) => { + weekDays.map((date, index) => { + // Timezone doesn't matter here, assuming all days are formatted in the same zone. + let dateDay = toDate(date, 'America/Los_Angeles'); let day = dayFormatter.format(dateDay); let dayLong = dayFormatterLong.format(dateDay); return ( diff --git a/packages/@react-spectrum/calendar/src/RangeCalendar.tsx b/packages/@react-spectrum/calendar/src/RangeCalendar.tsx index 26275bc95b7..a2634d9ecf1 100644 --- a/packages/@react-spectrum/calendar/src/RangeCalendar.tsx +++ b/packages/@react-spectrum/calendar/src/RangeCalendar.tsx @@ -11,13 +11,17 @@ */ import {CalendarBase} from './CalendarBase'; +import {createCalendar} from '@internationalized/date'; import React from 'react'; import {SpectrumRangeCalendarProps} from '@react-types/calendar'; import {useRangeCalendar} from '@react-aria/calendar'; import {useRangeCalendarState} from '@react-stately/calendar'; export function RangeCalendar(props: SpectrumRangeCalendarProps) { - let state = useRangeCalendarState(props); + let state = useRangeCalendarState({ + ...props, + createCalendar + }); let aria = useRangeCalendar(props, state); return ( ; + return ; +} + +// https://github.com/unicode-org/cldr/blob/22af90ae3bb04263f651323ce3d9a71747a75ffb/common/supplemental/supplementalData.xml#L4649-L4664 +const preferences = [ + {locale: '', label: 'Default', ordering: 'gregory'}, + {label: 'Arabic (Algeria)', locale: 'ar-DZ', territories: 'DJ DZ EH ER IQ JO KM LB LY MA MR OM PS SD SY TD TN YE', ordering: 'gregory islamic islamic-civil islamic-tbla'}, + {label: 'Arabic (United Arab Emirates)', locale: 'ar-AE', territories: 'AE BH KW QA', ordering: 'gregory islamic-umalqura islamic islamic-civil islamic-tbla'}, + {label: 'Arabic (Egypt)', locale: 'AR-EG', territories: 'EG', ordering: 'gregory coptic islamic islamic-civil islamic-tbla'}, + {label: 'Arabic (Saudi Arabia)', locale: 'ar-SA', territories: 'SA', ordering: 'islamic-umalqura gregory islamic islamic-rgsa'}, + {label: 'Farsi (Afghanistan)', locale: 'fa-AF', territories: 'AF IR', ordering: 'persian gregory islamic islamic-civil islamic-tbla'}, + // {territories: 'CN CX HK MO SG', ordering: 'gregory chinese'}, + {label: 'Amharic (Ethiopia)', locale: 'am-ET', territories: 'ET', ordering: 'gregory ethiopic ethioaa'}, + {label: 'Hebrew (Israel)', locale: 'he-IL', territories: 'IL', ordering: 'gregory hebrew islamic islamic-civil islamic-tbla'}, + {label: 'Hindi (India)', locale: 'hi-IN', territories: 'IN', ordering: 'gregory indian'}, + {label: 'Japanese (Japan)', locale: 'ja-JP', territories: 'JP', ordering: 'gregory japanese'}, + // {territories: 'KR', ordering: 'gregory dangi'}, + {label: 'Thai (Thailand)', locale: 'th-TH', territories: 'TH', ordering: 'buddhist gregory'}, + {label: 'Chinese (Taiwan)', locale: 'zh-TW', territories: 'TW', ordering: 'gregory roc chinese'} +]; + +const calendars = [ + {key: 'gregory', name: 'Gregorian'}, + {key: 'japanese', name: 'Japanese'}, + {key: 'buddhist', name: 'Buddhist'}, + {key: 'roc', name: 'Taiwan'}, + {key: 'persian', name: 'Persian'}, + {key: 'indian', name: 'Indian'}, + {key: 'islamic-umalqura', name: 'Islamic (Umm al-Qura)'}, + {key: 'islamic-civil', name: 'Islamic Civil'}, + {key: 'islamic-tbla', name: 'Islamic Tabular'}, + {key: 'hebrew', name: 'Hebrew'}, + {key: 'coptic', name: 'Coptic'}, + {key: 'ethiopic', name: 'Ethiopic'}, + {key: 'ethioaa', name: 'Ethiopic (Amete Alem)'} +]; + +function Example(props) { + let [locale, setLocale] = React.useState(''); + let [calendar, setCalendar] = React.useState(calendars[0].key); + let {locale: defaultLocale} = useLocale(); + + let pref = preferences.find(p => p.locale === locale); + let preferredCalendars = React.useMemo(() => pref ? pref.ordering.split(' ').map(p => calendars.find(c => c.key === p)).filter(Boolean) : [calendars[0]], [pref]); + let otherCalendars = React.useMemo(() => calendars.filter(c => !preferredCalendars.some(p => p.key === c.key)), [preferredCalendars]); + + let updateLocale = locale => { + setLocale(locale); + let pref = preferences.find(p => p.locale === locale); + setCalendar(pref.ordering.split(' ')[0]); + }; + + return ( + + + + {item => {item.label}} + + +
+ {item => {item.name}} +
+
+ {item => {item.name}} +
+
+
+ + + +
+ ); } diff --git a/packages/@react-spectrum/combobox/test/ComboBox.test.js b/packages/@react-spectrum/combobox/test/ComboBox.test.js index 9bae3ec881e..45838161b9c 100644 --- a/packages/@react-spectrum/combobox/test/ComboBox.test.js +++ b/packages/@react-spectrum/combobox/test/ComboBox.test.js @@ -3655,6 +3655,10 @@ describe('ComboBox', function () { triggerPress(clearButton); }); + act(() => { + jest.runAllTimers(); + }); + expect(document.activeElement).toBe(trayInput); expect(trayInput.value).toBe(''); }); diff --git a/packages/@react-stately/calendar/package.json b/packages/@react-stately/calendar/package.json index c301ddfb2a7..e2224ff3cd4 100644 --- a/packages/@react-stately/calendar/package.json +++ b/packages/@react-stately/calendar/package.json @@ -19,6 +19,7 @@ }, "dependencies": { "@babel/runtime": "^7.6.2", + "@internationalized/date": "3.0.0-alpha.1", "@react-aria/i18n": "^3.1.0", "@react-stately/utils": "^3.1.0", "@react-types/calendar": "3.0.0-alpha.1", diff --git a/packages/@react-stately/calendar/src/types.ts b/packages/@react-stately/calendar/src/types.ts index 977da679568..294a61acd35 100644 --- a/packages/@react-stately/calendar/src/types.ts +++ b/packages/@react-stately/calendar/src/types.ts @@ -10,14 +10,16 @@ * governing permissions and limitations under the License. */ +import {CalendarDate} from '@internationalized/date'; import {RangeValue} from '@react-types/shared'; export interface CalendarStateBase { isDisabled: boolean, isReadOnly: boolean, - currentMonth: Date, - focusedDate: Date, - setFocusedDate(value: Date): void, + currentMonth: CalendarDate, + timeZone: string, + focusedDate: CalendarDate, + setFocusedDate(value: CalendarDate): void, focusNextDay(): void, focusPreviousDay(): void, focusNextWeek(): void, @@ -29,32 +31,32 @@ export interface CalendarStateBase { focusNextYear(): void, focusPreviousYear(): void, selectFocusedDate(): void, - selectDate(date: Date): void, + selectDate(date: CalendarDate): void, isFocused: boolean, setFocused(value: boolean): void, weeksInMonth: number, weekStart: number, daysInMonth: number, - weekDays: Array, - getCellDate(weekIndex: number, dayIndex: number): Date, - isInvalid(date: Date): boolean, - isSelected(date: Date): boolean, - isCellFocused(date: Date): boolean, - isCellDisabled(date: Date): boolean, + weekDays: Array, + getCellDate(weekIndex: number, dayIndex: number): CalendarDate, + isInvalid(date: CalendarDate): boolean, + isSelected(date: CalendarDate): boolean, + isCellFocused(date: CalendarDate): boolean, + isCellDisabled(date: CalendarDate): boolean, isPreviousMonthInvalid(): boolean, isNextMonthInvalid(): boolean } export interface CalendarState extends CalendarStateBase { - value: Date, + value: CalendarDate, setValue(value: Date): void } export interface RangeCalendarState extends CalendarStateBase { - value: RangeValue, + value: RangeValue, setValue(value: RangeValue): void, - highlightDate(date: Date): void, - anchorDate: Date | null, - setAnchorDate(date: Date | null): void, - highlightedRange: RangeValue + highlightDate(date: CalendarDate): void, + anchorDate: CalendarDate | null, + setAnchorDate(date: CalendarDate | null): void, + highlightedRange: RangeValue } diff --git a/packages/@react-stately/calendar/src/useCalendarState.ts b/packages/@react-stately/calendar/src/useCalendarState.ts index 67296990c15..f5fd532d702 100644 --- a/packages/@react-stately/calendar/src/useCalendarState.ts +++ b/packages/@react-stately/calendar/src/useCalendarState.ts @@ -10,35 +10,76 @@ * governing permissions and limitations under the License. */ -import {addDays, addMonths, addWeeks, addYears, endOfDay, endOfMonth, getDaysInMonth, isSameDay, isSameMonth, setDay, startOfDay, startOfMonth, subDays, subMonths, subWeeks, subYears} from 'date-fns'; +import { + add, + Calendar, + CalendarDate, + compare, + endOfMonth, + fromAbsolute, + getDayOfWeek, + isSameDay, + isSameMonth, + set, + startOfMonth, + subtract, + toCalendar, + toCalendarDate, + toDate, + today +} from '@internationalized/date'; import {CalendarProps} from '@react-types/calendar'; import {CalendarState} from './types'; import {useControlledState} from '@react-stately/utils'; -import {useMemo, useState} from 'react'; +import {useDateFormatter} from '@react-aria/i18n'; +import {useEffect, useMemo, useRef, useState} from 'react'; import {useWeekStart} from './useWeekStart'; -export function useCalendarState(props: CalendarProps): CalendarState { +interface CalendarStateOptions extends CalendarProps { + createCalendar: (name: string) => Calendar +} + +export function useCalendarState(props: CalendarStateOptions): CalendarState { + let defaultFormatter = useDateFormatter(); + let resolvedOptions = useMemo(() => defaultFormatter.resolvedOptions(), [defaultFormatter]); + let { + createCalendar, + timeZone = resolvedOptions.timeZone + } = props; + + let calendar = useMemo(() => createCalendar(resolvedOptions.calendar), [createCalendar, resolvedOptions.calendar]); + let [value, setControlledValue] = useControlledState(props.value || undefined, props.defaultValue, props.onChange); - let dateValue = value ? new Date(value) : null; - let defaultMonth = dateValue || new Date(); + let dateValue = useMemo(() => value ? new Date(value) : null, [value]); + let calendarDateValue = useMemo(() => dateValue ? toCalendar(toCalendarDate(fromAbsolute(dateValue.getTime(), timeZone)), calendar) : null, [dateValue, timeZone, calendar]); + let defaultMonth = calendarDateValue || toCalendar(today(timeZone), calendar); let [currentMonth, setCurrentMonth] = useState(defaultMonth); // TODO: does this need to be in state at all?? let [focusedDate, setFocusedDate] = useState(defaultMonth); let [isFocused, setFocused] = useState(props.autoFocus || false); - let month = currentMonth.getMonth(); - let year = currentMonth.getFullYear(); let weekStart = useWeekStart(); - let monthStartsAt = (startOfMonth(currentMonth).getDay() - weekStart) % 7; + let monthStartsAt = (getDayOfWeek(startOfMonth(currentMonth)) - weekStart) % 7; if (monthStartsAt < 0) { monthStartsAt += 7; } - let days = getDaysInMonth(currentMonth); + // Reset focused date and current month when calendar changes. + let lastCalendarIdentifier = useRef(calendar.identifier); + useEffect(() => { + if (calendar.identifier !== lastCalendarIdentifier.current) { + let newFocusedDate = toCalendar(focusedDate, calendar); + setCurrentMonth(startOfMonth(newFocusedDate)); + setFocusedDate(newFocusedDate); + lastCalendarIdentifier.current = calendar.identifier; + } + }, [calendar, focusedDate]); + + let days = currentMonth.calendar.getDaysInMonth(currentMonth); let weeksInMonth = Math.ceil((monthStartsAt + days) / 7); - let minDate = props.minValue ? startOfDay(props.minValue) : null; - let maxDate = props.maxValue ? endOfDay(props.maxValue) : null; + let minDate = useMemo(() => props.minValue ? toCalendar(toCalendarDate(fromAbsolute(new Date(props.minValue).getTime(), timeZone)), calendar) : null, [calendar, props.minValue, timeZone]); + let maxDate = useMemo(() => props.maxValue ? toCalendar(toCalendarDate(fromAbsolute(new Date(props.maxValue).getTime(), timeZone)), calendar) : null, [calendar, props.maxValue, timeZone]); // Sets focus to a specific cell date - function focusCell(date: Date) { + function focusCell(date: CalendarDate) { if (isInvalid(date, minDate, maxDate)) { return; } @@ -58,68 +99,69 @@ export function useCalendarState(props: CalendarProps): CalendarState { let weekDays = useMemo(() => ( [...new Array(7).keys()] - .map(index => setDay(Date.now(), (index + weekStart) % 7)) - ), [weekStart]); + .map(index => set(currentMonth, {day: index - monthStartsAt + 1})) + ), [currentMonth, monthStartsAt]); return { isDisabled: props.isDisabled, isReadOnly: props.isReadOnly, - value: dateValue, + value: calendarDateValue, setValue, currentMonth, focusedDate, + timeZone, setFocusedDate, focusNextDay() { - focusCell(addDays(focusedDate, 1)); + focusCell(add(focusedDate, {days: 1})); }, focusPreviousDay() { - focusCell(subDays(focusedDate, 1)); + focusCell(subtract(focusedDate, {days: 1})); }, focusNextWeek() { - focusCell(addWeeks(focusedDate, 1)); + focusCell(add(focusedDate, {weeks: 1})); }, focusPreviousWeek() { - focusCell(subWeeks(focusedDate, 1)); + focusCell(subtract(focusedDate, {weeks: 1})); }, focusNextMonth() { - focusCell(addMonths(focusedDate, 1)); + focusCell(add(focusedDate, {months: 1})); }, focusPreviousMonth() { - focusCell(subMonths(focusedDate, 1)); + focusCell(subtract(focusedDate, {months: 1})); }, focusStartOfMonth() { focusCell(startOfMonth(focusedDate)); }, focusEndOfMonth() { - focusCell(endOfMonth(startOfDay(focusedDate))); + focusCell(endOfMonth(focusedDate)); }, focusNextYear() { - focusCell(addYears(focusedDate, 1)); + focusCell(add(focusedDate, {years: 1})); }, focusPreviousYear() { - focusCell(subYears(focusedDate, 1)); + focusCell(subtract(focusedDate, {years: 1})); }, selectFocusedDate() { - setValue(focusedDate); + setValue(toDate(focusedDate, timeZone)); }, selectDate(date) { - setValue(date); + setValue(toDate(date, timeZone)); }, isFocused, setFocused, weeksInMonth, weekStart, - daysInMonth: getDaysInMonth(currentMonth), + daysInMonth: currentMonth.calendar.getDaysInMonth(currentMonth), weekDays, getCellDate(weekIndex, dayIndex) { let day = (weekIndex * 7 + dayIndex) - monthStartsAt + 1; - return new Date(year, month, day); + return set(currentMonth, {day}); }, isInvalid(date) { return isInvalid(date, minDate, maxDate); }, isSelected(date) { - return isSameDay(date, value); + return calendarDateValue != null && isSameDay(date, calendarDateValue); }, isCellFocused(date) { return isFocused && focusedDate && isSameDay(date, focusedDate); @@ -128,15 +170,15 @@ export function useCalendarState(props: CalendarProps): CalendarState { return props.isDisabled || !isSameMonth(date, currentMonth) || isInvalid(date, minDate, maxDate); }, isPreviousMonthInvalid() { - return isInvalid(endOfMonth(subMonths(currentMonth, 1)), minDate, maxDate); + return isInvalid(endOfMonth(subtract(currentMonth, {months: 1})), minDate, maxDate); }, isNextMonthInvalid() { - return isInvalid(startOfMonth(addMonths(currentMonth, 1)), minDate, maxDate); + return isInvalid(startOfMonth(add(currentMonth, {months: 1})), minDate, maxDate); } }; } -function isInvalid(date: Date, minDate: Date, maxDate: Date) { - return (minDate != null && date < minDate) || - (maxDate != null && date > maxDate); +function isInvalid(date: CalendarDate, minDate: CalendarDate, maxDate: CalendarDate) { + return (minDate != null && compare(date, minDate) < 0) || + (maxDate != null && compare(date, maxDate) > 0); } diff --git a/packages/@react-stately/calendar/src/useRangeCalendarState.ts b/packages/@react-stately/calendar/src/useRangeCalendarState.ts index 75ebae27bc8..af8d19189b9 100644 --- a/packages/@react-stately/calendar/src/useRangeCalendarState.ts +++ b/packages/@react-stately/calendar/src/useRangeCalendarState.ts @@ -10,8 +10,8 @@ * governing permissions and limitations under the License. */ +import {Calendar, CalendarDate, compare, fromAbsolute, toCalendarDate, toDate} from '@internationalized/date'; import {DateValue} from '@react-types/datepicker'; -import {endOfDay, startOfDay} from 'date-fns'; import {RangeCalendarProps} from '@react-types/calendar'; import {RangeCalendarState} from './types'; import {RangeValue} from '@react-types/shared'; @@ -19,23 +19,28 @@ import {useCalendarState} from './useCalendarState'; import {useControlledState} from '@react-stately/utils'; import {useState} from 'react'; -export function useRangeCalendarState(props: RangeCalendarProps): RangeCalendarState { - let {value: valueProp, defaultValue, onChange, ...calendarProps} = props; +interface RangeCalendarStateOptions extends RangeCalendarProps { + createCalendar: (name: string) => Calendar +} + +export function useRangeCalendarState(props: RangeCalendarStateOptions): RangeCalendarState { + let {value: valueProp, defaultValue, onChange, createCalendar, ...calendarProps} = props; let [value, setValue] = useControlledState( valueProp, defaultValue, onChange ); - let dateRange = value != null ? convertRange(value) : null; let [anchorDate, setAnchorDate] = useState(null); let calendar = useCalendarState({ ...calendarProps, - value: value && value.start + value: value && value.start, + createCalendar }); + let dateRange = value != null ? convertRange(value, calendar.timeZone) : null; let highlightedRange = anchorDate ? makeRange(anchorDate, calendar.focusedDate) : value && makeRange(dateRange.start, dateRange.end); - let selectDate = (date: Date) => { + let selectDate = (date: CalendarDate) => { if (props.isReadOnly) { return; } @@ -43,7 +48,11 @@ export function useRangeCalendarState(props: RangeCalendarProps): RangeCalendarS if (!anchorDate) { setAnchorDate(date); } else { - setValue(makeRange(anchorDate, date)); + let range = makeRange(anchorDate, date); + setValue({ + start: toDate(range.start, calendar.timeZone), + end: toDate(range.end, calendar.timeZone) + }); setAnchorDate(null); } }; @@ -65,22 +74,22 @@ export function useRangeCalendarState(props: RangeCalendarProps): RangeCalendarS } }, isSelected(date) { - return highlightedRange && date >= highlightedRange.start && date <= highlightedRange.end; + return highlightedRange && compare(date, highlightedRange.start) >= 0 && compare(date, highlightedRange.end) <= 0; } }; } -function makeRange(start: Date, end: Date): RangeValue { - if (end < start) { +function makeRange(start: CalendarDate, end: CalendarDate): RangeValue { + if (compare(end, start) < 0) { [start, end] = [end, start]; } - return {start: startOfDay(start), end: endOfDay(end)}; + return {start, end}; } -function convertRange(range: RangeValue): RangeValue { +function convertRange(range: RangeValue, timeZone: string): RangeValue { return { - start: new Date(range.start), - end: new Date(range.end) + start: toCalendarDate(fromAbsolute(new Date(range.start).getTime(), timeZone)), + end: toCalendarDate(fromAbsolute(new Date(range.end).getTime(), timeZone)) }; } diff --git a/packages/@react-stately/calendar/src/useWeekStart.ts b/packages/@react-stately/calendar/src/useWeekStart.ts index 5f1d2bda68a..cac082c9498 100644 --- a/packages/@react-stately/calendar/src/useWeekStart.ts +++ b/packages/@react-stately/calendar/src/useWeekStart.ts @@ -125,5 +125,9 @@ function useRegion() { } // If not, just try splitting the string. - return locale.split('-')[1]; + // 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' ? null : part; } diff --git a/packages/@react-stately/collections/src/CollectionBuilder.ts b/packages/@react-stately/collections/src/CollectionBuilder.ts index 9aac44fa28b..15225d98872 100644 --- a/packages/@react-stately/collections/src/CollectionBuilder.ts +++ b/packages/@react-stately/collections/src/CollectionBuilder.ts @@ -97,6 +97,7 @@ export class CollectionBuilder { let cached = this.cache.get(partialNode.value); if (cached && (!cached.shouldInvalidate || !cached.shouldInvalidate(this.context))) { cached.index = partialNode.index; + cached.parentKey = parentNode ? parentNode.key : null; yield cached; return; } diff --git a/packages/@react-stately/layout/src/ListLayout.ts b/packages/@react-stately/layout/src/ListLayout.ts index 6dcacf76028..fe819521a2b 100644 --- a/packages/@react-stately/layout/src/ListLayout.ts +++ b/packages/@react-stately/layout/src/ListLayout.ts @@ -129,6 +129,20 @@ export class ListLayout extends Layout> implements KeyboardDelegate { this.collection = this.virtualizer.collection; this.rootNodes = this.buildCollection(); + // Remove deleted layout nodes + if (this.lastCollection) { + for (let key of this.lastCollection.getKeys()) { + if (!this.collection.getItem(key)) { + let layoutNode = this.layoutNodes.get(key); + if (layoutNode) { + this.layoutInfos.delete(layoutNode.layoutInfo.key); + this.layoutInfos.delete(layoutNode.header?.key); + this.layoutNodes.delete(key); + } + } + } + } + this.lastWidth = this.virtualizer.visibleRect.width; this.lastCollection = this.collection; } @@ -179,45 +193,10 @@ export class ListLayout extends Layout> implements KeyboardDelegate { this.layoutInfos.set(layoutNode.header.key, layoutNode.header); } - // Remove deleted child layout nodes from key mapping. - if (cached) { - let childKeys = new Set(); - if (layoutNode.children) { - for (let child of layoutNode.children) { - childKeys.add(child.layoutInfo.key); - } - } - - if (cached.children) { - for (let child of cached.children) { - if (!childKeys.has(child.layoutInfo.key)) { - this.removeLayoutNode(child); - } - } - } - } - this.layoutNodes.set(node.key, layoutNode); return layoutNode; } - removeLayoutNode(layoutNode: LayoutNode) { - this.layoutNodes.delete(layoutNode.layoutInfo.key); - - this.layoutInfos.delete(layoutNode.layoutInfo.key); - if (layoutNode.header) { - this.layoutInfos.delete(layoutNode.header.key); - } - - if (layoutNode.children) { - for (let child of layoutNode.children) { - if (this.layoutNodes.get(child.layoutInfo.key) === child) { - this.removeLayoutNode(child); - } - } - } - } - buildNode(node: Node, x: number, y: number): LayoutNode { switch (node.type) { case 'section': diff --git a/packages/@react-types/calendar/src/index.d.ts b/packages/@react-types/calendar/src/index.d.ts index 2d760a925b1..81b78c5e7dd 100644 --- a/packages/@react-types/calendar/src/index.d.ts +++ b/packages/@react-types/calendar/src/index.d.ts @@ -14,6 +14,7 @@ import {DOMProps, RangeValue, StyleProps, ValueBase} from '@react-types/shared'; export type DateValue = string | number | Date; export interface CalendarPropsBase { + timeZone?: string, minValue?: DateValue, maxValue?: DateValue, isDisabled?: boolean,