- Port Dialog component from uisv to acel (dialog.svelte.ts state machine) - Rewrite alert-dialog as thin wrapper over acel Dialog - Remove 4 redundant alert-dialog components (Trigger, Overlay, Title, Description) now re-exported directly from dialog/components/ - Add Dialog export to acel/index.ts - Fix double-namespace in button/index.ts - Fix SPA config: ssr=false, prerender=false, adapter-static fallback - Fix Snippet<[DialogContentSnippetProps]> children call with snippetProps - Fix glob/lib/date TS errors: import type for verbatimModuleSyntax (×45) - Fix TS7053 in manipulation.ts invertDuration and DateFormatter.ts - Fix TS2322 hourCycle type in comn_utils.ts and DateFormatter.ts - Recreate test pages: accordion, collapsible, alert-dialog, dialog (new), button (new) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>main
parent
0dac42173e
commit
9084d2783a
@ -1,7 +1,13 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(git add:*)"
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)",
|
||||
"Bash(npx svelte-check:*)",
|
||||
"Bash(npx tsc:*)",
|
||||
"Bash(grep -v \"^$\")",
|
||||
"Bash(grep -n export.*Mutable g:/dev/svelte/vicen/src/lib/glob/lib/date/utils.ts)",
|
||||
"Bash(grep -rn export.*Mutable g:/dev/svelte/vicen/src/lib/glob/lib/date/ --include=*.ts)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,406 @@
|
||||
/*
|
||||
* Copyright 2020 Adobe. All rights reserved.
|
||||
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License. You may obtain a copy
|
||||
* of the License at http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software distributed under
|
||||
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
|
||||
* OF ANY KIND, either express or implied. See the License for the specific language
|
||||
* governing permissions and limitations under the License.
|
||||
*/
|
||||
|
||||
import {add, addTime, addZoned, constrain, constrainTime, cycleDate, cycleTime, cycleZoned, set, setTime, setZoned, subtract, subtractTime, subtractZoned} from './manipulation';
|
||||
import type {AnyCalendarDate, AnyTime, Calendar, CycleOptions, CycleTimeOptions, DateDuration, DateField, DateFields, DateTimeDuration, Disambiguation, TimeDuration, TimeField, TimeFields} from './types';
|
||||
import {compareDate, compareTime} from './queries';
|
||||
import {dateTimeToString, dateToString, timeToString, zonedDateTimeToString} from './string';
|
||||
import {GregorianCalendar} from './calendars/GregorianCalendar';
|
||||
import {toCalendarDateTime, toDate, toZoned, zonedToDate} from './conversion';
|
||||
|
||||
function shiftArgs(args: any[]) {
|
||||
let calendar: Calendar = typeof args[0] === 'object'
|
||||
? args.shift()
|
||||
: new GregorianCalendar();
|
||||
|
||||
let era: string;
|
||||
if (typeof args[0] === 'string') {
|
||||
era = args.shift();
|
||||
} else {
|
||||
let eras = calendar.getEras();
|
||||
era = eras[eras.length - 1];
|
||||
}
|
||||
|
||||
let year = args.shift();
|
||||
let month = args.shift();
|
||||
let day = args.shift();
|
||||
|
||||
return [calendar, era, year, month, day];
|
||||
}
|
||||
|
||||
/** A CalendarDate represents a date without any time components in a specific calendar system. */
|
||||
export class CalendarDate {
|
||||
// This prevents TypeScript from allowing other types with the same fields to match.
|
||||
// i.e. a ZonedDateTime should not be be passable to a parameter that expects CalendarDate.
|
||||
// If that behavior is desired, use the AnyCalendarDate interface instead.
|
||||
// @ts-ignore
|
||||
#type;
|
||||
/** The calendar system associated with this date, e.g. Gregorian. */
|
||||
public readonly calendar: Calendar;
|
||||
/** The calendar era for this date, e.g. "BC" or "AD". */
|
||||
public readonly era: string;
|
||||
/** The year of this date within the era. */
|
||||
public readonly year: number;
|
||||
/**
|
||||
* The month number within the year. Note that some calendar systems such as Hebrew
|
||||
* may have a variable number of months per year. Therefore, month numbers may not
|
||||
* always correspond to the same month names in different years.
|
||||
*/
|
||||
public readonly month: number;
|
||||
/** The day number within the month. */
|
||||
public readonly day: number;
|
||||
|
||||
constructor(year: number, month: number, day: number);
|
||||
constructor(era: string, year: number, month: number, day: number);
|
||||
constructor(calendar: Calendar, year: number, month: number, day: number);
|
||||
constructor(calendar: Calendar, era: string, year: number, month: number, day: number);
|
||||
constructor(...args: any[]) {
|
||||
let [calendar, era, year, month, day] = shiftArgs(args);
|
||||
this.calendar = calendar;
|
||||
this.era = era;
|
||||
this.year = year;
|
||||
this.month = month;
|
||||
this.day = day;
|
||||
|
||||
constrain(this);
|
||||
}
|
||||
|
||||
/** Returns a copy of this date. */
|
||||
copy(): CalendarDate {
|
||||
if (this.era) {
|
||||
return new CalendarDate(this.calendar, this.era, this.year, this.month, this.day);
|
||||
} else {
|
||||
return new CalendarDate(this.calendar, this.year, this.month, this.day);
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns a new `CalendarDate` with the given duration added to it. */
|
||||
add(duration: DateDuration): CalendarDate {
|
||||
return add(this, duration);
|
||||
}
|
||||
|
||||
/** Returns a new `CalendarDate` with the given duration subtracted from it. */
|
||||
subtract(duration: DateDuration): CalendarDate {
|
||||
return subtract(this, duration);
|
||||
}
|
||||
|
||||
/** Returns a new `CalendarDate` with the given fields set to the provided values. Other fields will be constrained accordingly. */
|
||||
set(fields: DateFields): CalendarDate {
|
||||
return set(this, fields);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new `CalendarDate` with the given field adjusted by a specified amount.
|
||||
* When the resulting value reaches the limits of the field, it wraps around.
|
||||
*/
|
||||
cycle(field: DateField, amount: number, options?: CycleOptions): CalendarDate {
|
||||
return cycleDate(this, field, amount, options);
|
||||
}
|
||||
|
||||
/** Converts the date to a native JavaScript Date object, with the time set to midnight in the given time zone. */
|
||||
toDate(timeZone: string): Date {
|
||||
return toDate(this, timeZone);
|
||||
}
|
||||
|
||||
/** Converts the date to an ISO 8601 formatted string. */
|
||||
toString(): string {
|
||||
return dateToString(this);
|
||||
}
|
||||
|
||||
/** Compares this date with another. A negative result indicates that this date is before the given one, and a positive date indicates that it is after. */
|
||||
compare(b: AnyCalendarDate): number {
|
||||
return compareDate(this, b);
|
||||
}
|
||||
}
|
||||
|
||||
/** A Time represents a clock time without any date components. */
|
||||
export class Time {
|
||||
// This prevents TypeScript from allowing other types with the same fields to match.
|
||||
// @ts-ignore
|
||||
#type;
|
||||
/** The hour, numbered from 0 to 23. */
|
||||
public readonly hour: number;
|
||||
/** The minute in the hour. */
|
||||
public readonly minute: number;
|
||||
/** The second in the minute. */
|
||||
public readonly second: number;
|
||||
/** The millisecond in the second. */
|
||||
public readonly millisecond: number;
|
||||
|
||||
constructor(
|
||||
hour: number = 0,
|
||||
minute: number = 0,
|
||||
second: number = 0,
|
||||
millisecond: number = 0
|
||||
) {
|
||||
this.hour = hour;
|
||||
this.minute = minute;
|
||||
this.second = second;
|
||||
this.millisecond = millisecond;
|
||||
constrainTime(this);
|
||||
}
|
||||
|
||||
/** Returns a copy of this time. */
|
||||
copy(): Time {
|
||||
return new Time(this.hour, this.minute, this.second, this.millisecond);
|
||||
}
|
||||
|
||||
/** Returns a new `Time` with the given duration added to it. */
|
||||
add(duration: TimeDuration): Time {
|
||||
return addTime(this, duration);
|
||||
}
|
||||
|
||||
/** Returns a new `Time` with the given duration subtracted from it. */
|
||||
subtract(duration: TimeDuration): Time {
|
||||
return subtractTime(this, duration);
|
||||
}
|
||||
|
||||
/** Returns a new `Time` with the given fields set to the provided values. Other fields will be constrained accordingly. */
|
||||
set(fields: TimeFields): Time {
|
||||
return setTime(this, fields);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new `Time` with the given field adjusted by a specified amount.
|
||||
* When the resulting value reaches the limits of the field, it wraps around.
|
||||
*/
|
||||
cycle(field: TimeField, amount: number, options?: CycleTimeOptions): Time {
|
||||
return cycleTime(this, field, amount, options);
|
||||
}
|
||||
|
||||
/** Converts the time to an ISO 8601 formatted string. */
|
||||
toString(): string {
|
||||
return timeToString(this);
|
||||
}
|
||||
|
||||
/** Compares this time with another. A negative result indicates that this time is before the given one, and a positive time indicates that it is after. */
|
||||
compare(b: AnyTime): number {
|
||||
return compareTime(this, b);
|
||||
}
|
||||
}
|
||||
|
||||
/** A CalendarDateTime represents a date and time without a time zone, in a specific calendar system. */
|
||||
export class CalendarDateTime {
|
||||
// This prevents TypeScript from allowing other types with the same fields to match.
|
||||
// @ts-ignore
|
||||
#type;
|
||||
/** The calendar system associated with this date, e.g. Gregorian. */
|
||||
public readonly calendar: Calendar;
|
||||
/** The calendar era for this date, e.g. "BC" or "AD". */
|
||||
public readonly era: string;
|
||||
/** The year of this date within the era. */
|
||||
public readonly year: number;
|
||||
/**
|
||||
* The month number within the year. Note that some calendar systems such as Hebrew
|
||||
* may have a variable number of months per year. Therefore, month numbers may not
|
||||
* always correspond to the same month names in different years.
|
||||
*/
|
||||
public readonly month: number;
|
||||
/** The day number within the month. */
|
||||
public readonly day: number;
|
||||
/** The hour in the day, numbered from 0 to 23. */
|
||||
public readonly hour: number;
|
||||
/** The minute in the hour. */
|
||||
public readonly minute: number;
|
||||
/** The second in the minute. */
|
||||
public readonly second: number;
|
||||
/** The millisecond in the second. */
|
||||
public readonly millisecond: number;
|
||||
|
||||
constructor(year: number, month: number, day: number, hour?: number, minute?: number, second?: number, millisecond?: number);
|
||||
constructor(era: string, year: number, month: number, day: number, hour?: number, minute?: number, second?: number, millisecond?: number);
|
||||
constructor(calendar: Calendar, year: number, month: number, day: number, hour?: number, minute?: number, second?: number, millisecond?: number);
|
||||
constructor(calendar: Calendar, era: string, year: number, month: number, day: number, hour?: number, minute?: number, second?: number, millisecond?: number);
|
||||
constructor(...args: any[]) {
|
||||
let [calendar, era, year, month, day] = shiftArgs(args);
|
||||
this.calendar = calendar;
|
||||
this.era = era;
|
||||
this.year = year;
|
||||
this.month = month;
|
||||
this.day = day;
|
||||
this.hour = args.shift() || 0;
|
||||
this.minute = args.shift() || 0;
|
||||
this.second = args.shift() || 0;
|
||||
this.millisecond = args.shift() || 0;
|
||||
|
||||
constrain(this);
|
||||
}
|
||||
|
||||
/** Returns a copy of this date. */
|
||||
copy(): CalendarDateTime {
|
||||
if (this.era) {
|
||||
return new CalendarDateTime(this.calendar, this.era, this.year, this.month, this.day, this.hour, this.minute, this.second, this.millisecond);
|
||||
} else {
|
||||
return new CalendarDateTime(this.calendar, this.year, this.month, this.day, this.hour, this.minute, this.second, this.millisecond);
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns a new `CalendarDateTime` with the given duration added to it. */
|
||||
add(duration: DateTimeDuration): CalendarDateTime {
|
||||
return add(this, duration);
|
||||
}
|
||||
|
||||
/** Returns a new `CalendarDateTime` with the given duration subtracted from it. */
|
||||
subtract(duration: DateTimeDuration): CalendarDateTime {
|
||||
return subtract(this, duration);
|
||||
}
|
||||
|
||||
/** Returns a new `CalendarDateTime` with the given fields set to the provided values. Other fields will be constrained accordingly. */
|
||||
set(fields: DateFields & TimeFields): CalendarDateTime {
|
||||
return set(setTime(this, fields), fields);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new `CalendarDateTime` with the given field adjusted by a specified amount.
|
||||
* When the resulting value reaches the limits of the field, it wraps around.
|
||||
*/
|
||||
cycle(field: DateField | TimeField, amount: number, options?: CycleTimeOptions): CalendarDateTime {
|
||||
switch (field) {
|
||||
case 'era':
|
||||
case 'year':
|
||||
case 'month':
|
||||
case 'day':
|
||||
return cycleDate(this, field, amount, options);
|
||||
default:
|
||||
return cycleTime(this, field, amount, options);
|
||||
}
|
||||
}
|
||||
|
||||
/** Converts the date to a native JavaScript Date object in the given time zone. */
|
||||
toDate(timeZone: string, disambiguation?: Disambiguation): Date {
|
||||
return toDate(this, timeZone, disambiguation);
|
||||
}
|
||||
|
||||
/** Converts the date to an ISO 8601 formatted string. */
|
||||
toString(): string {
|
||||
return dateTimeToString(this);
|
||||
}
|
||||
|
||||
/** Compares this date with another. A negative result indicates that this date is before the given one, and a positive date indicates that it is after. */
|
||||
compare(b: CalendarDate | CalendarDateTime | ZonedDateTime): number {
|
||||
let res = compareDate(this, b);
|
||||
if (res === 0) {
|
||||
return compareTime(this, toCalendarDateTime(b));
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
/** A ZonedDateTime represents a date and time in a specific time zone and calendar system. */
|
||||
export class ZonedDateTime {
|
||||
// This prevents TypeScript from allowing other types with the same fields to match.
|
||||
// @ts-ignore
|
||||
#type;
|
||||
/** The calendar system associated with this date, e.g. Gregorian. */
|
||||
public readonly calendar: Calendar;
|
||||
/** The calendar era for this date, e.g. "BC" or "AD". */
|
||||
public readonly era: string;
|
||||
/** The year of this date within the era. */
|
||||
public readonly year: number;
|
||||
/**
|
||||
* The month number within the year. Note that some calendar systems such as Hebrew
|
||||
* may have a variable number of months per year. Therefore, month numbers may not
|
||||
* always correspond to the same month names in different years.
|
||||
*/
|
||||
public readonly month: number;
|
||||
/** The day number within the month. */
|
||||
public readonly day: number;
|
||||
/** The hour in the day, numbered from 0 to 23. */
|
||||
public readonly hour: number;
|
||||
/** The minute in the hour. */
|
||||
public readonly minute: number;
|
||||
/** The second in the minute. */
|
||||
public readonly second: number;
|
||||
/** The millisecond in the second. */
|
||||
public readonly millisecond: number;
|
||||
/** The IANA time zone identifier that this date and time is represented in. */
|
||||
public readonly timeZone: string;
|
||||
/** The UTC offset for this time, in milliseconds. */
|
||||
public readonly offset: number;
|
||||
|
||||
constructor(year: number, month: number, day: number, timeZone: string, offset: number, hour?: number, minute?: number, second?: number, millisecond?: number);
|
||||
constructor(era: string, year: number, month: number, day: number, timeZone: string, offset: number, hour?: number, minute?: number, second?: number, millisecond?: number);
|
||||
constructor(calendar: Calendar, year: number, month: number, day: number, timeZone: string, offset: number, hour?: number, minute?: number, second?: number, millisecond?: number);
|
||||
constructor(calendar: Calendar, era: string, year: number, month: number, day: number, timeZone: string, offset: number, hour?: number, minute?: number, second?: number, millisecond?: number);
|
||||
constructor(...args: any[]) {
|
||||
let [calendar, era, year, month, day] = shiftArgs(args);
|
||||
let timeZone = args.shift();
|
||||
let offset = args.shift();
|
||||
this.calendar = calendar;
|
||||
this.era = era;
|
||||
this.year = year;
|
||||
this.month = month;
|
||||
this.day = day;
|
||||
this.timeZone = timeZone;
|
||||
this.offset = offset;
|
||||
this.hour = args.shift() || 0;
|
||||
this.minute = args.shift() || 0;
|
||||
this.second = args.shift() || 0;
|
||||
this.millisecond = args.shift() || 0;
|
||||
|
||||
constrain(this);
|
||||
}
|
||||
|
||||
/** Returns a copy of this date. */
|
||||
copy(): ZonedDateTime {
|
||||
if (this.era) {
|
||||
return new ZonedDateTime(this.calendar, this.era, this.year, this.month, this.day, this.timeZone, this.offset, this.hour, this.minute, this.second, this.millisecond);
|
||||
} else {
|
||||
return new ZonedDateTime(this.calendar, this.year, this.month, this.day, this.timeZone, this.offset, this.hour, this.minute, this.second, this.millisecond);
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns a new `ZonedDateTime` with the given duration added to it. */
|
||||
add(duration: DateTimeDuration): ZonedDateTime {
|
||||
return addZoned(this, duration);
|
||||
}
|
||||
|
||||
/** Returns a new `ZonedDateTime` with the given duration subtracted from it. */
|
||||
subtract(duration: DateTimeDuration): ZonedDateTime {
|
||||
return subtractZoned(this, duration);
|
||||
}
|
||||
|
||||
/** Returns a new `ZonedDateTime` with the given fields set to the provided values. Other fields will be constrained accordingly. */
|
||||
set(fields: DateFields & TimeFields, disambiguation?: Disambiguation): ZonedDateTime {
|
||||
return setZoned(this, fields, disambiguation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new `ZonedDateTime` with the given field adjusted by a specified amount.
|
||||
* When the resulting value reaches the limits of the field, it wraps around.
|
||||
*/
|
||||
cycle(field: DateField | TimeField, amount: number, options?: CycleTimeOptions): ZonedDateTime {
|
||||
return cycleZoned(this, field, amount, options);
|
||||
}
|
||||
|
||||
/** Converts the date to a native JavaScript Date object. */
|
||||
toDate(): Date {
|
||||
return zonedToDate(this);
|
||||
}
|
||||
|
||||
/** Converts the date to an ISO 8601 formatted string, including the UTC offset and time zone identifier. */
|
||||
toString(): string {
|
||||
return zonedDateTimeToString(this);
|
||||
}
|
||||
|
||||
/** Converts the date to an ISO 8601 formatted string in UTC. */
|
||||
toAbsoluteString(): string {
|
||||
return this.toDate().toISOString();
|
||||
}
|
||||
|
||||
/** Compares this date with another. A negative result indicates that this date is before the given one, and a positive date indicates that it is after. */
|
||||
compare(b: CalendarDate | CalendarDateTime | ZonedDateTime): number {
|
||||
// TODO: Is this a bad idea??
|
||||
return this.toDate().getTime() - toZoned(b, this.timeZone).toDate().getTime();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Copyright 2020 Adobe. All rights reserved.
|
||||
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License. You may obtain a copy
|
||||
* of the License at http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software distributed under
|
||||
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
|
||||
* OF ANY KIND, either express or implied. See the License for the specific language
|
||||
* governing permissions and limitations under the License.
|
||||
*/
|
||||
|
||||
// Portions of the code in this file are based on code from ICU.
|
||||
// Original licensing can be found in the NOTICE file in the root directory of this source tree.
|
||||
|
||||
import type {AnyCalendarDate, CalendarIdentifier} from '../types';
|
||||
import {CalendarDate} from '../CalendarDate';
|
||||
import {fromExtendedYear, getExtendedYear, GregorianCalendar} from './GregorianCalendar';
|
||||
|
||||
const BUDDHIST_ERA_START = -543;
|
||||
|
||||
/**
|
||||
* The Buddhist calendar is the same as the Gregorian calendar, but counts years
|
||||
* starting from the birth of Buddha in 543 BC (Gregorian). It supports only one
|
||||
* era, identified as 'BE'.
|
||||
*/
|
||||
export class BuddhistCalendar extends GregorianCalendar {
|
||||
identifier: CalendarIdentifier = 'buddhist';
|
||||
|
||||
fromJulianDay(jd: number): CalendarDate {
|
||||
let gregorianDate = super.fromJulianDay(jd);
|
||||
let year = getExtendedYear(gregorianDate.era, gregorianDate.year);
|
||||
return new CalendarDate(
|
||||
this,
|
||||
year - BUDDHIST_ERA_START,
|
||||
gregorianDate.month,
|
||||
gregorianDate.day
|
||||
);
|
||||
}
|
||||
|
||||
toJulianDay(date: AnyCalendarDate): number {
|
||||
return super.toJulianDay(toGregorian(date));
|
||||
}
|
||||
|
||||
getEras(): string[] {
|
||||
return ['BE'];
|
||||
}
|
||||
|
||||
getDaysInMonth(date: AnyCalendarDate): number {
|
||||
return super.getDaysInMonth(toGregorian(date));
|
||||
}
|
||||
|
||||
balanceDate(): void {}
|
||||
}
|
||||
|
||||
function toGregorian(date: AnyCalendarDate) {
|
||||
let [era, year] = fromExtendedYear(date.year + BUDDHIST_ERA_START);
|
||||
return new CalendarDate(
|
||||
era,
|
||||
year,
|
||||
date.month,
|
||||
date.day
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,204 @@
|
||||
/*
|
||||
* Copyright 2020 Adobe. All rights reserved.
|
||||
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License. You may obtain a copy
|
||||
* of the License at http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software distributed under
|
||||
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
|
||||
* OF ANY KIND, either express or implied. See the License for the specific language
|
||||
* governing permissions and limitations under the License.
|
||||
*/
|
||||
|
||||
// Portions of the code in this file are based on code from ICU.
|
||||
// Original licensing can be found in the NOTICE file in the root directory of this source tree.
|
||||
|
||||
import type {AnyCalendarDate, Calendar, CalendarIdentifier} from '../types';
|
||||
import {CalendarDate} from '../CalendarDate';
|
||||
import type {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(epoch: number, jd: number) {
|
||||
let year = Math.floor((4 * (jd - epoch)) / 1461);
|
||||
let month = 1 + Math.floor((jd - ceToJulianDay(epoch, year, 1, 1)) / 30);
|
||||
let day = jd + 1 - ceToJulianDay(epoch, year, month, 1);
|
||||
return [year, month, day];
|
||||
}
|
||||
|
||||
function getLeapDay(year: number) {
|
||||
return Math.floor((year % 4) / 3);
|
||||
}
|
||||
|
||||
function getDaysInMonth(year: number, month: number) {
|
||||
// The Ethiopian and Coptic calendars have 13 months, 12 of 30 days each and
|
||||
// an intercalary month at the end of the year of 5 or 6 days, depending whether
|
||||
// the year is a leap year or not. The Leap Year follows the same rules as the
|
||||
// Julian Calendar so that the extra month always has six days in the year before
|
||||
// a Julian Leap Year.
|
||||
if (month % 13 !== 0) {
|
||||
// not intercalary month
|
||||
return 30;
|
||||
} else {
|
||||
// intercalary month 5 days + possible leap day
|
||||
return getLeapDay(year) + 5;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Ethiopic calendar system is the official calendar used in Ethiopia.
|
||||
* It includes 12 months of 30 days each, plus 5 or 6 intercalary days depending
|
||||
* on whether it is a leap year. Two eras are supported: 'AA' and 'AM'.
|
||||
*/
|
||||
export class EthiopicCalendar implements Calendar {
|
||||
identifier: CalendarIdentifier = 'ethiopic';
|
||||
|
||||
fromJulianDay(jd: number): CalendarDate {
|
||||
let [year, month, day] = julianDayToCE(ETHIOPIC_EPOCH, jd);
|
||||
let era = 'AM';
|
||||
if (year <= 0) {
|
||||
era = 'AA';
|
||||
year += AMETE_MIHRET_DELTA;
|
||||
}
|
||||
|
||||
return new CalendarDate(this, era, year, month, day);
|
||||
}
|
||||
|
||||
toJulianDay(date: AnyCalendarDate): number {
|
||||
let year = date.year;
|
||||
if (date.era === 'AA') {
|
||||
year -= AMETE_MIHRET_DELTA;
|
||||
}
|
||||
|
||||
return ceToJulianDay(ETHIOPIC_EPOCH, year, date.month, date.day);
|
||||
}
|
||||
|
||||
getDaysInMonth(date: AnyCalendarDate): number {
|
||||
return getDaysInMonth(date.year, date.month);
|
||||
}
|
||||
|
||||
getMonthsInYear(): number {
|
||||
return 13;
|
||||
}
|
||||
|
||||
getDaysInYear(date: AnyCalendarDate): number {
|
||||
return 365 + getLeapDay(date.year);
|
||||
}
|
||||
|
||||
getMaximumMonthsInYear(): number {
|
||||
return 13;
|
||||
}
|
||||
|
||||
getMaximumDaysInMonth(): number {
|
||||
return 30;
|
||||
}
|
||||
|
||||
getYearsInEra(date: AnyCalendarDate): number {
|
||||
// 9999-12-31 gregorian is 9992-20-02 ethiopic.
|
||||
// Round down to 9991 for the last full year.
|
||||
// AA 9999-01-01 ethiopic is 4506-09-30 gregorian.
|
||||
return date.era === 'AA' ? 9999 : 9991;
|
||||
}
|
||||
|
||||
getEras(): string[] {
|
||||
return ['AA', 'AM'];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Ethiopic (Amete Alem) calendar is the same as the modern Ethiopic calendar,
|
||||
* except years were measured from a different epoch. Only one era is supported: 'AA'.
|
||||
*/
|
||||
export class EthiopicAmeteAlemCalendar extends EthiopicCalendar {
|
||||
identifier: CalendarIdentifier = 'ethioaa'; // also known as 'ethiopic-amete-alem' in ICU
|
||||
|
||||
fromJulianDay(jd: number): CalendarDate {
|
||||
let [year, month, day] = julianDayToCE(ETHIOPIC_EPOCH, jd);
|
||||
year += AMETE_MIHRET_DELTA;
|
||||
return new CalendarDate(this, 'AA', year, month, day);
|
||||
}
|
||||
|
||||
getEras(): string[] {
|
||||
return ['AA'];
|
||||
}
|
||||
|
||||
getYearsInEra(): number {
|
||||
// 9999-13-04 ethioaa is the maximum date, which is equivalent to 4506-09-29 gregorian.
|
||||
return 9999;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Coptic calendar is similar to the Ethiopic calendar.
|
||||
* It includes 12 months of 30 days each, plus 5 or 6 intercalary days depending
|
||||
* on whether it is a leap year. Two eras are supported: 'BCE' and 'CE'.
|
||||
*/
|
||||
export class CopticCalendar extends EthiopicCalendar {
|
||||
identifier: CalendarIdentifier = 'coptic';
|
||||
|
||||
fromJulianDay(jd: number): CalendarDate {
|
||||
let [year, month, day] = julianDayToCE(COPTIC_EPOCH, jd);
|
||||
let era = 'CE';
|
||||
if (year <= 0) {
|
||||
era = 'BCE';
|
||||
year = 1 - year;
|
||||
}
|
||||
|
||||
return new CalendarDate(this, era, year, month, day);
|
||||
}
|
||||
|
||||
toJulianDay(date: AnyCalendarDate): number {
|
||||
let year = date.year;
|
||||
if (date.era === 'BCE') {
|
||||
year = 1 - year;
|
||||
}
|
||||
|
||||
return ceToJulianDay(COPTIC_EPOCH, year, date.month, date.day);
|
||||
}
|
||||
|
||||
getDaysInMonth(date: AnyCalendarDate): number {
|
||||
let year = date.year;
|
||||
if (date.era === 'BCE') {
|
||||
year = 1 - year;
|
||||
}
|
||||
|
||||
return getDaysInMonth(year, date.month);
|
||||
}
|
||||
|
||||
isInverseEra(date: AnyCalendarDate): boolean {
|
||||
return date.era === 'BCE';
|
||||
}
|
||||
|
||||
balanceDate(date: Mutable<AnyCalendarDate>): void {
|
||||
if (date.year <= 0) {
|
||||
date.era = date.era === 'BCE' ? 'CE' : 'BCE';
|
||||
date.year = 1 - date.year;
|
||||
}
|
||||
}
|
||||
|
||||
getEras(): string[] {
|
||||
return ['BCE', 'CE'];
|
||||
}
|
||||
|
||||
getYearsInEra(date: AnyCalendarDate): number {
|
||||
// 9999-12-30 gregorian is 9716-02-20 coptic.
|
||||
// Round down to 9715 for the last full year.
|
||||
// BCE 9999-01-01 coptic is BC 9716-06-15 gregorian.
|
||||
return date.era === 'BCE' ? 9999 : 9715;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,144 @@
|
||||
/*
|
||||
* Copyright 2020 Adobe. All rights reserved.
|
||||
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License. You may obtain a copy
|
||||
* of the License at http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software distributed under
|
||||
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
|
||||
* OF ANY KIND, either express or implied. See the License for the specific language
|
||||
* governing permissions and limitations under the License.
|
||||
*/
|
||||
|
||||
// Portions of the code in this file are based on code from ICU.
|
||||
// Original licensing can be found in the NOTICE file in the root directory of this source tree.
|
||||
|
||||
import type {AnyCalendarDate, Calendar, CalendarIdentifier} from '../types';
|
||||
import {CalendarDate} from '../CalendarDate';
|
||||
import {mod} from '../utils';
|
||||
import type {Mutable} from '../utils';
|
||||
|
||||
const EPOCH = 1721426; // 001/01/03 Julian C.E.
|
||||
export function gregorianToJulianDay(era: string, year: number, month: number, day: number): number {
|
||||
year = getExtendedYear(era, year);
|
||||
|
||||
let y1 = year - 1;
|
||||
let monthOffset = -2;
|
||||
if (month <= 2) {
|
||||
monthOffset = 0;
|
||||
} else if (isLeapYear(year)) {
|
||||
monthOffset = -1;
|
||||
}
|
||||
|
||||
return (
|
||||
EPOCH -
|
||||
1 +
|
||||
365 * y1 +
|
||||
Math.floor(y1 / 4) -
|
||||
Math.floor(y1 / 100) +
|
||||
Math.floor(y1 / 400) +
|
||||
Math.floor((367 * month - 362) / 12 + monthOffset + day)
|
||||
);
|
||||
}
|
||||
|
||||
export function isLeapYear(year: number): boolean {
|
||||
return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
|
||||
}
|
||||
|
||||
export function getExtendedYear(era: string, year: number): number {
|
||||
return era === 'BC' ? 1 - year : year;
|
||||
}
|
||||
|
||||
export function fromExtendedYear(year: number): [string, number] {
|
||||
let era = 'AD';
|
||||
if (year <= 0) {
|
||||
era = 'BC';
|
||||
year = 1 - year;
|
||||
}
|
||||
|
||||
return [era, year];
|
||||
}
|
||||
|
||||
const daysInMonth = {
|
||||
standard: [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31],
|
||||
leapyear: [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
|
||||
};
|
||||
|
||||
/**
|
||||
* The Gregorian calendar is the most commonly used calendar system in the world. It supports two eras: BC, and AD.
|
||||
* Years always contain 12 months, and 365 or 366 days depending on whether it is a leap year.
|
||||
*/
|
||||
export class GregorianCalendar implements Calendar {
|
||||
identifier: CalendarIdentifier = 'gregory';
|
||||
|
||||
fromJulianDay(jd: number): CalendarDate {
|
||||
let jd0 = jd;
|
||||
let depoch = jd0 - EPOCH;
|
||||
let quadricent = Math.floor(depoch / 146097);
|
||||
let dqc = mod(depoch, 146097);
|
||||
let cent = Math.floor(dqc / 36524);
|
||||
let dcent = mod(dqc, 36524);
|
||||
let quad = Math.floor(dcent / 1461);
|
||||
let dquad = mod(dcent, 1461);
|
||||
let yindex = Math.floor(dquad / 365);
|
||||
|
||||
let extendedYear = quadricent * 400 + cent * 100 + quad * 4 + yindex + (cent !== 4 && yindex !== 4 ? 1 : 0);
|
||||
let [era, year] = fromExtendedYear(extendedYear);
|
||||
let yearDay = jd0 - gregorianToJulianDay(era, year, 1, 1);
|
||||
let leapAdj = 2;
|
||||
if (jd0 < gregorianToJulianDay(era, year, 3, 1)) {
|
||||
leapAdj = 0;
|
||||
} else if (isLeapYear(year)) {
|
||||
leapAdj = 1;
|
||||
}
|
||||
let month = Math.floor(((yearDay + leapAdj) * 12 + 373) / 367);
|
||||
let day = jd0 - gregorianToJulianDay(era, year, month, 1) + 1;
|
||||
|
||||
return new CalendarDate(era, year, month, day);
|
||||
}
|
||||
|
||||
toJulianDay(date: AnyCalendarDate): number {
|
||||
return gregorianToJulianDay(date.era, date.year, date.month, date.day);
|
||||
}
|
||||
|
||||
getDaysInMonth(date: AnyCalendarDate): number {
|
||||
return daysInMonth[isLeapYear(date.year) ? 'leapyear' : 'standard'][date.month - 1];
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
getMonthsInYear(date: AnyCalendarDate): number {
|
||||
return 12;
|
||||
}
|
||||
|
||||
getDaysInYear(date: AnyCalendarDate): number {
|
||||
return isLeapYear(date.year) ? 366 : 365;
|
||||
}
|
||||
|
||||
getMaximumMonthsInYear(): number {
|
||||
return 12;
|
||||
}
|
||||
|
||||
getMaximumDaysInMonth(): number {
|
||||
return 31;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
getYearsInEra(date: AnyCalendarDate): number {
|
||||
return 9999;
|
||||
}
|
||||
|
||||
getEras(): string[] {
|
||||
return ['BC', 'AD'];
|
||||
}
|
||||
|
||||
isInverseEra(date: AnyCalendarDate): boolean {
|
||||
return date.era === 'BC';
|
||||
}
|
||||
|
||||
balanceDate(date: Mutable<AnyCalendarDate>): void {
|
||||
if (date.year <= 0) {
|
||||
date.era = date.era === 'BC' ? 'AD' : 'BC';
|
||||
date.year = 1 - date.year;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,211 @@
|
||||
/*
|
||||
* Copyright 2020 Adobe. All rights reserved.
|
||||
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License. You may obtain a copy
|
||||
* of the License at http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software distributed under
|
||||
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
|
||||
* OF ANY KIND, either express or implied. See the License for the specific language
|
||||
* governing permissions and limitations under the License.
|
||||
*/
|
||||
|
||||
// Portions of the code in this file are based on code from ICU.
|
||||
// Original licensing can be found in the NOTICE file in the root directory of this source tree.
|
||||
|
||||
import type {AnyCalendarDate, Calendar, CalendarIdentifier} from '../types';
|
||||
import {CalendarDate} from '../CalendarDate';
|
||||
import {mod} from '../utils';
|
||||
import type {Mutable} 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Hebrew calendar is used in Israel and around the world by the Jewish faith.
|
||||
* Years include either 12 or 13 months depending on whether it is a leap year.
|
||||
* In leap years, an extra month is inserted at month 6.
|
||||
*/
|
||||
export class HebrewCalendar implements Calendar {
|
||||
identifier: CalendarIdentifier = 'hebrew';
|
||||
|
||||
fromJulianDay(jd: number): CalendarDate {
|
||||
let d = jd - HEBREW_EPOCH;
|
||||
let m = (d * DAY_PARTS) / MONTH_PARTS; // Months (approx)
|
||||
let year = Math.floor((19 * m + 234) / 235) + 1; // Years (approx)
|
||||
let ys = startOfYear(year); // 1st day of year
|
||||
let dayOfYear = Math.floor(d - ys);
|
||||
|
||||
// Because of the postponement rules, it's possible to guess wrong. Fix it.
|
||||
while (dayOfYear < 1) {
|
||||
year--;
|
||||
ys = startOfYear(year);
|
||||
dayOfYear = Math.floor(d - ys);
|
||||
}
|
||||
|
||||
// Now figure out which month we're in, and the date within that month
|
||||
let month = 1;
|
||||
let monthStart = 0;
|
||||
while (monthStart < dayOfYear) {
|
||||
monthStart += getDaysInMonth(year, month);
|
||||
month++;
|
||||
}
|
||||
|
||||
month--;
|
||||
monthStart -= getDaysInMonth(year, month);
|
||||
|
||||
let day = dayOfYear - monthStart;
|
||||
return new CalendarDate(this, year, month, day);
|
||||
}
|
||||
|
||||
toJulianDay(date: AnyCalendarDate): number {
|
||||
let jd = startOfYear(date.year);
|
||||
for (let month = 1; month < date.month; month++) {
|
||||
jd += getDaysInMonth(date.year, month);
|
||||
}
|
||||
|
||||
return jd + date.day + HEBREW_EPOCH;
|
||||
}
|
||||
|
||||
getDaysInMonth(date: AnyCalendarDate): number {
|
||||
return getDaysInMonth(date.year, date.month);
|
||||
}
|
||||
|
||||
getMonthsInYear(date: AnyCalendarDate): number {
|
||||
return isLeapYear(date.year) ? 13 : 12;
|
||||
}
|
||||
|
||||
getDaysInYear(date: AnyCalendarDate): number {
|
||||
return getDaysInYear(date.year);
|
||||
}
|
||||
|
||||
getMaximumMonthsInYear(): number {
|
||||
return 13;
|
||||
}
|
||||
|
||||
getMaximumDaysInMonth(): number {
|
||||
return 30;
|
||||
}
|
||||
|
||||
getYearsInEra(): number {
|
||||
// 6239 gregorian
|
||||
return 9999;
|
||||
}
|
||||
|
||||
getEras(): string[] {
|
||||
return ['AM'];
|
||||
}
|
||||
|
||||
balanceYearMonth(date: Mutable<AnyCalendarDate>, previousDate: AnyCalendarDate): void {
|
||||
// Keep date in the same month when switching between leap years and non leap years
|
||||
if (previousDate.year !== date.year) {
|
||||
if (isLeapYear(previousDate.year) && !isLeapYear(date.year) && previousDate.month > 6) {
|
||||
date.month--;
|
||||
} else if (!isLeapYear(previousDate.year) && isLeapYear(date.year) && previousDate.month > 6) {
|
||||
date.month++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,129 @@
|
||||
/*
|
||||
* Copyright 2020 Adobe. All rights reserved.
|
||||
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License. You may obtain a copy
|
||||
* of the License at http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software distributed under
|
||||
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
|
||||
* OF ANY KIND, either express or implied. See the License for the specific language
|
||||
* governing permissions and limitations under the License.
|
||||
*/
|
||||
|
||||
// Portions of the code in this file are based on code from ICU.
|
||||
// Original licensing can be found in the NOTICE file in the root directory of this source tree.
|
||||
|
||||
import type {AnyCalendarDate, CalendarIdentifier} from '../types';
|
||||
import {CalendarDate} from '../CalendarDate';
|
||||
import {fromExtendedYear, GregorianCalendar, gregorianToJulianDay, isLeapYear} from './GregorianCalendar';
|
||||
|
||||
// Starts in 78 AD,
|
||||
const INDIAN_ERA_START = 78;
|
||||
|
||||
// The Indian year starts 80 days later than the Gregorian year.
|
||||
const INDIAN_YEAR_START = 80;
|
||||
|
||||
/**
|
||||
* The Indian National Calendar is similar to the Gregorian calendar, but with
|
||||
* years numbered since the Saka era in 78 AD (Gregorian). There are 12 months
|
||||
* in each year, with either 30 or 31 days. Only one era identifier is supported: 'saka'.
|
||||
*/
|
||||
export class IndianCalendar extends GregorianCalendar {
|
||||
identifier: CalendarIdentifier = 'indian';
|
||||
|
||||
fromJulianDay(jd: number): CalendarDate {
|
||||
// Gregorian date for Julian day
|
||||
let date = super.fromJulianDay(jd);
|
||||
|
||||
// Year in Saka era
|
||||
let indianYear = date.year - INDIAN_ERA_START;
|
||||
|
||||
// Day number in Gregorian year (starting from 0)
|
||||
let yDay = jd - gregorianToJulianDay(date.era, date.year, 1, 1);
|
||||
|
||||
let leapMonth: number;
|
||||
if (yDay < INDIAN_YEAR_START) {
|
||||
// Day is at the end of the preceding Saka year
|
||||
indianYear--;
|
||||
|
||||
// Days in leapMonth this year, previous Gregorian year
|
||||
leapMonth = isLeapYear(date.year - 1) ? 31 : 30;
|
||||
yDay += leapMonth + (31 * 5) + (30 * 3) + 10;
|
||||
} else {
|
||||
// Days in leapMonth this year
|
||||
leapMonth = isLeapYear(date.year) ? 31 : 30;
|
||||
yDay -= INDIAN_YEAR_START;
|
||||
}
|
||||
|
||||
let indianMonth: number;
|
||||
let indianDay: number;
|
||||
if (yDay < leapMonth) {
|
||||
indianMonth = 1;
|
||||
indianDay = yDay + 1;
|
||||
} else {
|
||||
let mDay = yDay - leapMonth;
|
||||
if (mDay < (31 * 5)) {
|
||||
indianMonth = Math.floor(mDay / 31) + 2;
|
||||
indianDay = (mDay % 31) + 1;
|
||||
} else {
|
||||
mDay -= 31 * 5;
|
||||
indianMonth = Math.floor(mDay / 30) + 7;
|
||||
indianDay = (mDay % 30) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return new CalendarDate(this, indianYear, indianMonth, indianDay);
|
||||
}
|
||||
|
||||
toJulianDay(date: AnyCalendarDate): number {
|
||||
let extendedYear = date.year + INDIAN_ERA_START;
|
||||
let [era, year] = fromExtendedYear(extendedYear);
|
||||
|
||||
let leapMonth: number;
|
||||
let jd: number;
|
||||
if (isLeapYear(year)) {
|
||||
leapMonth = 31;
|
||||
jd = gregorianToJulianDay(era, year, 3, 21);
|
||||
} else {
|
||||
leapMonth = 30;
|
||||
jd = gregorianToJulianDay(era, year, 3, 22);
|
||||
}
|
||||
|
||||
if (date.month === 1) {
|
||||
return jd + date.day - 1;
|
||||
}
|
||||
|
||||
jd += leapMonth + Math.min(date.month - 2, 5) * 31;
|
||||
|
||||
if (date.month >= 8) {
|
||||
jd += (date.month - 7) * 30;
|
||||
}
|
||||
|
||||
jd += date.day - 1;
|
||||
return jd;
|
||||
}
|
||||
|
||||
getDaysInMonth(date: AnyCalendarDate): number {
|
||||
if (date.month === 1 && isLeapYear(date.year + INDIAN_ERA_START)) {
|
||||
return 31;
|
||||
}
|
||||
|
||||
if (date.month >= 2 && date.month <= 6) {
|
||||
return 31;
|
||||
}
|
||||
|
||||
return 30;
|
||||
}
|
||||
|
||||
getYearsInEra(): number {
|
||||
// 9999-12-31 gregorian is 9920-10-10 indian.
|
||||
// Round down to 9919 for the last full year.
|
||||
return 9919;
|
||||
}
|
||||
|
||||
getEras(): string[] {
|
||||
return ['saka'];
|
||||
}
|
||||
|
||||
balanceDate(): void {}
|
||||
}
|
||||
@ -0,0 +1,233 @@
|
||||
/*
|
||||
* Copyright 2020 Adobe. All rights reserved.
|
||||
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License. You may obtain a copy
|
||||
* of the License at http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software distributed under
|
||||
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
|
||||
* OF ANY KIND, either express or implied. See the License for the specific language
|
||||
* governing permissions and limitations under the License.
|
||||
*/
|
||||
|
||||
// Portions of the code in this file are based on code from ICU.
|
||||
// Original licensing can be found in the NOTICE file in the root directory of this source tree.
|
||||
|
||||
import type {AnyCalendarDate, Calendar, CalendarIdentifier} from '../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;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Islamic calendar, also known as the "Hijri" calendar, is used throughout much of the Arab world.
|
||||
* The civil variant uses simple arithmetic rules rather than astronomical calculations to approximate
|
||||
* the traditional calendar, which is based on sighting of the crescent moon. It uses Friday, July 16 622 CE (Julian) as the epoch.
|
||||
* Each year has 12 months, with either 354 or 355 days depending on whether it is a leap year.
|
||||
* Learn more about the available Islamic calendars [here](https://cldr.unicode.org/development/development-process/design-proposals/islamic-calendar-types).
|
||||
*/
|
||||
export class IslamicCivilCalendar implements Calendar {
|
||||
identifier: CalendarIdentifier = 'islamic-civil';
|
||||
|
||||
fromJulianDay(jd: number): CalendarDate {
|
||||
return julianDayToIslamic(this, CIVIL_EPOC, jd);
|
||||
}
|
||||
|
||||
toJulianDay(date: AnyCalendarDate): number {
|
||||
return islamicToJulianDay(CIVIL_EPOC, date.year, date.month, date.day);
|
||||
}
|
||||
|
||||
getDaysInMonth(date: AnyCalendarDate): number {
|
||||
let length = 29 + date.month % 2;
|
||||
if (date.month === 12 && isLeapYear(date.year)) {
|
||||
length++;
|
||||
}
|
||||
|
||||
return length;
|
||||
}
|
||||
|
||||
getMonthsInYear(): number {
|
||||
return 12;
|
||||
}
|
||||
|
||||
getDaysInYear(date: AnyCalendarDate): number {
|
||||
return isLeapYear(date.year) ? 355 : 354;
|
||||
}
|
||||
|
||||
getMaximumMonthsInYear(): number {
|
||||
return 12;
|
||||
}
|
||||
|
||||
getMaximumDaysInMonth(): number {
|
||||
return 30;
|
||||
}
|
||||
|
||||
getYearsInEra(): number {
|
||||
// 9999 gregorian
|
||||
return 9665;
|
||||
}
|
||||
|
||||
getEras(): string[] {
|
||||
return ['AH'];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Islamic calendar, also known as the "Hijri" calendar, is used throughout much of the Arab world.
|
||||
* The tabular variant uses simple arithmetic rules rather than astronomical calculations to approximate
|
||||
* the traditional calendar, which is based on sighting of the crescent moon. It uses Thursday, July 15 622 CE (Julian) as the epoch.
|
||||
* Each year has 12 months, with either 354 or 355 days depending on whether it is a leap year.
|
||||
* Learn more about the available Islamic calendars [here](https://cldr.unicode.org/development/development-process/design-proposals/islamic-calendar-types).
|
||||
*/
|
||||
export class IslamicTabularCalendar extends IslamicCivilCalendar {
|
||||
identifier: CalendarIdentifier = 'islamic-tbla';
|
||||
|
||||
fromJulianDay(jd: number): CalendarDate {
|
||||
return julianDayToIslamic(this, ASTRONOMICAL_EPOC, jd);
|
||||
}
|
||||
|
||||
toJulianDay(date: AnyCalendarDate): number {
|
||||
return islamicToJulianDay(ASTRONOMICAL_EPOC, date.year, date.month, date.day);
|
||||
}
|
||||
}
|
||||
|
||||
// Generated by scripts/generate-umalqura.js
|
||||
const UMALQURA_DATA = 'qgpUDckO1AbqBmwDrQpVBakGkgepC9QF2gpcBS0NlQZKB1QLagutBa4ETwoXBYsGpQbVCtYCWwmdBE0KJg2VDawFtgm6AlsKKwWVCsoG6Qr0AnYJtgJWCcoKpAvSC9kF3AJtCU0FpQpSC6ULtAW2CVcFlwJLBaMGUgdlC2oFqworBZUMSg2lDcoF1gpXCasESwmlClILagt1BXYCtwhbBFUFqQW0BdoJ3QRuAjYJqgpUDbIN1QXaAlsJqwRVCkkLZAtxC7QFtQpVCiUNkg7JDtQG6QprCasEkwpJDaQNsg25CroEWworBZUKKgtVC1wFvQQ9Ah0JlQpKC1oLbQW2AjsJmwRVBqkGVAdqC2wFrQpVBSkLkgupC9QF2gpaBasKlQVJB2QHqgu1BbYCVgpNDiULUgtqC60FrgIvCZcESwalBqwG1gpdBZ0ETQoWDZUNqgW1BdoCWwmtBJUFygbkBuoK9QS2AlYJqgpUC9IL2QXqAm0JrQSVCkoLpQuyBbUJ1gSXCkcFkwZJB1ULagVrCisFiwpGDaMNygXWCtsEawJLCaUKUgtpC3UFdgG3CFsCKwVlBbQF2gntBG0BtgimClINqQ3UBdoKWwmrBFMGKQdiB6kLsgW1ClUFJQuSDckO0gbpCmsFqwRVCikNVA2qDbUJugQ7CpsETQqqCtUK2gJdCV4ELgqaDFUNsga5BroEXQotBZUKUguoC7QLuQXaAloJSgukDdEO6AZqC20FNQWVBkoNqA3UDdoGWwWdAisGFQtKC5ULqgWuCi4JjwwnBZUGqgbWCl0FnQI=';
|
||||
let UMALQURA_MONTHLENGTH: Uint16Array;
|
||||
let UMALQURA_YEAR_START_TABLE: Uint32Array;
|
||||
|
||||
function umalquraYearStart(year: number): number {
|
||||
return UMALQURA_START_DAYS + UMALQURA_YEAR_START_TABLE[year - UMALQURA_YEAR_START];
|
||||
}
|
||||
|
||||
function umalquraMonthLength(year: number, month: number): number {
|
||||
let idx = (year - UMALQURA_YEAR_START);
|
||||
let mask = (0x01 << (11 - (month - 1)));
|
||||
if ((UMALQURA_MONTHLENGTH[idx] & mask) === 0) {
|
||||
return 29;
|
||||
} else {
|
||||
return 30;
|
||||
}
|
||||
}
|
||||
|
||||
function umalquraMonthStart(year: number, month: number): number {
|
||||
let day = umalquraYearStart(year);
|
||||
for (let i = 1; i < month; i++) {
|
||||
day += umalquraMonthLength(year, i);
|
||||
}
|
||||
return day;
|
||||
}
|
||||
|
||||
function umalquraYearLength(year: number): number {
|
||||
return UMALQURA_YEAR_START_TABLE[year + 1 - UMALQURA_YEAR_START] - UMALQURA_YEAR_START_TABLE[year - UMALQURA_YEAR_START];
|
||||
}
|
||||
|
||||
/**
|
||||
* The Islamic calendar, also known as the "Hijri" calendar, is used throughout much of the Arab world.
|
||||
* The Umalqura variant is primarily used in Saudi Arabia. It is a lunar calendar, based on astronomical
|
||||
* calculations that predict the sighting of a crescent moon. Month and year lengths vary between years
|
||||
* depending on these calculations.
|
||||
* Learn more about the available Islamic calendars [here](https://cldr.unicode.org/development/development-process/design-proposals/islamic-calendar-types).
|
||||
*/
|
||||
export class IslamicUmalquraCalendar extends IslamicCivilCalendar {
|
||||
identifier: CalendarIdentifier = 'islamic-umalqura';
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
if (!UMALQURA_MONTHLENGTH) {
|
||||
UMALQURA_MONTHLENGTH = new Uint16Array(Uint8Array.from(atob(UMALQURA_DATA), c => c.charCodeAt(0)).buffer);
|
||||
}
|
||||
|
||||
if (!UMALQURA_YEAR_START_TABLE) {
|
||||
UMALQURA_YEAR_START_TABLE = new Uint32Array(UMALQURA_YEAR_END - UMALQURA_YEAR_START + 1);
|
||||
|
||||
let yearStart = 0;
|
||||
for (let year = UMALQURA_YEAR_START; year <= UMALQURA_YEAR_END; year++) {
|
||||
UMALQURA_YEAR_START_TABLE[year - UMALQURA_YEAR_START] = yearStart;
|
||||
for (let i = 1; i <= 12; i++) {
|
||||
yearStart += umalquraMonthLength(year, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fromJulianDay(jd: number): CalendarDate {
|
||||
let days = jd - CIVIL_EPOC;
|
||||
let startDays = umalquraYearStart(UMALQURA_YEAR_START);
|
||||
let endDays = umalquraYearStart(UMALQURA_YEAR_END);
|
||||
if (days < startDays || days > endDays) {
|
||||
return super.fromJulianDay(jd);
|
||||
} else {
|
||||
let y = UMALQURA_YEAR_START - 1;
|
||||
let m = 1;
|
||||
let d = 1;
|
||||
while (d > 0) {
|
||||
y++;
|
||||
d = days - umalquraYearStart(y) + 1;
|
||||
let yearLength = umalquraYearLength(y);
|
||||
if (d === yearLength) {
|
||||
m = 12;
|
||||
break;
|
||||
} else if (d < yearLength) {
|
||||
let monthLength = umalquraMonthLength(y, m);
|
||||
m = 1;
|
||||
while (d > monthLength) {
|
||||
d -= monthLength;
|
||||
m++;
|
||||
monthLength = umalquraMonthLength(y, m);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return new CalendarDate(this, y, m, (days - umalquraMonthStart(y, m) + 1));
|
||||
}
|
||||
}
|
||||
|
||||
toJulianDay(date: AnyCalendarDate): number {
|
||||
if (date.year < UMALQURA_YEAR_START || date.year > UMALQURA_YEAR_END) {
|
||||
return super.toJulianDay(date);
|
||||
}
|
||||
|
||||
return CIVIL_EPOC + umalquraMonthStart(date.year, date.month) + (date.day - 1);
|
||||
}
|
||||
|
||||
getDaysInMonth(date: AnyCalendarDate): number {
|
||||
if (date.year < UMALQURA_YEAR_START || date.year > UMALQURA_YEAR_END) {
|
||||
return super.getDaysInMonth(date);
|
||||
}
|
||||
|
||||
return umalquraMonthLength(date.year, date.month);
|
||||
}
|
||||
|
||||
getDaysInYear(date: AnyCalendarDate): number {
|
||||
if (date.year < UMALQURA_YEAR_START || date.year > UMALQURA_YEAR_END) {
|
||||
return super.getDaysInYear(date);
|
||||
}
|
||||
|
||||
return umalquraYearLength(date.year);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,177 @@
|
||||
/*
|
||||
* Copyright 2020 Adobe. All rights reserved.
|
||||
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License. You may obtain a copy
|
||||
* of the License at http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software distributed under
|
||||
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
|
||||
* OF ANY KIND, either express or implied. See the License for the specific language
|
||||
* governing permissions and limitations under the License.
|
||||
*/
|
||||
|
||||
// Portions of the code in this file are based on code from the TC39 Temporal proposal.
|
||||
// Original licensing can be found in the NOTICE file in the root directory of this source tree.
|
||||
|
||||
import type {AnyCalendarDate, CalendarIdentifier} from '../types';
|
||||
import {CalendarDate} from '../CalendarDate';
|
||||
import {GregorianCalendar} from './GregorianCalendar';
|
||||
import type {Mutable} from '../utils';
|
||||
|
||||
const ERA_START_DATES = [[1868, 9, 8], [1912, 7, 30], [1926, 12, 25], [1989, 1, 8], [2019, 5, 1]];
|
||||
const ERA_END_DATES = [[1912, 7, 29], [1926, 12, 24], [1989, 1, 7], [2019, 4, 30]];
|
||||
const ERA_ADDENDS = [1867, 1911, 1925, 1988, 2018];
|
||||
const ERA_NAMES = ['meiji', 'taisho', 'showa', 'heisei', 'reiwa'];
|
||||
|
||||
function findEraFromGregorianDate(date: AnyCalendarDate) {
|
||||
const idx = ERA_START_DATES.findIndex(([year, month, day]) => {
|
||||
if (date.year < year) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (date.year === year && date.month < month) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (date.year === year && date.month === month && date.day < day) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
if (idx === -1) {
|
||||
return ERA_START_DATES.length - 1;
|
||||
}
|
||||
|
||||
if (idx === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return idx - 1;
|
||||
}
|
||||
|
||||
function toGregorian(date: AnyCalendarDate) {
|
||||
let eraAddend = ERA_ADDENDS[ERA_NAMES.indexOf(date.era)];
|
||||
if (!eraAddend) {
|
||||
throw new Error('Unknown era: ' + date.era);
|
||||
}
|
||||
|
||||
return new CalendarDate(
|
||||
date.year + eraAddend,
|
||||
date.month,
|
||||
date.day
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Japanese calendar is based on the Gregorian calendar, but with eras for the reign of each Japanese emperor.
|
||||
* Whenever a new emperor ascends to the throne, a new era begins and the year starts again from 1.
|
||||
* Note that eras before 1868 (Gregorian) are not currently supported by this implementation.
|
||||
*/
|
||||
export class JapaneseCalendar extends GregorianCalendar {
|
||||
identifier: CalendarIdentifier = 'japanese';
|
||||
|
||||
fromJulianDay(jd: number): CalendarDate {
|
||||
let date = super.fromJulianDay(jd);
|
||||
let era = findEraFromGregorianDate(date);
|
||||
|
||||
return new CalendarDate(
|
||||
this,
|
||||
ERA_NAMES[era],
|
||||
date.year - ERA_ADDENDS[era],
|
||||
date.month,
|
||||
date.day
|
||||
);
|
||||
}
|
||||
|
||||
toJulianDay(date: AnyCalendarDate): number {
|
||||
return super.toJulianDay(toGregorian(date));
|
||||
}
|
||||
|
||||
balanceDate(date: Mutable<AnyCalendarDate>): void {
|
||||
let gregorianDate = toGregorian(date);
|
||||
let era = findEraFromGregorianDate(gregorianDate);
|
||||
|
||||
if (ERA_NAMES[era] !== date.era) {
|
||||
date.era = ERA_NAMES[era];
|
||||
date.year = gregorianDate.year - ERA_ADDENDS[era];
|
||||
}
|
||||
|
||||
// Constrain in case we went before the first supported era.
|
||||
this.constrainDate(date);
|
||||
}
|
||||
|
||||
constrainDate(date: Mutable<AnyCalendarDate>): void {
|
||||
let idx = ERA_NAMES.indexOf(date.era);
|
||||
let end = ERA_END_DATES[idx];
|
||||
if (end != null) {
|
||||
let [endYear, endMonth, endDay] = end;
|
||||
|
||||
// Constrain the year to the maximum possible value in the era.
|
||||
// Then constrain the month and day fields within that.
|
||||
let maxYear = endYear - ERA_ADDENDS[idx];
|
||||
date.year = Math.max(1, Math.min(maxYear, date.year));
|
||||
if (date.year === maxYear) {
|
||||
date.month = Math.min(endMonth, date.month);
|
||||
|
||||
if (date.month === endMonth) {
|
||||
date.day = Math.min(endDay, date.day);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (date.year === 1 && idx >= 0) {
|
||||
let [, startMonth, startDay] = ERA_START_DATES[idx];
|
||||
date.month = Math.max(startMonth, date.month);
|
||||
|
||||
if (date.month === startMonth) {
|
||||
date.day = Math.max(startDay, date.day);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getEras(): string[] {
|
||||
return ERA_NAMES;
|
||||
}
|
||||
|
||||
getYearsInEra(date: AnyCalendarDate): number {
|
||||
// Get the number of years in the era, taking into account the date's month and day fields.
|
||||
let era = ERA_NAMES.indexOf(date.era);
|
||||
let cur = ERA_START_DATES[era];
|
||||
let next = ERA_START_DATES[era + 1];
|
||||
if (next == null) {
|
||||
// 9999 gregorian is the maximum year allowed.
|
||||
return 9999 - cur[0] + 1;
|
||||
}
|
||||
|
||||
let years = next[0] - cur[0];
|
||||
|
||||
if (date.month < next[1] || (date.month === next[1] && date.day < next[2])) {
|
||||
years++;
|
||||
}
|
||||
|
||||
return years;
|
||||
}
|
||||
|
||||
getDaysInMonth(date: AnyCalendarDate): number {
|
||||
return super.getDaysInMonth(toGregorian(date));
|
||||
}
|
||||
|
||||
getMinimumMonthInYear(date: AnyCalendarDate): number {
|
||||
let start = getMinimums(date);
|
||||
return start ? start[1] : 1;
|
||||
}
|
||||
|
||||
getMinimumDayInMonth(date: AnyCalendarDate): number {
|
||||
let start = getMinimums(date);
|
||||
return start && date.month === start[1] ? start[2] : 1;
|
||||
}
|
||||
}
|
||||
|
||||
function getMinimums(date: AnyCalendarDate) {
|
||||
if (date.year === 1) {
|
||||
let idx = ERA_NAMES.indexOf(date.era);
|
||||
return ERA_START_DATES[idx];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,100 @@
|
||||
/*
|
||||
* Copyright 2020 Adobe. All rights reserved.
|
||||
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License. You may obtain a copy
|
||||
* of the License at http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software distributed under
|
||||
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
|
||||
* OF ANY KIND, either express or implied. See the License for the specific language
|
||||
* governing permissions and limitations under the License.
|
||||
*/
|
||||
|
||||
// Portions of the code in this file are based on code from ICU.
|
||||
// Original licensing can be found in the NOTICE file in the root directory of this source tree.
|
||||
|
||||
import type {AnyCalendarDate, Calendar, CalendarIdentifier} from '../types';
|
||||
import {CalendarDate} from '../CalendarDate';
|
||||
import {mod} from '../utils';
|
||||
|
||||
const PERSIAN_EPOCH = 1948320;
|
||||
|
||||
// Number of days from the start of the year to the start of each month.
|
||||
const MONTH_START = [
|
||||
0, // Farvardin
|
||||
31, // Ordibehesht
|
||||
62, // Khordad
|
||||
93, // Tir
|
||||
124, // Mordad
|
||||
155, // Shahrivar
|
||||
186, // Mehr
|
||||
216, // Aban
|
||||
246, // Azar
|
||||
276, // Dey
|
||||
306, // Bahman
|
||||
336 // Esfand
|
||||
];
|
||||
|
||||
/**
|
||||
* The Persian calendar is the main calendar used in Iran and Afghanistan. It has 12 months
|
||||
* in each year, the first 6 of which have 31 days, and the next 5 have 30 days. The 12th month
|
||||
* has either 29 or 30 days depending on whether it is a leap year. The Persian year starts
|
||||
* around the March equinox.
|
||||
*/
|
||||
export class PersianCalendar implements Calendar {
|
||||
identifier: CalendarIdentifier = 'persian';
|
||||
|
||||
fromJulianDay(jd: number): CalendarDate {
|
||||
let daysSinceEpoch = jd - PERSIAN_EPOCH;
|
||||
let year = 1 + Math.floor((33 * daysSinceEpoch + 3) / 12053);
|
||||
let farvardin1 = 365 * (year - 1) + Math.floor((8 * year + 21) / 33);
|
||||
let dayOfYear = daysSinceEpoch - farvardin1;
|
||||
let month = dayOfYear < 216
|
||||
? Math.floor(dayOfYear / 31)
|
||||
: Math.floor((dayOfYear - 6) / 30);
|
||||
let day = dayOfYear - MONTH_START[month] + 1;
|
||||
return new CalendarDate(this, year, month + 1, day);
|
||||
}
|
||||
|
||||
toJulianDay(date: AnyCalendarDate): number {
|
||||
let jd = PERSIAN_EPOCH - 1 + 365 * (date.year - 1) + Math.floor((8 * date.year + 21) / 33);
|
||||
jd += MONTH_START[date.month - 1];
|
||||
jd += date.day;
|
||||
return jd;
|
||||
}
|
||||
|
||||
getMonthsInYear(): number {
|
||||
return 12;
|
||||
}
|
||||
|
||||
getDaysInMonth(date: AnyCalendarDate): number {
|
||||
if (date.month <= 6) {
|
||||
return 31;
|
||||
}
|
||||
|
||||
if (date.month <= 11) {
|
||||
return 30;
|
||||
}
|
||||
|
||||
let isLeapYear = mod(25 * date.year + 11, 33) < 8;
|
||||
return isLeapYear ? 30 : 29;
|
||||
}
|
||||
|
||||
getMaximumMonthsInYear(): number {
|
||||
return 12;
|
||||
}
|
||||
|
||||
getMaximumDaysInMonth(): number {
|
||||
return 31;
|
||||
}
|
||||
|
||||
getEras(): string[] {
|
||||
return ['AP'];
|
||||
}
|
||||
|
||||
getYearsInEra(): number {
|
||||
// 9378-10-10 persian is 9999-12-31 gregorian.
|
||||
// Round down to 9377 to set the maximum full year.
|
||||
return 9377;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,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 type {AnyCalendarDate, CalendarIdentifier} from '../types';
|
||||
import {CalendarDate} from '../CalendarDate';
|
||||
import {fromExtendedYear, getExtendedYear, GregorianCalendar} from './GregorianCalendar';
|
||||
import type {Mutable} from '../utils';
|
||||
|
||||
const TAIWAN_ERA_START = 1911;
|
||||
|
||||
function gregorianYear(date: AnyCalendarDate) {
|
||||
return date.era === 'minguo'
|
||||
? date.year + TAIWAN_ERA_START
|
||||
: 1 - date.year + TAIWAN_ERA_START;
|
||||
}
|
||||
|
||||
function gregorianToTaiwan(year: number): [string, number] {
|
||||
let y = year - TAIWAN_ERA_START;
|
||||
if (y > 0) {
|
||||
return ['minguo', y];
|
||||
} else {
|
||||
return ['before_minguo', 1 - y];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The Taiwanese calendar is the same as the Gregorian calendar, but years
|
||||
* are numbered starting from 1912 (Gregorian). Two eras are supported:
|
||||
* 'before_minguo' and 'minguo'.
|
||||
*/
|
||||
export class TaiwanCalendar extends GregorianCalendar {
|
||||
identifier: CalendarIdentifier = 'roc'; // Republic of China
|
||||
|
||||
fromJulianDay(jd: number): CalendarDate {
|
||||
let date = super.fromJulianDay(jd);
|
||||
let extendedYear = getExtendedYear(date.era, date.year);
|
||||
let [era, year] = gregorianToTaiwan(extendedYear);
|
||||
return new CalendarDate(this, era, year, date.month, date.day);
|
||||
}
|
||||
|
||||
toJulianDay(date: AnyCalendarDate): number {
|
||||
return super.toJulianDay(toGregorian(date));
|
||||
}
|
||||
|
||||
getEras(): string[] {
|
||||
return ['before_minguo', 'minguo'];
|
||||
}
|
||||
|
||||
balanceDate(date: Mutable<AnyCalendarDate>): void {
|
||||
let [era, year] = gregorianToTaiwan(gregorianYear(date));
|
||||
date.era = era;
|
||||
date.year = year;
|
||||
}
|
||||
|
||||
isInverseEra(date: AnyCalendarDate): boolean {
|
||||
return date.era === 'before_minguo';
|
||||
}
|
||||
|
||||
getDaysInMonth(date: AnyCalendarDate): number {
|
||||
return super.getDaysInMonth(toGregorian(date));
|
||||
}
|
||||
|
||||
getYearsInEra(date: AnyCalendarDate): number {
|
||||
return date.era === 'before_minguo' ? 9999 : 9999 - TAIWAN_ERA_START;
|
||||
}
|
||||
}
|
||||
|
||||
function toGregorian(date: AnyCalendarDate) {
|
||||
let [era, year] = fromExtendedYear(gregorianYear(date));
|
||||
return new CalendarDate(
|
||||
era,
|
||||
year,
|
||||
date.month,
|
||||
date.day
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,316 @@
|
||||
/*
|
||||
* Copyright 2020 Adobe. All rights reserved.
|
||||
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License. You may obtain a copy
|
||||
* of the License at http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software distributed under
|
||||
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
|
||||
* OF ANY KIND, either express or implied. See the License for the specific language
|
||||
* governing permissions and limitations under the License.
|
||||
*/
|
||||
|
||||
// Portions of the code in this file are based on code from the TC39 Temporal proposal.
|
||||
// Original licensing can be found in the NOTICE file in the root directory of this source tree.
|
||||
|
||||
import type {AnyCalendarDate, AnyDateTime, AnyTime, Calendar, DateFields, Disambiguation, TimeFields} from './types';
|
||||
import {CalendarDate, CalendarDateTime, Time, ZonedDateTime} from './CalendarDate';
|
||||
import {constrain} from './manipulation';
|
||||
import {getExtendedYear, GregorianCalendar} from './calendars/GregorianCalendar';
|
||||
import {getLocalTimeZone, isEqualCalendar, isLocalTimeZoneOverridden} from './queries';
|
||||
import type {Mutable} from './utils';
|
||||
|
||||
export function epochFromDate(date: AnyDateTime): number {
|
||||
date = toCalendar(date, new GregorianCalendar());
|
||||
let year = getExtendedYear(date.era, date.year);
|
||||
return epochFromParts(year, date.month, date.day, date.hour, date.minute, date.second, date.millisecond);
|
||||
}
|
||||
|
||||
function epochFromParts(year: number, month: number, day: number, hour: number, minute: number, second: number, millisecond: number): number {
|
||||
// Note: Date.UTC() interprets one and two-digit years as being in the
|
||||
// 20th century, so don't use it
|
||||
let date = new Date();
|
||||
date.setUTCHours(hour, minute, second, millisecond);
|
||||
date.setUTCFullYear(year, month - 1, day);
|
||||
return date.getTime();
|
||||
}
|
||||
|
||||
export function getTimeZoneOffset(ms: number, timeZone: string): number {
|
||||
// Fast path for UTC.
|
||||
if (timeZone === 'UTC') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Fast path: for local timezone after 1970, use native Date.
|
||||
// Skip this fast path if the local timezone was explicitly overridden via setLocalTimeZone,
|
||||
// since native Date always uses the browser's timezone, not the overridden one.
|
||||
if (ms > 0 && timeZone === getLocalTimeZone() && !isLocalTimeZoneOverridden()) {
|
||||
return new Date(ms).getTimezoneOffset() * -60 * 1000;
|
||||
}
|
||||
|
||||
let {year, month, day, hour, minute, second} = getTimeZoneParts(ms, timeZone);
|
||||
let utc = epochFromParts(year, month, day, hour, minute, second, 0);
|
||||
return utc - Math.floor(ms / 1000) * 1000;
|
||||
}
|
||||
|
||||
const formattersByTimeZone = new Map<string, Intl.DateTimeFormat>();
|
||||
|
||||
function getTimeZoneParts(ms: number, timeZone: string) {
|
||||
let formatter = formattersByTimeZone.get(timeZone);
|
||||
if (!formatter) {
|
||||
formatter = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone,
|
||||
hour12: false,
|
||||
era: 'short',
|
||||
year: 'numeric',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
second: 'numeric'
|
||||
});
|
||||
|
||||
formattersByTimeZone.set(timeZone, formatter);
|
||||
}
|
||||
|
||||
let parts = formatter.formatToParts(new Date(ms));
|
||||
let namedParts: {[name: string]: string} = {};
|
||||
for (let part of parts) {
|
||||
if (part.type !== 'literal') {
|
||||
namedParts[part.type] = part.value;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
// Firefox returns B instead of BC... https://bugzilla.mozilla.org/show_bug.cgi?id=1752253
|
||||
year: namedParts.era === 'BC' || namedParts.era === 'B' ? -namedParts.year + 1 : +namedParts.year,
|
||||
month: +namedParts.month,
|
||||
day: +namedParts.day,
|
||||
hour: namedParts.hour === '24' ? 0 : +namedParts.hour, // bugs.chromium.org/p/chromium/issues/detail?id=1045791
|
||||
minute: +namedParts.minute,
|
||||
second: +namedParts.second
|
||||
};
|
||||
}
|
||||
|
||||
const DAYMILLIS = 86400000;
|
||||
|
||||
export function possibleAbsolutes(date: CalendarDateTime, timeZone: string): number[] {
|
||||
let ms = epochFromDate(date);
|
||||
let earlier = ms - getTimeZoneOffset(ms - DAYMILLIS, timeZone);
|
||||
let later = ms - getTimeZoneOffset(ms + DAYMILLIS, timeZone);
|
||||
return getValidWallTimes(date, timeZone, earlier, later);
|
||||
}
|
||||
|
||||
function getValidWallTimes(date: CalendarDateTime, timeZone: string, earlier: number, later: number): number[] {
|
||||
let found = earlier === later ? [earlier] : [earlier, later];
|
||||
return found.filter(absolute => isValidWallTime(date, timeZone, absolute));
|
||||
}
|
||||
|
||||
function isValidWallTime(date: CalendarDateTime, timeZone: string, absolute: number) {
|
||||
let parts = getTimeZoneParts(absolute, timeZone);
|
||||
return date.year === parts.year
|
||||
&& date.month === parts.month
|
||||
&& date.day === parts.day
|
||||
&& date.hour === parts.hour
|
||||
&& date.minute === parts.minute
|
||||
&& date.second === parts.second;
|
||||
}
|
||||
|
||||
export function toAbsolute(date: CalendarDate | CalendarDateTime, timeZone: string, disambiguation: Disambiguation = 'compatible'): number {
|
||||
let dateTime = toCalendarDateTime(date);
|
||||
|
||||
// Fast path: if the time zone is UTC, use native Date.
|
||||
if (timeZone === 'UTC') {
|
||||
return epochFromDate(dateTime);
|
||||
}
|
||||
|
||||
// Fast path: if the time zone is the local timezone and disambiguation is compatible, use native Date.
|
||||
// Skip this fast path if the local timezone was explicitly overridden via setLocalTimeZone,
|
||||
// since native Date always uses the browser's timezone, not the overridden one.
|
||||
if (timeZone === getLocalTimeZone() && disambiguation === 'compatible' && !isLocalTimeZoneOverridden()) {
|
||||
dateTime = toCalendar(dateTime, new GregorianCalendar());
|
||||
|
||||
// Don't use Date constructor here because two-digit years are interpreted in the 20th century.
|
||||
let date = new Date();
|
||||
let year = getExtendedYear(dateTime.era, dateTime.year);
|
||||
date.setFullYear(year, dateTime.month - 1, dateTime.day);
|
||||
date.setHours(dateTime.hour, dateTime.minute, dateTime.second, dateTime.millisecond);
|
||||
return date.getTime();
|
||||
}
|
||||
|
||||
let ms = epochFromDate(dateTime);
|
||||
let offsetBefore = getTimeZoneOffset(ms - DAYMILLIS, timeZone);
|
||||
let offsetAfter = getTimeZoneOffset(ms + DAYMILLIS, timeZone);
|
||||
let valid = getValidWallTimes(dateTime, timeZone, ms - offsetBefore, ms - offsetAfter);
|
||||
|
||||
if (valid.length === 1) {
|
||||
return valid[0];
|
||||
}
|
||||
|
||||
if (valid.length > 1) {
|
||||
switch (disambiguation) {
|
||||
// 'compatible' means 'earlier' for "fall back" transitions
|
||||
case 'compatible':
|
||||
case 'earlier':
|
||||
return valid[0];
|
||||
case 'later':
|
||||
return valid[valid.length - 1];
|
||||
case 'reject':
|
||||
throw new RangeError('Multiple possible absolute times found');
|
||||
}
|
||||
}
|
||||
|
||||
switch (disambiguation) {
|
||||
case 'earlier':
|
||||
return Math.min(ms - offsetBefore, ms - offsetAfter);
|
||||
// 'compatible' means 'later' for "spring forward" transitions
|
||||
case 'compatible':
|
||||
case 'later':
|
||||
return Math.max(ms - offsetBefore, ms - offsetAfter);
|
||||
case 'reject':
|
||||
throw new RangeError('No such absolute time found');
|
||||
}
|
||||
}
|
||||
|
||||
export function toDate(dateTime: CalendarDate | CalendarDateTime, timeZone: string, disambiguation: Disambiguation = 'compatible'): Date {
|
||||
return new Date(toAbsolute(dateTime, timeZone, disambiguation));
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a Unix epoch (milliseconds since 1970) and converts it to the provided time zone.
|
||||
*/
|
||||
export function fromAbsolute(ms: number, timeZone: string): ZonedDateTime {
|
||||
let offset = getTimeZoneOffset(ms, timeZone);
|
||||
let date = new Date(ms + offset);
|
||||
let year = date.getUTCFullYear();
|
||||
let month = date.getUTCMonth() + 1;
|
||||
let day = date.getUTCDate();
|
||||
let hour = date.getUTCHours();
|
||||
let minute = date.getUTCMinutes();
|
||||
let second = date.getUTCSeconds();
|
||||
let millisecond = date.getUTCMilliseconds();
|
||||
|
||||
return new ZonedDateTime(year < 1 ? 'BC' : 'AD', year < 1 ? -year + 1 : year, month, day, timeZone, offset, hour, minute, second, millisecond);
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a `Date` object and converts it to the provided time zone.
|
||||
*/
|
||||
export function fromDate(date: Date, timeZone: string): ZonedDateTime {
|
||||
return fromAbsolute(date.getTime(), timeZone);
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a `Date` object and converts it to the time zone identifier for the current user.
|
||||
*/
|
||||
export function fromDateToLocal(date: Date): ZonedDateTime {
|
||||
return fromDate(date, getLocalTimeZone());
|
||||
}
|
||||
|
||||
/** Converts a value with date components such as a `CalendarDateTime` or `ZonedDateTime` into a `CalendarDate`. */
|
||||
export function toCalendarDate(dateTime: AnyCalendarDate): CalendarDate {
|
||||
return new CalendarDate(dateTime.calendar, dateTime.era, dateTime.year, dateTime.month, dateTime.day);
|
||||
}
|
||||
|
||||
export function toDateFields(date: AnyCalendarDate): DateFields {
|
||||
return {
|
||||
era: date.era,
|
||||
year: date.year,
|
||||
month: date.month,
|
||||
day: date.day
|
||||
};
|
||||
}
|
||||
|
||||
export function toTimeFields(date: AnyTime): TimeFields {
|
||||
return {
|
||||
hour: date.hour,
|
||||
minute: date.minute,
|
||||
second: date.second,
|
||||
millisecond: date.millisecond
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a date value to a `CalendarDateTime`. An optional `Time` value can be passed to set the time
|
||||
* of the resulting value, otherwise it will default to midnight.
|
||||
*/
|
||||
export function toCalendarDateTime(date: CalendarDate | CalendarDateTime | ZonedDateTime, time?: AnyTime): CalendarDateTime {
|
||||
let hour = 0, minute = 0, second = 0, millisecond = 0;
|
||||
if ('timeZone' in date) {
|
||||
({hour, minute, second, millisecond} = date);
|
||||
} else if ('hour' in date && !time) {
|
||||
return date;
|
||||
}
|
||||
|
||||
if (time) {
|
||||
({hour, minute, second, millisecond} = time);
|
||||
}
|
||||
|
||||
return new CalendarDateTime(
|
||||
date.calendar,
|
||||
date.era,
|
||||
date.year,
|
||||
date.month,
|
||||
date.day,
|
||||
hour,
|
||||
minute,
|
||||
second,
|
||||
millisecond
|
||||
);
|
||||
}
|
||||
|
||||
/** Extracts the time components from a value containing a date and time. */
|
||||
export function toTime(dateTime: CalendarDateTime | ZonedDateTime): Time {
|
||||
return new Time(dateTime.hour, dateTime.minute, dateTime.second, dateTime.millisecond);
|
||||
}
|
||||
|
||||
/** Converts a date from one calendar system to another. */
|
||||
export function toCalendar<T extends AnyCalendarDate>(date: T, calendar: Calendar): T {
|
||||
if (isEqualCalendar(date.calendar, calendar)) {
|
||||
return date;
|
||||
}
|
||||
|
||||
let calendarDate = calendar.fromJulianDay(date.calendar.toJulianDay(date));
|
||||
let copy: Mutable<T> = date.copy();
|
||||
copy.calendar = calendar;
|
||||
copy.era = calendarDate.era;
|
||||
copy.year = calendarDate.year;
|
||||
copy.month = calendarDate.month;
|
||||
copy.day = calendarDate.day;
|
||||
constrain(copy);
|
||||
return copy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a date value to a `ZonedDateTime` in the provided time zone. The `disambiguation` option can be set
|
||||
* to control how values that fall on daylight saving time changes are interpreted.
|
||||
*/
|
||||
export function toZoned(date: CalendarDate | CalendarDateTime | ZonedDateTime, timeZone: string, disambiguation?: Disambiguation): ZonedDateTime {
|
||||
if (date instanceof ZonedDateTime) {
|
||||
if (date.timeZone === timeZone) {
|
||||
return date;
|
||||
}
|
||||
|
||||
return toTimeZone(date, timeZone);
|
||||
}
|
||||
|
||||
let ms = toAbsolute(date, timeZone, disambiguation);
|
||||
return fromAbsolute(ms, timeZone);
|
||||
}
|
||||
|
||||
export function zonedToDate(date: ZonedDateTime): Date {
|
||||
let ms = epochFromDate(date) - date.offset;
|
||||
return new Date(ms);
|
||||
}
|
||||
|
||||
/** Converts a `ZonedDateTime` from one time zone to another. */
|
||||
export function toTimeZone(date: ZonedDateTime, timeZone: string): ZonedDateTime {
|
||||
let ms = epochFromDate(date) - date.offset;
|
||||
return toCalendar(fromAbsolute(ms, timeZone), date.calendar);
|
||||
}
|
||||
|
||||
/** Converts the given `ZonedDateTime` into the user's local time zone. */
|
||||
export function toLocalTimeZone(date: ZonedDateTime): ZonedDateTime {
|
||||
return toTimeZone(date, getLocalTimeZone());
|
||||
}
|
||||
@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Copyright 2020 Adobe. All rights reserved.
|
||||
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License. You may obtain a copy
|
||||
* of the License at http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software distributed under
|
||||
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
|
||||
* OF ANY KIND, either express or implied. See the License for the specific language
|
||||
* governing permissions and limitations under the License.
|
||||
*/
|
||||
|
||||
import {BuddhistCalendar} from './calendars/BuddhistCalendar';
|
||||
import type {Calendar, CalendarIdentifier} from './types';
|
||||
import {CopticCalendar, EthiopicAmeteAlemCalendar, EthiopicCalendar} from './calendars/EthiopicCalendar';
|
||||
import {GregorianCalendar} from './calendars/GregorianCalendar';
|
||||
import {HebrewCalendar} from './calendars/HebrewCalendar';
|
||||
import {IndianCalendar} from './calendars/IndianCalendar';
|
||||
import {IslamicCivilCalendar, IslamicTabularCalendar, IslamicUmalquraCalendar} from './calendars/IslamicCalendar';
|
||||
import {JapaneseCalendar} from './calendars/JapaneseCalendar';
|
||||
import {PersianCalendar} from './calendars/PersianCalendar';
|
||||
import {TaiwanCalendar} from './calendars/TaiwanCalendar';
|
||||
|
||||
/** Creates a `Calendar` instance from a Unicode calendar identifier string. */
|
||||
export function createCalendar(name: CalendarIdentifier): Calendar {
|
||||
switch (name) {
|
||||
case 'buddhist':
|
||||
return new BuddhistCalendar();
|
||||
case 'ethiopic':
|
||||
return new EthiopicCalendar();
|
||||
case 'ethioaa':
|
||||
return new EthiopicAmeteAlemCalendar();
|
||||
case 'coptic':
|
||||
return new CopticCalendar();
|
||||
case 'hebrew':
|
||||
return new HebrewCalendar();
|
||||
case 'indian':
|
||||
return new IndianCalendar();
|
||||
case 'islamic-civil':
|
||||
return new IslamicCivilCalendar();
|
||||
case 'islamic-tbla':
|
||||
return new IslamicTabularCalendar();
|
||||
case 'islamic-umalqura':
|
||||
return new IslamicUmalquraCalendar();
|
||||
case 'japanese':
|
||||
return new JapaneseCalendar();
|
||||
case 'persian':
|
||||
return new PersianCalendar();
|
||||
case 'roc':
|
||||
return new TaiwanCalendar();
|
||||
case 'gregory':
|
||||
default:
|
||||
return new GregorianCalendar();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,98 @@
|
||||
/*
|
||||
* Copyright 2020 Adobe. All rights reserved.
|
||||
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License. You may obtain a copy
|
||||
* of the License at http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software distributed under
|
||||
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
|
||||
* OF ANY KIND, either express or implied. See the License for the specific language
|
||||
* governing permissions and limitations under the License.
|
||||
*/
|
||||
|
||||
export type {
|
||||
AnyCalendarDate,
|
||||
AnyTime,
|
||||
AnyDateTime,
|
||||
Calendar,
|
||||
CalendarIdentifier,
|
||||
DateDuration,
|
||||
TimeDuration,
|
||||
DateTimeDuration,
|
||||
DateFields,
|
||||
TimeFields,
|
||||
DateField,
|
||||
TimeField,
|
||||
Disambiguation,
|
||||
CycleOptions,
|
||||
CycleTimeOptions
|
||||
} from './types';
|
||||
|
||||
export type { DateValue } from './queries';
|
||||
|
||||
|
||||
|
||||
export {CalendarDate, CalendarDateTime, Time, ZonedDateTime} 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 {
|
||||
toCalendarDate,
|
||||
toCalendarDateTime,
|
||||
toTime,
|
||||
toCalendar,
|
||||
toZoned,
|
||||
toTimeZone,
|
||||
toLocalTimeZone,
|
||||
fromDate,
|
||||
fromDateToLocal,
|
||||
fromAbsolute
|
||||
} from './conversion';
|
||||
export {
|
||||
isSameDay,
|
||||
isSameMonth,
|
||||
isSameYear,
|
||||
isEqualDay,
|
||||
isEqualMonth,
|
||||
isEqualYear,
|
||||
isToday,
|
||||
getDayOfWeek,
|
||||
now,
|
||||
today,
|
||||
getHoursInDay,
|
||||
getLocalTimeZone,
|
||||
setLocalTimeZone,
|
||||
resetLocalTimeZone,
|
||||
startOfMonth,
|
||||
startOfWeek,
|
||||
startOfYear,
|
||||
endOfMonth,
|
||||
endOfWeek,
|
||||
endOfYear,
|
||||
getMinimumMonthInYear,
|
||||
getMinimumDayInMonth,
|
||||
getWeeksInMonth,
|
||||
minDate,
|
||||
maxDate,
|
||||
isWeekend,
|
||||
isWeekday,
|
||||
isEqualCalendar,
|
||||
|
||||
} from './queries';
|
||||
export {
|
||||
parseDate,
|
||||
parseDateTime,
|
||||
parseTime,
|
||||
parseAbsolute,
|
||||
parseAbsoluteToLocal,
|
||||
parseZonedDateTime,
|
||||
parseDuration
|
||||
} from './string';
|
||||
export {DateFormatter} from './DateFormatter';
|
||||
@ -0,0 +1,477 @@
|
||||
/*
|
||||
* Copyright 2020 Adobe. All rights reserved.
|
||||
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License. You may obtain a copy
|
||||
* of the License at http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software distributed under
|
||||
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
|
||||
* OF ANY KIND, either express or implied. See the License for the specific language
|
||||
* governing permissions and limitations under the License.
|
||||
*/
|
||||
|
||||
import type {AnyCalendarDate, AnyDateTime, AnyTime, CycleOptions, CycleTimeOptions, DateDuration, DateField, DateFields, DateTimeDuration, Disambiguation, TimeDuration, TimeField, TimeFields} from './types';
|
||||
import {CalendarDate, CalendarDateTime, Time, ZonedDateTime} from './CalendarDate';
|
||||
import {epochFromDate, fromAbsolute, toAbsolute, toCalendar, toCalendarDateTime} from './conversion';
|
||||
import {GregorianCalendar} from './calendars/GregorianCalendar';
|
||||
import type {Mutable} from './utils';
|
||||
|
||||
const ONE_HOUR = 3600000;
|
||||
|
||||
export function add(date: CalendarDateTime, duration: DateTimeDuration): CalendarDateTime;
|
||||
export function add(date: CalendarDate, duration: DateDuration): CalendarDate;
|
||||
export function add(date: CalendarDate | CalendarDateTime, duration: DateTimeDuration): CalendarDate | CalendarDateTime;
|
||||
export function add(date: CalendarDate | CalendarDateTime, duration: DateTimeDuration): Mutable<AnyCalendarDate | AnyDateTime> {
|
||||
let mutableDate: Mutable<AnyCalendarDate | AnyDateTime> = date.copy();
|
||||
let days = 'hour' in mutableDate ? addTimeFields(mutableDate, duration) : 0;
|
||||
|
||||
addYears(mutableDate, duration.years || 0);
|
||||
if (mutableDate.calendar.balanceYearMonth) {
|
||||
mutableDate.calendar.balanceYearMonth(mutableDate, date);
|
||||
}
|
||||
|
||||
mutableDate.month += duration.months || 0;
|
||||
|
||||
balanceYearMonth(mutableDate);
|
||||
constrainMonthDay(mutableDate);
|
||||
|
||||
mutableDate.day += (duration.weeks || 0) * 7;
|
||||
mutableDate.day += duration.days || 0;
|
||||
mutableDate.day += days;
|
||||
|
||||
balanceDay(mutableDate);
|
||||
|
||||
if (mutableDate.calendar.balanceDate) {
|
||||
mutableDate.calendar.balanceDate(mutableDate);
|
||||
}
|
||||
|
||||
// Constrain in case adding ended up with a date outside the valid range for the calendar system.
|
||||
// The behavior here is slightly different than when constraining in the `set` function in that
|
||||
// we adjust smaller fields to their minimum/maximum values rather than constraining each field
|
||||
// individually. This matches the general behavior of `add` vs `set` regarding how fields are balanced.
|
||||
if (mutableDate.year < 1) {
|
||||
mutableDate.year = 1;
|
||||
mutableDate.month = 1;
|
||||
mutableDate.day = 1;
|
||||
}
|
||||
|
||||
let maxYear = mutableDate.calendar.getYearsInEra(mutableDate);
|
||||
if (mutableDate.year > maxYear) {
|
||||
let isInverseEra = mutableDate.calendar.isInverseEra?.(mutableDate);
|
||||
mutableDate.year = maxYear;
|
||||
mutableDate.month = isInverseEra ? 1 : mutableDate.calendar.getMonthsInYear(mutableDate);
|
||||
mutableDate.day = isInverseEra ? 1 : mutableDate.calendar.getDaysInMonth(mutableDate);
|
||||
}
|
||||
|
||||
if (mutableDate.month < 1) {
|
||||
mutableDate.month = 1;
|
||||
mutableDate.day = 1;
|
||||
}
|
||||
|
||||
let maxMonth = mutableDate.calendar.getMonthsInYear(mutableDate);
|
||||
if (mutableDate.month > maxMonth) {
|
||||
mutableDate.month = maxMonth;
|
||||
mutableDate.day = mutableDate.calendar.getDaysInMonth(mutableDate);
|
||||
}
|
||||
|
||||
mutableDate.day = Math.max(1, Math.min(mutableDate.calendar.getDaysInMonth(mutableDate), mutableDate.day));
|
||||
return mutableDate;
|
||||
}
|
||||
|
||||
function addYears(date: Mutable<AnyCalendarDate>, years: number) {
|
||||
if (date.calendar.isInverseEra?.(date)) {
|
||||
years = -years;
|
||||
}
|
||||
|
||||
date.year += years;
|
||||
}
|
||||
|
||||
function balanceYearMonth(date: Mutable<AnyCalendarDate>) {
|
||||
while (date.month < 1) {
|
||||
addYears(date, -1);
|
||||
date.month += date.calendar.getMonthsInYear(date);
|
||||
}
|
||||
|
||||
let monthsInYear = 0;
|
||||
while (date.month > (monthsInYear = date.calendar.getMonthsInYear(date))) {
|
||||
date.month -= monthsInYear;
|
||||
addYears(date, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function balanceDay(date: Mutable<AnyCalendarDate>) {
|
||||
while (date.day < 1) {
|
||||
date.month--;
|
||||
balanceYearMonth(date);
|
||||
date.day += date.calendar.getDaysInMonth(date);
|
||||
}
|
||||
|
||||
while (date.day > date.calendar.getDaysInMonth(date)) {
|
||||
date.day -= date.calendar.getDaysInMonth(date);
|
||||
date.month++;
|
||||
balanceYearMonth(date);
|
||||
}
|
||||
}
|
||||
|
||||
function constrainMonthDay(date: Mutable<AnyCalendarDate>) {
|
||||
date.month = Math.max(1, Math.min(date.calendar.getMonthsInYear(date), date.month));
|
||||
date.day = Math.max(1, Math.min(date.calendar.getDaysInMonth(date), date.day));
|
||||
}
|
||||
|
||||
export function constrain(date: Mutable<AnyCalendarDate>): void {
|
||||
if (date.calendar.constrainDate) {
|
||||
date.calendar.constrainDate(date);
|
||||
}
|
||||
|
||||
date.year = Math.max(1, Math.min(date.calendar.getYearsInEra(date), date.year));
|
||||
constrainMonthDay(date);
|
||||
}
|
||||
|
||||
export function invertDuration(duration: DateTimeDuration): DateTimeDuration {
|
||||
let inverseDuration: Record<string, number> = {};
|
||||
for (let key in duration) {
|
||||
const k = key as keyof DateTimeDuration;
|
||||
if (typeof duration[k] === 'number') {
|
||||
inverseDuration[key] = -(duration[k] as number);
|
||||
}
|
||||
}
|
||||
|
||||
return inverseDuration as DateTimeDuration;
|
||||
}
|
||||
|
||||
export function subtract(date: CalendarDateTime, duration: DateTimeDuration): CalendarDateTime;
|
||||
export function subtract(date: CalendarDate, duration: DateDuration): CalendarDate;
|
||||
export function subtract(date: CalendarDate | CalendarDateTime, duration: DateTimeDuration): CalendarDate | CalendarDateTime {
|
||||
return add(date, invertDuration(duration));
|
||||
}
|
||||
|
||||
export function set(date: CalendarDateTime, fields: DateFields): CalendarDateTime;
|
||||
export function set(date: CalendarDate, fields: DateFields): CalendarDate;
|
||||
export function set(date: CalendarDate | CalendarDateTime, fields: DateFields): Mutable<AnyCalendarDate> {
|
||||
let mutableDate: Mutable<AnyCalendarDate> = date.copy();
|
||||
|
||||
if (fields.era != null) {
|
||||
mutableDate.era = fields.era;
|
||||
}
|
||||
|
||||
if (fields.year != null) {
|
||||
mutableDate.year = fields.year;
|
||||
}
|
||||
|
||||
if (fields.month != null) {
|
||||
mutableDate.month = fields.month;
|
||||
}
|
||||
|
||||
if (fields.day != null) {
|
||||
mutableDate.day = fields.day;
|
||||
}
|
||||
|
||||
constrain(mutableDate);
|
||||
return mutableDate;
|
||||
}
|
||||
|
||||
export function setTime(value: CalendarDateTime, fields: TimeFields): CalendarDateTime;
|
||||
export function setTime(value: Time, fields: TimeFields): Time;
|
||||
export function setTime(value: Time | CalendarDateTime, fields: TimeFields): Mutable<Time | CalendarDateTime> {
|
||||
let mutableValue: Mutable<Time | CalendarDateTime> = value.copy();
|
||||
|
||||
if (fields.hour != null) {
|
||||
mutableValue.hour = fields.hour;
|
||||
}
|
||||
|
||||
if (fields.minute != null) {
|
||||
mutableValue.minute = fields.minute;
|
||||
}
|
||||
|
||||
if (fields.second != null) {
|
||||
mutableValue.second = fields.second;
|
||||
}
|
||||
|
||||
if (fields.millisecond != null) {
|
||||
mutableValue.millisecond = fields.millisecond;
|
||||
}
|
||||
|
||||
constrainTime(mutableValue);
|
||||
return mutableValue;
|
||||
}
|
||||
|
||||
function balanceTime(time: Mutable<AnyTime>): number {
|
||||
time.second += Math.floor(time.millisecond / 1000);
|
||||
time.millisecond = nonNegativeMod(time.millisecond, 1000);
|
||||
|
||||
time.minute += Math.floor(time.second / 60);
|
||||
time.second = nonNegativeMod(time.second, 60);
|
||||
|
||||
time.hour += Math.floor(time.minute / 60);
|
||||
time.minute = nonNegativeMod(time.minute, 60);
|
||||
|
||||
let days = Math.floor(time.hour / 24);
|
||||
time.hour = nonNegativeMod(time.hour, 24);
|
||||
|
||||
return days;
|
||||
}
|
||||
|
||||
export function constrainTime(time: Mutable<AnyTime>): void {
|
||||
time.millisecond = Math.max(0, Math.min(time.millisecond, 1000));
|
||||
time.second = Math.max(0, Math.min(time.second, 59));
|
||||
time.minute = Math.max(0, Math.min(time.minute, 59));
|
||||
time.hour = Math.max(0, Math.min(time.hour, 23));
|
||||
}
|
||||
|
||||
function nonNegativeMod(a: number, b: number) {
|
||||
let result = a % b;
|
||||
if (result < 0) {
|
||||
result += b;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function addTimeFields(time: Mutable<AnyTime>, duration: TimeDuration): number {
|
||||
time.hour += duration.hours || 0;
|
||||
time.minute += duration.minutes || 0;
|
||||
time.second += duration.seconds || 0;
|
||||
time.millisecond += duration.milliseconds || 0;
|
||||
return balanceTime(time);
|
||||
}
|
||||
|
||||
export function addTime(time: Time, duration: TimeDuration): Time {
|
||||
let res = time.copy();
|
||||
addTimeFields(res, duration);
|
||||
return res;
|
||||
}
|
||||
|
||||
export function subtractTime(time: Time, duration: TimeDuration): Time {
|
||||
return addTime(time, invertDuration(duration));
|
||||
}
|
||||
|
||||
export function cycleDate(value: CalendarDateTime, field: DateField, amount: number, options?: CycleOptions): CalendarDateTime;
|
||||
export function cycleDate(value: CalendarDate, field: DateField, amount: number, options?: CycleOptions): CalendarDate;
|
||||
export function cycleDate(value: CalendarDate | CalendarDateTime, field: DateField, amount: number, options?: CycleOptions): Mutable<CalendarDate | CalendarDateTime> {
|
||||
let mutable: Mutable<CalendarDate | CalendarDateTime> = value.copy();
|
||||
|
||||
switch (field) {
|
||||
case 'era': {
|
||||
let eras = value.calendar.getEras();
|
||||
let eraIndex = eras.indexOf(value.era);
|
||||
if (eraIndex < 0) {
|
||||
throw new Error('Invalid era: ' + value.era);
|
||||
}
|
||||
eraIndex = cycleValue(eraIndex, amount, 0, eras.length - 1, options?.round);
|
||||
mutable.era = eras[eraIndex];
|
||||
|
||||
// Constrain the year and other fields within the era, so the era doesn't change when we balance below.
|
||||
constrain(mutable);
|
||||
break;
|
||||
}
|
||||
case 'year': {
|
||||
if (mutable.calendar.isInverseEra?.(mutable)) {
|
||||
amount = -amount;
|
||||
}
|
||||
|
||||
// The year field should not cycle within the era as that can cause weird behavior affecting other fields.
|
||||
// We need to also allow values < 1 so that decrementing goes to the previous era. If we get -Infinity back
|
||||
// we know we wrapped around after reaching 9999 (the maximum), so set the year back to 1.
|
||||
mutable.year = cycleValue(value.year, amount, -Infinity, 9999, options?.round);
|
||||
if (mutable.year === -Infinity) {
|
||||
mutable.year = 1;
|
||||
}
|
||||
|
||||
if (mutable.calendar.balanceYearMonth) {
|
||||
mutable.calendar.balanceYearMonth(mutable, value);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'month':
|
||||
mutable.month = cycleValue(value.month, amount, 1, value.calendar.getMonthsInYear(value), options?.round);
|
||||
break;
|
||||
case 'day':
|
||||
mutable.day = cycleValue(value.day, amount, 1, value.calendar.getDaysInMonth(value), options?.round);
|
||||
break;
|
||||
default:
|
||||
throw new Error('Unsupported field ' + field);
|
||||
}
|
||||
|
||||
if (value.calendar.balanceDate) {
|
||||
value.calendar.balanceDate(mutable);
|
||||
}
|
||||
|
||||
constrain(mutable);
|
||||
return mutable;
|
||||
}
|
||||
|
||||
export function cycleTime(value: CalendarDateTime, field: TimeField, amount: number, options?: CycleTimeOptions): CalendarDateTime;
|
||||
export function cycleTime(value: Time, field: TimeField, amount: number, options?: CycleTimeOptions): Time;
|
||||
export function cycleTime(value: Time | CalendarDateTime, field: TimeField, amount: number, options?: CycleTimeOptions): Mutable<Time | CalendarDateTime> {
|
||||
let mutable: Mutable<Time | CalendarDateTime> = value.copy();
|
||||
|
||||
switch (field) {
|
||||
case 'hour': {
|
||||
let hours = value.hour;
|
||||
let min = 0;
|
||||
let max = 23;
|
||||
if (options?.hourCycle === 12) {
|
||||
let isPM = hours >= 12;
|
||||
min = isPM ? 12 : 0;
|
||||
max = isPM ? 23 : 11;
|
||||
}
|
||||
mutable.hour = cycleValue(hours, amount, min, max, options?.round);
|
||||
break;
|
||||
}
|
||||
case 'minute':
|
||||
mutable.minute = cycleValue(value.minute, amount, 0, 59, options?.round);
|
||||
break;
|
||||
case 'second':
|
||||
mutable.second = cycleValue(value.second, amount, 0, 59, options?.round);
|
||||
break;
|
||||
case 'millisecond':
|
||||
mutable.millisecond = cycleValue(value.millisecond, amount, 0, 999, options?.round);
|
||||
break;
|
||||
default:
|
||||
throw new Error('Unsupported field ' + field);
|
||||
}
|
||||
|
||||
return mutable;
|
||||
}
|
||||
|
||||
function cycleValue(value: number, amount: number, min: number, max: number, round = false) {
|
||||
if (round) {
|
||||
value += Math.sign(amount);
|
||||
|
||||
if (value < min) {
|
||||
value = max;
|
||||
}
|
||||
|
||||
let div = Math.abs(amount);
|
||||
if (amount > 0) {
|
||||
value = Math.ceil(value / div) * div;
|
||||
} else {
|
||||
value = Math.floor(value / div) * div;
|
||||
}
|
||||
|
||||
if (value > max) {
|
||||
value = min;
|
||||
}
|
||||
} else {
|
||||
value += amount;
|
||||
if (value < min) {
|
||||
value = max - (min - value - 1);
|
||||
} else if (value > max) {
|
||||
value = min + (value - max - 1);
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
export function addZoned(dateTime: ZonedDateTime, duration: DateTimeDuration): ZonedDateTime {
|
||||
let ms: number;
|
||||
if ((duration.years != null && duration.years !== 0) || (duration.months != null && duration.months !== 0) || (duration.weeks != null && duration.weeks !== 0) || (duration.days != null && duration.days !== 0)) {
|
||||
let res = add(toCalendarDateTime(dateTime), {
|
||||
years: duration.years,
|
||||
months: duration.months,
|
||||
weeks: duration.weeks,
|
||||
days: duration.days
|
||||
});
|
||||
|
||||
// Changing the date may change the timezone offset, so we need to recompute
|
||||
// using the 'compatible' disambiguation.
|
||||
ms = toAbsolute(res, dateTime.timeZone);
|
||||
} else {
|
||||
// Otherwise, preserve the offset of the original date.
|
||||
ms = epochFromDate(dateTime) - dateTime.offset;
|
||||
}
|
||||
|
||||
// Perform time manipulation in milliseconds rather than on the original time fields to account for DST.
|
||||
// For example, adding one hour during a DST transition may result in the hour field staying the same or
|
||||
// skipping an hour. This results in the offset field changing value instead of the specified field.
|
||||
ms += duration.milliseconds || 0;
|
||||
ms += (duration.seconds || 0) * 1000;
|
||||
ms += (duration.minutes || 0) * 60 * 1000;
|
||||
ms += (duration.hours || 0) * 60 * 60 * 1000;
|
||||
|
||||
let res = fromAbsolute(ms, dateTime.timeZone);
|
||||
return toCalendar(res, dateTime.calendar);
|
||||
}
|
||||
|
||||
export function subtractZoned(dateTime: ZonedDateTime, duration: DateTimeDuration): ZonedDateTime {
|
||||
return addZoned(dateTime, invertDuration(duration));
|
||||
}
|
||||
|
||||
export function cycleZoned(dateTime: ZonedDateTime, field: DateField | TimeField, amount: number, options?: CycleTimeOptions): ZonedDateTime {
|
||||
// For date fields, we want the time to remain consistent and the UTC offset to potentially change to account for DST changes.
|
||||
// For time fields, we want the time to change by the amount given. This may result in the hour field staying the same, but the UTC
|
||||
// offset changing in the case of a backward DST transition, or skipping an hour in the case of a forward DST transition.
|
||||
switch (field) {
|
||||
case 'hour': {
|
||||
let min = 0;
|
||||
let max = 23;
|
||||
if (options?.hourCycle === 12) {
|
||||
let isPM = dateTime.hour >= 12;
|
||||
min = isPM ? 12 : 0;
|
||||
max = isPM ? 23 : 11;
|
||||
}
|
||||
|
||||
// The minimum and maximum hour may be affected by daylight saving time.
|
||||
// For example, it might jump forward at midnight, and skip 1am.
|
||||
// Or it might end at midnight and repeat the 11pm hour. To handle this, we get
|
||||
// the possible absolute times for the min and max, and find the maximum range
|
||||
// that is within the current day.
|
||||
let plainDateTime = toCalendarDateTime(dateTime);
|
||||
let minDate = toCalendar(setTime(plainDateTime, {hour: min}), new GregorianCalendar());
|
||||
let minAbsolute = [toAbsolute(minDate, dateTime.timeZone, 'earlier'), toAbsolute(minDate, dateTime.timeZone, 'later')]
|
||||
.filter(ms => fromAbsolute(ms, dateTime.timeZone).day === minDate.day)[0];
|
||||
|
||||
let maxDate = toCalendar(setTime(plainDateTime, {hour: max}), new GregorianCalendar());
|
||||
let maxAbsolute = [toAbsolute(maxDate, dateTime.timeZone, 'earlier'), toAbsolute(maxDate, dateTime.timeZone, 'later')]
|
||||
.filter(ms => fromAbsolute(ms, dateTime.timeZone).day === maxDate.day).pop()!;
|
||||
|
||||
// Since hours may repeat, we need to operate on the absolute time in milliseconds.
|
||||
// This is done in hours from the Unix epoch so that cycleValue works correctly,
|
||||
// and then converted back to milliseconds.
|
||||
let ms = epochFromDate(dateTime) - dateTime.offset;
|
||||
let hours = Math.floor(ms / ONE_HOUR);
|
||||
let remainder = ms % ONE_HOUR;
|
||||
ms = cycleValue(
|
||||
hours,
|
||||
amount,
|
||||
Math.floor(minAbsolute / ONE_HOUR),
|
||||
Math.floor(maxAbsolute / ONE_HOUR),
|
||||
options?.round
|
||||
) * ONE_HOUR + remainder;
|
||||
|
||||
// Now compute the new timezone offset, and convert the absolute time back to local time.
|
||||
return toCalendar(fromAbsolute(ms, dateTime.timeZone), dateTime.calendar);
|
||||
}
|
||||
case 'minute':
|
||||
case 'second':
|
||||
case 'millisecond':
|
||||
// @ts-ignore
|
||||
return cycleTime(dateTime, field, amount, options);
|
||||
case 'era':
|
||||
case 'year':
|
||||
case 'month':
|
||||
case 'day': {
|
||||
let res = cycleDate(toCalendarDateTime(dateTime), field, amount, options);
|
||||
let ms = toAbsolute(res, dateTime.timeZone);
|
||||
return toCalendar(fromAbsolute(ms, dateTime.timeZone), dateTime.calendar);
|
||||
}
|
||||
default:
|
||||
throw new Error('Unsupported field ' + field);
|
||||
}
|
||||
}
|
||||
|
||||
export function setZoned(dateTime: ZonedDateTime, fields: DateFields & TimeFields, disambiguation?: Disambiguation): ZonedDateTime {
|
||||
// Set the date/time fields, and recompute the UTC offset to account for DST changes.
|
||||
// We also need to validate by converting back to a local time in case hours are skipped during forward DST transitions.
|
||||
let plainDateTime = toCalendarDateTime(dateTime);
|
||||
let res = setTime(set(plainDateTime, fields), fields);
|
||||
|
||||
// If the resulting plain date time values are equal, return the original time.
|
||||
// We don't want to change the offset when setting the time to the same value.
|
||||
if (res.compare(plainDateTime) === 0) {
|
||||
return dateTime;
|
||||
}
|
||||
|
||||
let ms = toAbsolute(res, dateTime.timeZone, disambiguation);
|
||||
return toCalendar(fromAbsolute(ms, dateTime.timeZone), dateTime.calendar);
|
||||
}
|
||||
@ -0,0 +1,373 @@
|
||||
/*
|
||||
* Copyright 2020 Adobe. All rights reserved.
|
||||
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License. You may obtain a copy
|
||||
* of the License at http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software distributed under
|
||||
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
|
||||
* OF ANY KIND, either express or implied. See the License for the specific language
|
||||
* governing permissions and limitations under the License.
|
||||
*/
|
||||
|
||||
import type {AnyCalendarDate, AnyTime, Calendar} from './types';
|
||||
import {CalendarDate, CalendarDateTime, ZonedDateTime} from './CalendarDate';
|
||||
import {fromAbsolute, toAbsolute, toCalendar, toCalendarDate} from './conversion';
|
||||
import {weekStartData} from './weekStartData';
|
||||
|
||||
export type DateValue = CalendarDate | CalendarDateTime | ZonedDateTime;
|
||||
|
||||
/** Returns whether the given dates occur on the same day, regardless of the time or calendar system. */
|
||||
export function isSameDay(a: DateValue, b: DateValue): boolean {
|
||||
b = toCalendar(b, a.calendar);
|
||||
return a.era === b.era && a.year === b.year && a.month === b.month && a.day === b.day;
|
||||
}
|
||||
|
||||
/** Returns whether the given dates occur in the same month, using the calendar system of the first date. */
|
||||
export function isSameMonth(a: DateValue, b: DateValue): boolean {
|
||||
b = toCalendar(b, a.calendar);
|
||||
// In the Japanese calendar, months can span multiple eras/years, so only compare the first of the month.
|
||||
a = startOfMonth(a);
|
||||
b = startOfMonth(b);
|
||||
return a.era === b.era && a.year === b.year && a.month === b.month;
|
||||
}
|
||||
|
||||
/** Returns whether the given dates occur in the same year, using the calendar system of the first date. */
|
||||
export function isSameYear(a: DateValue, b: DateValue): boolean {
|
||||
b = toCalendar(b, a.calendar);
|
||||
a = startOfYear(a);
|
||||
b = startOfYear(b);
|
||||
return a.era === b.era && a.year === b.year;
|
||||
}
|
||||
|
||||
/** Returns whether the given dates occur on the same day, and are of the same calendar system. */
|
||||
export function isEqualDay(a: DateValue, b: DateValue): boolean {
|
||||
return isEqualCalendar(a.calendar, b.calendar) && isSameDay(a, b);
|
||||
}
|
||||
|
||||
/** Returns whether the given dates occur in the same month, and are of the same calendar system. */
|
||||
export function isEqualMonth(a: DateValue, b: DateValue): boolean {
|
||||
return isEqualCalendar(a.calendar, b.calendar) && isSameMonth(a, b);
|
||||
}
|
||||
|
||||
/** Returns whether the given dates occur in the same year, and are of the same calendar system. */
|
||||
export function isEqualYear(a: DateValue, b: DateValue): boolean {
|
||||
return isEqualCalendar(a.calendar, b.calendar) && isSameYear(a, b);
|
||||
}
|
||||
|
||||
/** Returns whether two calendars are the same. */
|
||||
export function isEqualCalendar(a: Calendar, b: Calendar): boolean {
|
||||
return a.isEqual?.(b) ?? b.isEqual?.(a) ?? a.identifier === b.identifier;
|
||||
}
|
||||
|
||||
/** Returns whether the date is today in the given time zone. */
|
||||
export function isToday(date: DateValue, timeZone: string): boolean {
|
||||
return isSameDay(date, today(timeZone));
|
||||
}
|
||||
|
||||
const DAY_MAP = {
|
||||
sun: 0,
|
||||
mon: 1,
|
||||
tue: 2,
|
||||
wed: 3,
|
||||
thu: 4,
|
||||
fri: 5,
|
||||
sat: 6
|
||||
};
|
||||
|
||||
type DayOfWeek = 'sun' | 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat';
|
||||
|
||||
/**
|
||||
* Returns the day of week for the given date and locale. Days are numbered from zero to six,
|
||||
* where zero is the first day of the week in the given locale. For example, in the United States,
|
||||
* the first day of the week is Sunday, but in France it is Monday.
|
||||
*/
|
||||
export function getDayOfWeek(date: DateValue, locale: string, firstDayOfWeek?: DayOfWeek): number {
|
||||
let julian = date.calendar.toJulianDay(date);
|
||||
|
||||
// If julian is negative, then julian % 7 will be negative, so we adjust
|
||||
// accordingly. Julian day 0 is Monday.
|
||||
let weekStart = firstDayOfWeek ? DAY_MAP[firstDayOfWeek] : getWeekStart(locale);
|
||||
let dayOfWeek = Math.ceil(julian + 1 - weekStart) % 7;
|
||||
if (dayOfWeek < 0) {
|
||||
dayOfWeek += 7;
|
||||
}
|
||||
|
||||
return dayOfWeek;
|
||||
}
|
||||
|
||||
/** Returns the current time in the given time zone. */
|
||||
export function now(timeZone: string): ZonedDateTime {
|
||||
return fromAbsolute(Date.now(), timeZone);
|
||||
}
|
||||
|
||||
/** Returns today's date in the given time zone. */
|
||||
export function today(timeZone: string): CalendarDate {
|
||||
return toCalendarDate(now(timeZone));
|
||||
}
|
||||
|
||||
export function compareDate(a: AnyCalendarDate, b: AnyCalendarDate): number {
|
||||
return a.calendar.toJulianDay(a) - b.calendar.toJulianDay(b);
|
||||
}
|
||||
|
||||
export function compareTime(a: AnyTime, b: AnyTime): number {
|
||||
return timeToMs(a) - timeToMs(b);
|
||||
}
|
||||
|
||||
function timeToMs(a: AnyTime): number {
|
||||
return a.hour * 60 * 60 * 1000 + a.minute * 60 * 1000 + a.second * 1000 + a.millisecond;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the number of hours in the given date and time zone.
|
||||
* Usually this is 24, but it could be 23 or 25 if the date is on a daylight saving transition.
|
||||
*/
|
||||
export function getHoursInDay(a: CalendarDate, timeZone: string): number {
|
||||
let ms = toAbsolute(a, timeZone);
|
||||
let tomorrow = a.add({days: 1});
|
||||
let tomorrowMs = toAbsolute(tomorrow, timeZone);
|
||||
return (tomorrowMs - ms) / 3600000;
|
||||
}
|
||||
|
||||
let localTimeZone: string | null = null;
|
||||
let localTimeZoneOverride = false;
|
||||
|
||||
/** Returns the time zone identifier for the current user. */
|
||||
export function getLocalTimeZone(): string {
|
||||
if (localTimeZone == null) {
|
||||
localTimeZone = new Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
}
|
||||
|
||||
return localTimeZone!;
|
||||
}
|
||||
|
||||
/** Sets the time zone identifier for the current user. */
|
||||
export function setLocalTimeZone(timeZone: string): void {
|
||||
localTimeZoneOverride = true;
|
||||
localTimeZone = timeZone;
|
||||
}
|
||||
|
||||
/** Resets the time zone identifier for the current user. */
|
||||
export function resetLocalTimeZone(): void {
|
||||
localTimeZoneOverride = false;
|
||||
localTimeZone = null;
|
||||
}
|
||||
|
||||
/** Returns whether the local time zone has been explicitly overridden via `setLocalTimeZone`. */
|
||||
export function isLocalTimeZoneOverridden(): boolean {
|
||||
return localTimeZoneOverride;
|
||||
}
|
||||
|
||||
/** Returns the first date of the month for the given date. */
|
||||
export function startOfMonth(date: ZonedDateTime): ZonedDateTime;
|
||||
export function startOfMonth(date: CalendarDateTime): CalendarDateTime;
|
||||
export function startOfMonth(date: CalendarDate): CalendarDate;
|
||||
export function startOfMonth(date: DateValue): DateValue;
|
||||
export function startOfMonth(date: DateValue): DateValue {
|
||||
// Use `subtract` instead of `set` so we don't get constrained in an era.
|
||||
return date.subtract({days: date.day - 1});
|
||||
}
|
||||
|
||||
/** Returns the last date of the month for the given date. */
|
||||
export function endOfMonth(date: ZonedDateTime): ZonedDateTime;
|
||||
export function endOfMonth(date: CalendarDateTime): CalendarDateTime;
|
||||
export function endOfMonth(date: CalendarDate): CalendarDate;
|
||||
export function endOfMonth(date: DateValue): DateValue;
|
||||
export function endOfMonth(date: DateValue): DateValue {
|
||||
return date.add({days: date.calendar.getDaysInMonth(date) - date.day});
|
||||
}
|
||||
|
||||
/** Returns the first day of the year for the given date. */
|
||||
export function startOfYear(date: ZonedDateTime): ZonedDateTime;
|
||||
export function startOfYear(date: CalendarDateTime): CalendarDateTime;
|
||||
export function startOfYear(date: CalendarDate): CalendarDate;
|
||||
export function startOfYear(date: DateValue): DateValue;
|
||||
export function startOfYear(date: DateValue): DateValue {
|
||||
return startOfMonth(date.subtract({months: date.month - 1}));
|
||||
}
|
||||
|
||||
/** Returns the last day of the year for the given date. */
|
||||
export function endOfYear(date: ZonedDateTime): ZonedDateTime;
|
||||
export function endOfYear(date: CalendarDateTime): CalendarDateTime;
|
||||
export function endOfYear(date: CalendarDate): CalendarDate;
|
||||
export function endOfYear(date: DateValue): DateValue;
|
||||
export function endOfYear(date: DateValue): DateValue {
|
||||
return endOfMonth(date.add({months: date.calendar.getMonthsInYear(date) - date.month}));
|
||||
}
|
||||
|
||||
export function getMinimumMonthInYear(date: AnyCalendarDate): number {
|
||||
if (date.calendar.getMinimumMonthInYear) {
|
||||
return date.calendar.getMinimumMonthInYear(date);
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
export function getMinimumDayInMonth(date: AnyCalendarDate): number {
|
||||
if (date.calendar.getMinimumDayInMonth) {
|
||||
return date.calendar.getMinimumDayInMonth(date);
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
/** Returns the first date of the week for the given date and locale. */
|
||||
export function startOfWeek(date: ZonedDateTime, locale: string, firstDayOfWeek?: DayOfWeek): ZonedDateTime;
|
||||
export function startOfWeek(date: CalendarDateTime, locale: string, firstDayOfWeek?: DayOfWeek): CalendarDateTime;
|
||||
export function startOfWeek(date: CalendarDate, locale: string, firstDayOfWeek?: DayOfWeek): CalendarDate;
|
||||
export function startOfWeek(date: DateValue, locale: string, firstDayOfWeek?: DayOfWeek): DateValue;
|
||||
export function startOfWeek(date: DateValue, locale: string, firstDayOfWeek?: DayOfWeek): DateValue {
|
||||
let dayOfWeek = getDayOfWeek(date, locale, firstDayOfWeek);
|
||||
return date.subtract({days: dayOfWeek});
|
||||
}
|
||||
|
||||
/** Returns the last date of the week for the given date and locale. */
|
||||
export function endOfWeek(date: ZonedDateTime, locale: string, firstDayOfWeek?: DayOfWeek): ZonedDateTime;
|
||||
export function endOfWeek(date: CalendarDateTime, locale: string, firstDayOfWeek?: DayOfWeek): CalendarDateTime;
|
||||
export function endOfWeek(date: CalendarDate, locale: string, firstDayOfWeek?: DayOfWeek): CalendarDate;
|
||||
export function endOfWeek(date: DateValue, locale: string, firstDayOfWeek?: DayOfWeek): DateValue;
|
||||
export function endOfWeek(date: DateValue, locale: string, firstDayOfWeek?: DayOfWeek): DateValue {
|
||||
return startOfWeek(date, locale, firstDayOfWeek).add({days: 6});
|
||||
}
|
||||
|
||||
const cachedRegions = new Map<string, string>();
|
||||
const cachedWeekInfo = new Map<string, {firstDay: number}>();
|
||||
|
||||
function getRegion(locale: string): string | undefined {
|
||||
// If the Intl.Locale API is available, use it to get the region for the locale.
|
||||
// @ts-ignore
|
||||
if (Intl.Locale) {
|
||||
// Constructing an Intl.Locale is expensive, so cache the result.
|
||||
let region = cachedRegions.get(locale);
|
||||
if (!region) {
|
||||
// @ts-ignore
|
||||
region = new Intl.Locale(locale).maximize().region;
|
||||
if (region) {
|
||||
cachedRegions.set(locale, region);
|
||||
}
|
||||
}
|
||||
return region;
|
||||
}
|
||||
|
||||
// If not, just try splitting the string.
|
||||
// If the second part of the locale string is 'u',
|
||||
// then this is a unicode extension, so ignore it.
|
||||
// Otherwise, it should be the region.
|
||||
let part = locale.split('-')[1];
|
||||
return part === 'u' ? undefined : part;
|
||||
}
|
||||
|
||||
function getWeekStart(locale: string): number {
|
||||
// TODO: use Intl.Locale for this once browsers support the weekInfo property
|
||||
// https://github.com/tc39/proposal-intl-locale-info
|
||||
let weekInfo = cachedWeekInfo.get(locale);
|
||||
if (!weekInfo) {
|
||||
if (Intl.Locale) {
|
||||
// @ts-ignore
|
||||
let localeInst = new Intl.Locale(locale);
|
||||
if ('getWeekInfo' in localeInst) {
|
||||
// @ts-expect-error
|
||||
weekInfo = localeInst.getWeekInfo();
|
||||
if (weekInfo) {
|
||||
cachedWeekInfo.set(locale, weekInfo);
|
||||
return weekInfo.firstDay;
|
||||
}
|
||||
}
|
||||
}
|
||||
let region = getRegion(locale);
|
||||
if (locale.includes('-fw-')) {
|
||||
// pull the value for the attribute fw from strings such as en-US-u-ca-iso8601-fw-tue or en-US-u-ca-iso8601-fw-mon-nu-thai
|
||||
// where the fw attribute could be followed by another unicode locale extension or not
|
||||
let day = locale.split('-fw-')[1].split('-')[0];
|
||||
if (day === 'mon') {
|
||||
weekInfo = {firstDay: 1};
|
||||
} else if (day === 'tue') {
|
||||
weekInfo = {firstDay: 2};
|
||||
} else if (day === 'wed') {
|
||||
weekInfo = {firstDay: 3};
|
||||
} else if (day === 'thu') {
|
||||
weekInfo = {firstDay: 4};
|
||||
} else if (day === 'fri') {
|
||||
weekInfo = {firstDay: 5};
|
||||
} else if (day === 'sat') {
|
||||
weekInfo = {firstDay: 6};
|
||||
} else {
|
||||
weekInfo = {firstDay: 0};
|
||||
}
|
||||
} else if (locale.includes('-ca-iso8601')) {
|
||||
weekInfo = {firstDay: 1};
|
||||
} else {
|
||||
weekInfo = {firstDay: region ? weekStartData[region as keyof typeof weekStartData] || 0 : 0};
|
||||
}
|
||||
cachedWeekInfo.set(locale, weekInfo);
|
||||
}
|
||||
|
||||
return weekInfo.firstDay;
|
||||
}
|
||||
|
||||
/** Returns the number of weeks in the given month and locale. */
|
||||
export function getWeeksInMonth(date: DateValue, locale: string, firstDayOfWeek?: DayOfWeek): number {
|
||||
let days = date.calendar.getDaysInMonth(date);
|
||||
return Math.ceil((getDayOfWeek(startOfMonth(date), locale, firstDayOfWeek) + days) / 7);
|
||||
}
|
||||
|
||||
/** Returns the lesser of the two provider dates. */
|
||||
export function minDate<A extends DateValue, B extends DateValue>(a?: A | null, b?: B | null): A | B | null | undefined {
|
||||
if (a && b) {
|
||||
return a.compare(b) <= 0 ? a : b;
|
||||
}
|
||||
|
||||
return a || b;
|
||||
}
|
||||
|
||||
/** Returns the greater of the two provider dates. */
|
||||
export function maxDate<A extends DateValue, B extends DateValue>(a?: A | null, b?: B | null): A | B | null | undefined {
|
||||
if (a && b) {
|
||||
return a.compare(b) >= 0 ? a : b;
|
||||
}
|
||||
|
||||
return a || b;
|
||||
}
|
||||
|
||||
const WEEKEND_DATA = {
|
||||
AF: [4, 5],
|
||||
AE: [5, 6],
|
||||
BH: [5, 6],
|
||||
DZ: [5, 6],
|
||||
EG: [5, 6],
|
||||
IL: [5, 6],
|
||||
IQ: [5, 6],
|
||||
IR: [5, 5],
|
||||
JO: [5, 6],
|
||||
KW: [5, 6],
|
||||
LY: [5, 6],
|
||||
OM: [5, 6],
|
||||
QA: [5, 6],
|
||||
SA: [5, 6],
|
||||
SD: [5, 6],
|
||||
SY: [5, 6],
|
||||
YE: [5, 6]
|
||||
};
|
||||
|
||||
/** Returns whether the given date is on a weekend in the given locale. */
|
||||
export function isWeekend(date: DateValue, locale: string): boolean {
|
||||
let julian = date.calendar.toJulianDay(date);
|
||||
|
||||
// If julian is negative, then julian % 7 will be negative, so we adjust
|
||||
// accordingly. Julian day 0 is Monday.
|
||||
let dayOfWeek = Math.ceil(julian + 1) % 7;
|
||||
if (dayOfWeek < 0) {
|
||||
dayOfWeek += 7;
|
||||
}
|
||||
|
||||
let region = getRegion(locale);
|
||||
// Use Intl.Locale for this once weekInfo is supported.
|
||||
// https://github.com/tc39/proposal-intl-locale-info
|
||||
let [start, end] = (region ? WEEKEND_DATA[region as keyof typeof WEEKEND_DATA] : undefined) || [6, 0];
|
||||
return dayOfWeek === start || dayOfWeek === end;
|
||||
}
|
||||
|
||||
/** Returns whether the given date is on a weekday in the given locale. */
|
||||
export function isWeekday(date: DateValue, locale: string): boolean {
|
||||
return !isWeekend(date, locale);
|
||||
}
|
||||
@ -0,0 +1,299 @@
|
||||
/*
|
||||
* Copyright 2020 Adobe. All rights reserved.
|
||||
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License. You may obtain a copy
|
||||
* of the License at http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software distributed under
|
||||
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
|
||||
* OF ANY KIND, either express or implied. See the License for the specific language
|
||||
* governing permissions and limitations under the License.
|
||||
*/
|
||||
|
||||
import type {AnyDateTime, DateTimeDuration, Disambiguation} from './types';
|
||||
import {CalendarDate, CalendarDateTime, Time, ZonedDateTime} from './CalendarDate';
|
||||
import {epochFromDate, fromAbsolute, possibleAbsolutes, toAbsolute, toCalendar, toCalendarDateTime, toTimeZone} from './conversion';
|
||||
import {getLocalTimeZone} from './queries';
|
||||
import {GregorianCalendar} from './calendars/GregorianCalendar';
|
||||
import type {Mutable} from './utils';
|
||||
|
||||
const TIME_RE = /^(\d{2})(?::(\d{2}))?(?::(\d{2}))?(\.\d+)?$/;
|
||||
const DATE_RE = /^([+-]\d{6}|\d{4})-(\d{2})-(\d{2})$/;
|
||||
const DATE_TIME_RE = /^([+-]\d{6}|\d{4})-(\d{2})-(\d{2})(?:T(\d{2}))?(?::(\d{2}))?(?::(\d{2}))?(\.\d+)?$/;
|
||||
const ZONED_DATE_TIME_RE = /^([+-]\d{6}|\d{4})-(\d{2})-(\d{2})(?:T(\d{2}))?(?::(\d{2}))?(?::(\d{2}))?(\.\d+)?(?:([+-]\d{2})(?::?(\d{2}))?(?::?(\d{2}))?)?\[(.*?)\]$/;
|
||||
const ABSOLUTE_RE = /^([+-]\d{6}|\d{4})-(\d{2})-(\d{2})(?:T(\d{2}))?(?::(\d{2}))?(?::(\d{2}))?(\.\d+)?(?:(?:([+-]\d{2})(?::?(\d{2}))?)|Z)$/;
|
||||
const DATE_TIME_DURATION_RE =
|
||||
/^((?<negative>-)|\+)?P((?<years>\d*)Y)?((?<months>\d*)M)?((?<weeks>\d*)W)?((?<days>\d*)D)?((?<time>T)((?<hours>\d*[.,]?\d{1,9})H)?((?<minutes>\d*[.,]?\d{1,9})M)?((?<seconds>\d*[.,]?\d{1,9})S)?)?$/;
|
||||
const requiredDurationTimeGroups = ['hours', 'minutes', 'seconds'];
|
||||
const requiredDurationGroups = ['years', 'months', 'weeks', 'days', ...requiredDurationTimeGroups];
|
||||
|
||||
/** Parses an ISO 8601 time string. */
|
||||
export function parseTime(value: string): Time {
|
||||
let m = value.match(TIME_RE);
|
||||
if (!m) {
|
||||
throw new Error('Invalid ISO 8601 time string: ' + value);
|
||||
}
|
||||
|
||||
return new Time(
|
||||
parseNumber(m[1], 0, 23),
|
||||
m[2] ? parseNumber(m[2], 0, 59) : 0,
|
||||
m[3] ? parseNumber(m[3], 0, 59) : 0,
|
||||
m[4] ? parseNumber(m[4], 0, Infinity) * 1000 : 0
|
||||
);
|
||||
}
|
||||
|
||||
/** Parses an ISO 8601 date string, with no time components. */
|
||||
export function parseDate(value: string): CalendarDate {
|
||||
let m = value.match(DATE_RE);
|
||||
if (!m) {
|
||||
if (ABSOLUTE_RE.test(value)) {
|
||||
throw new Error(`Invalid ISO 8601 date string: ${value}. Use parseAbsolute() instead.`);
|
||||
}
|
||||
throw new Error('Invalid ISO 8601 date string: ' + value);
|
||||
}
|
||||
|
||||
let date: Mutable<CalendarDate> = new CalendarDate(
|
||||
parseNumber(m[1], 0, 9999),
|
||||
parseNumber(m[2], 1, 12),
|
||||
1
|
||||
);
|
||||
|
||||
date.day = parseNumber(m[3], 1, date.calendar.getDaysInMonth(date));
|
||||
return date as CalendarDate;
|
||||
}
|
||||
|
||||
/** Parses an ISO 8601 date and time string, with no time zone. */
|
||||
export function parseDateTime(value: string): CalendarDateTime {
|
||||
let m = value.match(DATE_TIME_RE);
|
||||
if (!m) {
|
||||
if (ABSOLUTE_RE.test(value)) {
|
||||
throw new Error(`Invalid ISO 8601 date time string: ${value}. Use parseAbsolute() instead.`);
|
||||
}
|
||||
throw new Error('Invalid ISO 8601 date time string: ' + value);
|
||||
}
|
||||
|
||||
let year = parseNumber(m[1], -9999, 9999);
|
||||
let era = year < 1 ? 'BC' : 'AD';
|
||||
|
||||
let date: Mutable<CalendarDateTime> = new CalendarDateTime(
|
||||
era,
|
||||
year < 1 ? -year + 1 : year,
|
||||
parseNumber(m[2], 1, 12),
|
||||
1,
|
||||
m[4] ? parseNumber(m[4], 0, 23) : 0,
|
||||
m[5] ? parseNumber(m[5], 0, 59) : 0,
|
||||
m[6] ? parseNumber(m[6], 0, 59) : 0,
|
||||
m[7] ? parseNumber(m[7], 0, Infinity) * 1000 : 0
|
||||
);
|
||||
|
||||
date.day = parseNumber(m[3], 0, date.calendar.getDaysInMonth(date));
|
||||
return date as CalendarDateTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an ISO 8601 date and time string with a time zone extension and optional UTC offset
|
||||
* (e.g. "2021-11-07T00:45[America/Los_Angeles]" or "2021-11-07T00:45-07:00[America/Los_Angeles]").
|
||||
* Ambiguous times due to daylight saving time transitions are resolved according to the `disambiguation`
|
||||
* parameter.
|
||||
*/
|
||||
export function parseZonedDateTime(value: string, disambiguation?: Disambiguation): ZonedDateTime {
|
||||
let m = value.match(ZONED_DATE_TIME_RE);
|
||||
if (!m) {
|
||||
throw new Error('Invalid ISO 8601 date time string: ' + value);
|
||||
}
|
||||
|
||||
let year = parseNumber(m[1], -9999, 9999);
|
||||
let era = year < 1 ? 'BC' : 'AD';
|
||||
|
||||
let date: Mutable<ZonedDateTime> = new ZonedDateTime(
|
||||
era,
|
||||
year < 1 ? -year + 1 : year,
|
||||
parseNumber(m[2], 1, 12),
|
||||
1,
|
||||
m[11],
|
||||
0,
|
||||
m[4] ? parseNumber(m[4], 0, 23) : 0,
|
||||
m[5] ? parseNumber(m[5], 0, 59) : 0,
|
||||
m[6] ? parseNumber(m[6], 0, 59) : 0,
|
||||
m[7] ? parseNumber(m[7], 0, Infinity) * 1000 : 0
|
||||
);
|
||||
|
||||
date.day = parseNumber(m[3], 0, date.calendar.getDaysInMonth(date));
|
||||
|
||||
let plainDateTime = toCalendarDateTime(date as ZonedDateTime);
|
||||
|
||||
let ms: number;
|
||||
if (m[8]) {
|
||||
let hourOffset = parseNumber(m[8], -23, 23);
|
||||
date.offset = Math.sign(hourOffset) * (Math.abs(hourOffset) * 60 * 60 * 1000 + parseNumber(m[9] ?? '0', 0, 59) * 60 * 1000 + parseNumber(m[10] ?? '0', 0, 59) * 1000);
|
||||
ms = epochFromDate(date as ZonedDateTime) - date.offset;
|
||||
|
||||
// Validate offset against parsed date.
|
||||
let absolutes = possibleAbsolutes(plainDateTime, date.timeZone);
|
||||
if (!absolutes.includes(ms)) {
|
||||
throw new Error(`Offset ${offsetToString(date.offset)} is invalid for ${dateTimeToString(date)} in ${date.timeZone}`);
|
||||
}
|
||||
} else {
|
||||
// Convert to absolute and back to fix invalid times due to DST.
|
||||
ms = toAbsolute(toCalendarDateTime(plainDateTime), date.timeZone, disambiguation);
|
||||
}
|
||||
|
||||
return fromAbsolute(ms, date.timeZone);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an ISO 8601 date and time string with a UTC offset (e.g. "2021-11-07T07:45:00Z"
|
||||
* or "2021-11-07T07:45:00-07:00"). The result is converted to the provided time zone.
|
||||
*/
|
||||
export function parseAbsolute(value: string, timeZone: string): ZonedDateTime {
|
||||
let m = value.match(ABSOLUTE_RE);
|
||||
if (!m) {
|
||||
throw new Error('Invalid ISO 8601 date time string: ' + value);
|
||||
}
|
||||
|
||||
let year = parseNumber(m[1], -9999, 9999);
|
||||
let era = year < 1 ? 'BC' : 'AD';
|
||||
|
||||
let date: Mutable<ZonedDateTime> = new ZonedDateTime(
|
||||
era,
|
||||
year < 1 ? -year + 1 : year,
|
||||
parseNumber(m[2], 1, 12),
|
||||
1,
|
||||
timeZone,
|
||||
0,
|
||||
m[4] ? parseNumber(m[4], 0, 23) : 0,
|
||||
m[5] ? parseNumber(m[5], 0, 59) : 0,
|
||||
m[6] ? parseNumber(m[6], 0, 59) : 0,
|
||||
m[7] ? parseNumber(m[7], 0, Infinity) * 1000 : 0
|
||||
);
|
||||
|
||||
date.day = parseNumber(m[3], 0, date.calendar.getDaysInMonth(date));
|
||||
|
||||
if (m[8]) {
|
||||
date.offset = parseNumber(m[8], -23, 23) * 60 * 60 * 1000 + parseNumber(m[9] ?? '0', 0, 59) * 60 * 1000;
|
||||
}
|
||||
|
||||
return toTimeZone(date as ZonedDateTime, timeZone);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an ISO 8601 date and time string with a UTC offset (e.g. "2021-11-07T07:45:00Z"
|
||||
* or "2021-11-07T07:45:00-07:00"). The result is converted to the user's local time zone.
|
||||
*/
|
||||
export function parseAbsoluteToLocal(value: string): ZonedDateTime {
|
||||
return parseAbsolute(value, getLocalTimeZone());
|
||||
}
|
||||
|
||||
function parseNumber(value: string, min: number, max: number) {
|
||||
let val = Number(value);
|
||||
if (val < min || val > max) {
|
||||
throw new RangeError(`Value out of range: ${min} <= ${val} <= ${max}`);
|
||||
}
|
||||
|
||||
return val;
|
||||
}
|
||||
|
||||
export function timeToString(time: Time): string {
|
||||
return `${String(time.hour).padStart(2, '0')}:${String(time.minute).padStart(2, '0')}:${String(time.second).padStart(2, '0')}${time.millisecond ? String(time.millisecond / 1000).slice(1) : ''}`;
|
||||
}
|
||||
|
||||
export function dateToString(date: CalendarDate): string {
|
||||
let gregorianDate = toCalendar(date, new GregorianCalendar());
|
||||
let year: string;
|
||||
if (gregorianDate.era === 'BC') {
|
||||
year = gregorianDate.year === 1
|
||||
? '0000'
|
||||
: '-' + String(Math.abs(1 - gregorianDate.year)).padStart(6, '00');
|
||||
} else {
|
||||
year = String(gregorianDate.year).padStart(4, '0');
|
||||
}
|
||||
return `${year}-${String(gregorianDate.month).padStart(2, '0')}-${String(gregorianDate.day).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function dateTimeToString(date: AnyDateTime): string {
|
||||
// @ts-ignore
|
||||
return `${dateToString(date)}T${timeToString(date)}`;
|
||||
}
|
||||
|
||||
function offsetToString(offset: number) {
|
||||
let sign = Math.sign(offset) < 0 ? '-' : '+';
|
||||
offset = Math.abs(offset);
|
||||
let offsetHours = Math.floor(offset / (60 * 60 * 1000));
|
||||
let offsetMinutes = Math.floor((offset % (60 * 60 * 1000)) / (60 * 1000));
|
||||
let offsetSeconds = Math.floor((offset % (60 * 60 * 1000)) % (60 * 1000) / 1000);
|
||||
let stringOffset = `${sign}${String(offsetHours).padStart(2, '0')}:${String(offsetMinutes).padStart(2, '0')}`;
|
||||
if (offsetSeconds !== 0) {
|
||||
stringOffset += `:${String(offsetSeconds).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
return stringOffset;
|
||||
}
|
||||
|
||||
export function zonedDateTimeToString(date: ZonedDateTime): string {
|
||||
return `${dateTimeToString(date)}${offsetToString(date.offset)}[${date.timeZone}]`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an ISO 8601 duration string (e.g. "P3Y6M6W4DT12H30M5S").
|
||||
* @param value An ISO 8601 duration string.
|
||||
* @returns A DateTimeDuration object.
|
||||
*/
|
||||
export function parseDuration(value: string): Required<DateTimeDuration> {
|
||||
const match = value.match(DATE_TIME_DURATION_RE);
|
||||
|
||||
if (!match) {
|
||||
throw new Error(`Invalid ISO 8601 Duration string: ${value}`);
|
||||
}
|
||||
|
||||
const parseDurationGroup = (
|
||||
group: string | undefined,
|
||||
isNegative: boolean
|
||||
): number => {
|
||||
if (!group) {
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
const sign = isNegative ? -1 : 1;
|
||||
return sign * Number(group.replace(',', '.'));
|
||||
} catch {
|
||||
throw new Error(`Invalid ISO 8601 Duration string: ${value}`);
|
||||
}
|
||||
};
|
||||
|
||||
const isNegative = !!match.groups?.negative;
|
||||
|
||||
const hasRequiredGroups = requiredDurationGroups.some(group => match.groups?.[group]);
|
||||
|
||||
if (!hasRequiredGroups) {
|
||||
throw new Error(`Invalid ISO 8601 Duration string: ${value}`);
|
||||
}
|
||||
|
||||
const durationStringIncludesTime = match.groups?.time;
|
||||
|
||||
if (durationStringIncludesTime) {
|
||||
const hasRequiredDurationTimeGroups = requiredDurationTimeGroups.some(group => match.groups?.[group]);
|
||||
if (!hasRequiredDurationTimeGroups) {
|
||||
throw new Error(`Invalid ISO 8601 Duration string: ${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
const duration: Mutable<DateTimeDuration> = {
|
||||
years: parseDurationGroup(match.groups?.years, isNegative),
|
||||
months: parseDurationGroup(match.groups?.months, isNegative),
|
||||
weeks: parseDurationGroup(match.groups?.weeks, isNegative),
|
||||
days: parseDurationGroup(match.groups?.days, isNegative),
|
||||
hours: parseDurationGroup(match.groups?.hours, isNegative),
|
||||
minutes: parseDurationGroup(match.groups?.minutes, isNegative),
|
||||
seconds: parseDurationGroup(match.groups?.seconds, isNegative)
|
||||
};
|
||||
|
||||
if (duration.hours !== undefined && ((duration.hours % 1) !== 0) && (duration.minutes || duration.seconds)) {
|
||||
throw new Error(`Invalid ISO 8601 Duration string: ${value} - only the smallest unit can be fractional`);
|
||||
}
|
||||
|
||||
if (duration.minutes !== undefined && ((duration.minutes % 1) !== 0) && duration.seconds) {
|
||||
throw new Error(`Invalid ISO 8601 Duration string: ${value} - only the smallest unit can be fractional`);
|
||||
}
|
||||
|
||||
return duration as Required<DateTimeDuration>;
|
||||
}
|
||||
@ -0,0 +1,161 @@
|
||||
/*
|
||||
* Copyright 2020 Adobe. All rights reserved.
|
||||
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License. You may obtain a copy
|
||||
* of the License at http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software distributed under
|
||||
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
|
||||
* OF ANY KIND, either express or implied. See the License for the specific language
|
||||
* governing permissions and limitations under the License.
|
||||
*/
|
||||
|
||||
import {CalendarDate} from './CalendarDate';
|
||||
|
||||
/** An interface that is compatible with any object with date fields. */
|
||||
export interface AnyCalendarDate {
|
||||
readonly calendar: Calendar,
|
||||
readonly era: string,
|
||||
readonly year: number,
|
||||
readonly month: number,
|
||||
readonly day: number,
|
||||
copy(): this
|
||||
}
|
||||
|
||||
/** An interface that is compatible with any object with time fields. */
|
||||
export interface AnyTime {
|
||||
readonly hour: number,
|
||||
readonly minute: number,
|
||||
readonly second: number,
|
||||
readonly millisecond: number,
|
||||
copy(): this
|
||||
}
|
||||
|
||||
/** An interface that is compatible with any object with both date and time fields. */
|
||||
export interface AnyDateTime extends AnyCalendarDate, AnyTime {}
|
||||
|
||||
export type CalendarIdentifier = 'gregory' | 'buddhist' | 'chinese' | 'coptic' | 'dangi' | 'ethioaa' | 'ethiopic' | 'hebrew' | 'indian' | 'islamic' | 'islamic-umalqura' | 'islamic-tbla' | 'islamic-civil' | 'islamic-rgsa' | 'iso8601' | 'japanese' | 'persian' | 'roc';
|
||||
|
||||
/**
|
||||
* The Calendar interface represents a calendar system, including information
|
||||
* about how days, months, years, and eras are organized, and methods to perform
|
||||
* arithmetic on dates.
|
||||
*/
|
||||
export interface Calendar {
|
||||
/**
|
||||
* A string identifier for the calendar, as defined by Unicode CLDR.
|
||||
* See [MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/supportedValuesOf#supported_calendar_types).
|
||||
*/
|
||||
identifier: CalendarIdentifier,
|
||||
|
||||
/** Creates a CalendarDate in this calendar from the given Julian day number. */
|
||||
fromJulianDay(jd: number): CalendarDate,
|
||||
/** Converts a date in this calendar to a Julian day number. */
|
||||
toJulianDay(date: AnyCalendarDate): number,
|
||||
|
||||
/** Returns the number of days in the month of the given date. */
|
||||
getDaysInMonth(date: AnyCalendarDate): number,
|
||||
/** Returns the number of months in the year of the given date. */
|
||||
getMonthsInYear(date: AnyCalendarDate): number,
|
||||
/** Returns the number of years in the era of the given date. */
|
||||
getYearsInEra(date: AnyCalendarDate): number,
|
||||
/** Returns a list of era identifiers for the calendar. */
|
||||
getEras(): string[],
|
||||
|
||||
/**
|
||||
* Returns the minimum month number of the given date's year.
|
||||
* Normally, this is 1, but in some calendars such as the Japanese,
|
||||
* eras may begin in the middle of a year.
|
||||
*/
|
||||
getMinimumMonthInYear?(date: AnyCalendarDate): number,
|
||||
/**
|
||||
* Returns the minimum day number of the given date's month.
|
||||
* Normally, this is 1, but in some calendars such as the Japanese,
|
||||
* eras may begin in the middle of a month.
|
||||
*/
|
||||
getMinimumDayInMonth?(date: AnyCalendarDate): number,
|
||||
/** Returns the maximum months across all years. */
|
||||
getMaximumMonthsInYear(): number,
|
||||
/** Returns the maximum days across all months. */
|
||||
getMaximumDaysInMonth(): number,
|
||||
/**
|
||||
* Returns a date that is the first day of the month for the given date.
|
||||
* This is used to determine the month that the given date falls in, if
|
||||
* the calendar has months that do not align with the standard calendar months
|
||||
* (e.g. fiscal calendars).
|
||||
*/
|
||||
getFormattableMonth?(date: AnyCalendarDate): CalendarDate,
|
||||
|
||||
/** Returns whether the given calendar is the same as this calendar. */
|
||||
isEqual?(calendar: Calendar): boolean,
|
||||
|
||||
/** @private */
|
||||
balanceDate?(date: AnyCalendarDate): void,
|
||||
/** @private */
|
||||
balanceYearMonth?(date: AnyCalendarDate, previousDate: AnyCalendarDate): void,
|
||||
/** @private */
|
||||
constrainDate?(date: AnyCalendarDate): void,
|
||||
/** @private */
|
||||
isInverseEra?(date: AnyCalendarDate): boolean
|
||||
}
|
||||
|
||||
/** Represents an amount of time in calendar-specific units, for use when performing arithmetic. */
|
||||
export interface DateDuration {
|
||||
/** The number of years to add or subtract. */
|
||||
years?: number,
|
||||
/** The number of months to add or subtract. */
|
||||
months?: number,
|
||||
/** The number of weeks to add or subtract. */
|
||||
weeks?: number,
|
||||
/** The number of days to add or subtract. */
|
||||
days?: number
|
||||
}
|
||||
|
||||
/** Represents an amount of time, for use whe performing arithmetic. */
|
||||
export interface TimeDuration {
|
||||
/** The number of hours to add or subtract. */
|
||||
hours?: number,
|
||||
/** The number of minutes to add or subtract. */
|
||||
minutes?: number,
|
||||
/** The number of seconds to add or subtract. */
|
||||
seconds?: number,
|
||||
/** The number of milliseconds to add or subtract. */
|
||||
milliseconds?: number
|
||||
}
|
||||
|
||||
/** Represents an amount of time with both date and time components, for use when performing arithmetic. */
|
||||
export interface DateTimeDuration extends DateDuration, TimeDuration {}
|
||||
|
||||
export interface DateFields {
|
||||
era?: string,
|
||||
year?: number,
|
||||
month?: number,
|
||||
day?: number
|
||||
}
|
||||
|
||||
export interface TimeFields {
|
||||
hour?: number,
|
||||
minute?: number,
|
||||
second?: number,
|
||||
millisecond?: number
|
||||
}
|
||||
|
||||
export type DateField = keyof DateFields;
|
||||
export type TimeField = keyof TimeFields;
|
||||
|
||||
export type Disambiguation = 'compatible' | 'earlier' | 'later' | 'reject';
|
||||
|
||||
export interface CycleOptions {
|
||||
/** Whether to round the field value to the nearest interval of the amount. */
|
||||
round?: boolean
|
||||
}
|
||||
|
||||
export interface CycleTimeOptions extends CycleOptions {
|
||||
/**
|
||||
* Whether to use 12 or 24 hour time. If 12 hour time is chosen, the resulting value
|
||||
* will remain in the same day period as the original value (e.g. if the value is AM,
|
||||
* the resulting value also be AM).
|
||||
* @default 24
|
||||
*/
|
||||
hourCycle?: 12 | 24
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
/*
|
||||
* 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 type Mutable<T> = {
|
||||
-readonly[P in keyof T]: T[P]
|
||||
};
|
||||
|
||||
export function mod(amount: number, numerator: number): number {
|
||||
return amount - numerator * Math.floor(amount / numerator);
|
||||
}
|
||||
@ -0,0 +1,111 @@
|
||||
/*
|
||||
* Copyright 2020 Adobe. All rights reserved.
|
||||
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License. You may obtain a copy
|
||||
* of the License at http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software distributed under
|
||||
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
|
||||
* OF ANY KIND, either express or implied. See the License for the specific language
|
||||
* governing permissions and limitations under the License.
|
||||
*/
|
||||
|
||||
// Data from https://github.com/unicode-cldr/cldr-core/blob/master/supplemental/weekData.json
|
||||
// Locales starting on Sunday have been removed for compression.
|
||||
export const weekStartData = {
|
||||
'001': 1,
|
||||
AD: 1,
|
||||
AE: 6,
|
||||
AF: 6,
|
||||
AI: 1,
|
||||
AL: 1,
|
||||
AM: 1,
|
||||
AN: 1,
|
||||
AR: 1,
|
||||
AT: 1,
|
||||
AU: 1,
|
||||
AX: 1,
|
||||
AZ: 1,
|
||||
BA: 1,
|
||||
BE: 1,
|
||||
BG: 1,
|
||||
BH: 6,
|
||||
BM: 1,
|
||||
BN: 1,
|
||||
BY: 1,
|
||||
CH: 1,
|
||||
CL: 1,
|
||||
CM: 1,
|
||||
CN: 1,
|
||||
CR: 1,
|
||||
CY: 1,
|
||||
CZ: 1,
|
||||
DE: 1,
|
||||
DJ: 6,
|
||||
DK: 1,
|
||||
DZ: 6,
|
||||
EC: 1,
|
||||
EE: 1,
|
||||
EG: 6,
|
||||
ES: 1,
|
||||
FI: 1,
|
||||
FJ: 1,
|
||||
FO: 1,
|
||||
FR: 1,
|
||||
GB: 1,
|
||||
GE: 1,
|
||||
GF: 1,
|
||||
GP: 1,
|
||||
GR: 1,
|
||||
HR: 1,
|
||||
HU: 1,
|
||||
IE: 1,
|
||||
IQ: 6,
|
||||
IR: 6,
|
||||
IS: 1,
|
||||
IT: 1,
|
||||
JO: 6,
|
||||
KG: 1,
|
||||
KW: 6,
|
||||
KZ: 1,
|
||||
LB: 1,
|
||||
LI: 1,
|
||||
LK: 1,
|
||||
LT: 1,
|
||||
LU: 1,
|
||||
LV: 1,
|
||||
LY: 6,
|
||||
MC: 1,
|
||||
MD: 1,
|
||||
ME: 1,
|
||||
MK: 1,
|
||||
MN: 1,
|
||||
MQ: 1,
|
||||
MV: 5,
|
||||
MY: 1,
|
||||
NL: 1,
|
||||
NO: 1,
|
||||
NZ: 1,
|
||||
OM: 6,
|
||||
PL: 1,
|
||||
QA: 6,
|
||||
RE: 1,
|
||||
RO: 1,
|
||||
RS: 1,
|
||||
RU: 1,
|
||||
SD: 6,
|
||||
SE: 1,
|
||||
SI: 1,
|
||||
SK: 1,
|
||||
SM: 1,
|
||||
SY: 6,
|
||||
TJ: 1,
|
||||
TM: 1,
|
||||
TR: 1,
|
||||
UA: 1,
|
||||
UY: 1,
|
||||
UZ: 1,
|
||||
VA: 1,
|
||||
VN: 1,
|
||||
XK: 1
|
||||
};
|
||||
@ -1,2 +1,312 @@
|
||||
@import 'tailwindcss';
|
||||
@plugin '@tailwindcss/typography';
|
||||
@plugin "tailwindcss-animate";
|
||||
|
||||
|
||||
@import url("https://fonts.googleapis.com/css2?family=Source+Code+Pro:ital,wght@0,400;0,500;0,600;0,700;1,400;1,500;1,600;1,700&display=swap");
|
||||
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap");
|
||||
|
||||
|
||||
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@font-face {
|
||||
font-family: "Cal Sans";
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url("/CalSans-SemiBold.woff2") format("woff2");
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Colors */
|
||||
--background: hsl(0 0% 100%);
|
||||
--background-alt: hsl(0 0% 100%);
|
||||
--foreground: hsl(0 0% 9%);
|
||||
--foreground-alt: hsl(0 0% 32%);
|
||||
--muted: hsl(240 5% 96%);
|
||||
--muted-foreground: hsla(0 0% 9% / 0.4);
|
||||
--border: hsl(240 6% 10%);
|
||||
--border-input: hsla(240 6% 10% / 0.17);
|
||||
--border-input-hover: hsla(240 6% 10% / 0.4);
|
||||
--border-card: hsla(240 6% 10% / 0.1);
|
||||
--dark: hsl(240 6% 10%);
|
||||
--dark-10: hsla(240 6% 10% / 0.1);
|
||||
--dark-40: hsla(240 6% 10% / 0.4);
|
||||
--dark-04: hsla(240 6% 10% / 0.04);
|
||||
--accent: hsl(204 94% 94%);
|
||||
--accent-foreground: hsl(204 80% 16%);
|
||||
--destructive: hsl(347 77% 50%);
|
||||
--tertiary: hsl(37.7 92.1% 50.2%);
|
||||
--line: hsl(0 0% 100%);
|
||||
|
||||
/* black */
|
||||
--contrast: hsl(0 0% 0%);
|
||||
|
||||
/* Shadows */
|
||||
--shadow-mini: 0px 1px 0px 1px rgba(0, 0, 0, 0.04);
|
||||
--shadow-mini-inset: 0px 1px 0px 0px rgba(0, 0, 0, 0.04) inset;
|
||||
--shadow-popover: 0px 7px 12px 3px hsla(var(--dark-10));
|
||||
--shadow-kbd: 0px 2px 0px 0px rgba(0, 0, 0, 0.07);
|
||||
--shadow-btn: 0px 1px 0px 1px rgba(0, 0, 0, 0.03);
|
||||
--shadow-card: 0px 2px 0px 1px rgba(0, 0, 0, 0.04);
|
||||
--shadow-date-field-focus: 0px 0px 0px 3px rgba(24, 24, 27, 0.17);
|
||||
}
|
||||
|
||||
.dark {
|
||||
/* Colors */
|
||||
--background: hsl(0 0% 5%);
|
||||
--background-alt: hsl(0 0% 8%);
|
||||
--foreground: hsl(0 0% 95%);
|
||||
--foreground-alt: hsl(0 0% 70%);
|
||||
--muted: hsl(240 4% 16%);
|
||||
--muted-foreground: hsla(0 0% 100% / 0.4);
|
||||
--border: hsl(0 0% 96%);
|
||||
--border-input: hsla(0 0% 96% / 0.17);
|
||||
--border-input-hover: hsla(0 0% 96% / 0.4);
|
||||
--border-card: hsla(0 0% 96% / 0.1);
|
||||
--dark: hsl(0 0% 96%);
|
||||
--dark-40: hsl(0 0% 96% / 0.4);
|
||||
--dark-10: hsl(0 0% 96% / 0.1);
|
||||
--dark-04: hsl(0 0% 96% / 0.04);
|
||||
--accent: hsl(204 90% 90%);
|
||||
--accent-foreground: hsl(204 94% 94%);
|
||||
--destructive: hsl(350 89% 60%);
|
||||
--line: hsl(0 0% 9.02%);
|
||||
--tertiary: hsl(61.3 100% 82.2%);
|
||||
/* white */
|
||||
--contrast: hsl(0 0% 100%);
|
||||
|
||||
/* Shadows */
|
||||
--shadow-mini: 0px 1px 0px 1px rgba(0, 0, 0, 0.3);
|
||||
--shadow-mini-inset: 0px 1px 0px 0px rgba(0, 0, 0, 0.5) inset;
|
||||
--shadow-popover: 0px 7px 12px 3px hsla(0deg 0% 0% / 30%);
|
||||
--shadow-kbd: 0px 2px 0px 0px rgba(255, 255, 255, 0.07);
|
||||
--shadow-btn: 0px 1px 0px 1px rgba(0, 0, 0, 0.2);
|
||||
--shadow-card: 0px 2px 0px 1px rgba(0, 0, 0, 0.4);
|
||||
--shadow-date-field-focus: 0px 0px 0px 3px rgba(244, 244, 245, 0.1);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-background-alt: var(--background-alt);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-foreground-alt: var(--foreground-alt);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-border: var(--border-card);
|
||||
--color-border-input: var(--border-input);
|
||||
--color-border-input-hover: var(--border-input-hover);
|
||||
--color-border-card: var(--border-card);
|
||||
--color-dark: var(--dark);
|
||||
--color-dark-10: var(--dark-10);
|
||||
--color-dark-40: var(--dark-40);
|
||||
--color-dark-04: var(--dark-04);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-tertiary: var(--tertiary);
|
||||
--color-line: var(--line);
|
||||
--color-contrast: var(--contrast);
|
||||
|
||||
--shadow-mini: var(--shadow-mini);
|
||||
--shadow-mini-inset: var(--shadow-mini-inset);
|
||||
--shadow-popover: var(--shadow-popover);
|
||||
--shadow-kbd: var(--shadow-kbd);
|
||||
--shadow-btn: var(--shadow-btn);
|
||||
--shadow-card: var(--shadow-card);
|
||||
--shadow-date-field-focus: var(--shadow-date-field-focus);
|
||||
|
||||
--text-xxs: 10px;
|
||||
|
||||
--radius-card: 16px;
|
||||
--radius-card-lg: 20px;
|
||||
--radius-card-sm: 10px;
|
||||
--radius-input: 9px;
|
||||
--radius-button: 5px;
|
||||
--radius-5px: 5px;
|
||||
--radius-9px: 9px;
|
||||
--radius-10px: 10px;
|
||||
--radius-15px: 15px;
|
||||
|
||||
--spacing-input: 3rem;
|
||||
--spacing-input-sm: 2.5rem;
|
||||
|
||||
--breakpoint-desktop: 1440px;
|
||||
|
||||
--animate-accordion-down: accordion-down 0.2s ease-out;
|
||||
--animate-accordion-up: accordion-up 0.2s ease-out;
|
||||
--animate-caret-blink: caret-blink 1s ease-out infinite;
|
||||
--animate-scale-in: scale-in 0.2s ease;
|
||||
--animate-scale-out: scale-out 0.15s ease;
|
||||
--animate-fade-in: fade-in 0.2s ease;
|
||||
--animate-fade-out: fade-out 0.15s ease;
|
||||
--animate-enter-from-left: enter-from-left 0.2s ease;
|
||||
--animate-enter-from-right: enter-from-right 0.2s ease;
|
||||
--animate-exit-to-left: exit-to-left 0.2s ease;
|
||||
--animate-exit-to-right: exit-to-right 0.2s ease;
|
||||
|
||||
--font-sans: "Inter", "sans-serif";
|
||||
--font-mono: "Source Code Pro", "monospace";
|
||||
--font-alt: "Courier", "sans-serif";
|
||||
--font-display: "Cal Sans", "sans-serif";
|
||||
|
||||
@keyframes accordion-down {
|
||||
from {
|
||||
height: 0;
|
||||
}
|
||||
to {
|
||||
height: var(--bits-accordion-content-height);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes accordion-up {
|
||||
from {
|
||||
height: var(--bits-accordion-content-height);
|
||||
}
|
||||
to {
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes caret-blink {
|
||||
0%,
|
||||
70%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
20%,
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes enter-from-right {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(200px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes enter-from-left {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-200px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes exit-to-right {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(200px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes exit-to-left {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateX(-200px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scale-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: rotateX(-10deg) scale(0.9);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: rotateX(0deg) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scale-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: rotateX(0deg) scale(1);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: rotateX(-10deg) scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-border-card, currentColor);
|
||||
}
|
||||
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
font-variation-settings: normal;
|
||||
scrollbar-color: var(--bg-muted);
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-feature-settings:
|
||||
"rlig" 1,
|
||||
"calt" 1;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: #fdffa4;
|
||||
color: black;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
*:not(body):not(.focus-override) {
|
||||
outline: none !important;
|
||||
&:focus-visible {
|
||||
@apply focus-visible:ring-foreground focus-visible:ring-offset-background focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-offset-1;
|
||||
}
|
||||
}
|
||||
|
||||
.link {
|
||||
@apply hover:text-foreground/80 focus-visible:ring-foreground focus-visible:ring-offset-background rounded-xs focus-visible:outline-hidden inline-flex items-center gap-1 font-medium underline underline-offset-4 focus-visible:ring-2 focus-visible:ring-offset-2;
|
||||
}
|
||||
}
|
||||
@ -1,125 +1,318 @@
|
||||
<script lang="ts">
|
||||
import { Accordion } from '@/uix/acel';
|
||||
import { Accordion } from '$uix/acel';
|
||||
|
||||
let singleValue = $state<string | undefined>(undefined);
|
||||
let multipleValue = $state<string[]>([]);
|
||||
</script>
|
||||
|
||||
<div class="p-8 max-w-2xl mx-auto">
|
||||
<h1 class="text-2xl font-bold mb-6">Accordion Component Test</h1>
|
||||
|
||||
<div class="space-y-8">
|
||||
<!-- Single Accordion -->
|
||||
<section>
|
||||
<h2 class="text-lg font-semibold mb-3">Single Accordion (type="single")</h2>
|
||||
<Accordion.Root type="single" class="border rounded-lg">
|
||||
<Accordion.Item value="item-1" class="border-b">
|
||||
<Accordion.Header>
|
||||
<Accordion.Trigger class="flex w-full items-center justify-between p-4 hover:bg-gray-100">
|
||||
<span>Is it accessible?</span>
|
||||
<span class="accordion-chevron">▼</span>
|
||||
</Accordion.Trigger>
|
||||
</Accordion.Header>
|
||||
<Accordion.Content class="p-4 pt-0">
|
||||
Yes. It adheres to the WAI-ARIA design pattern.
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
|
||||
<Accordion.Item value="item-2" class="border-b">
|
||||
<Accordion.Header>
|
||||
<Accordion.Trigger class="flex w-full items-center justify-between p-4 hover:bg-gray-100">
|
||||
<span>Is it styled?</span>
|
||||
<span class="accordion-chevron">▼</span>
|
||||
</Accordion.Trigger>
|
||||
</Accordion.Header>
|
||||
<Accordion.Content class="p-4 pt-0">
|
||||
Yes. It comes with default styles that match the other components' aesthetics.
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
|
||||
<Accordion.Item value="item-3">
|
||||
<Accordion.Header>
|
||||
<Accordion.Trigger class="flex w-full items-center justify-between p-4 hover:bg-gray-100">
|
||||
<span>Is it animated?</span>
|
||||
<span class="accordion-chevron">▼</span>
|
||||
</Accordion.Trigger>
|
||||
</Accordion.Header>
|
||||
<Accordion.Content class="p-4 pt-0">
|
||||
Yes. It's animated by default, but you can disable it if you prefer.
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
</Accordion.Root>
|
||||
</section>
|
||||
|
||||
<!-- Multiple Accordion -->
|
||||
<section>
|
||||
<h2 class="text-lg font-semibold mb-3">Multiple Accordion (type="multiple")</h2>
|
||||
<Accordion.Root type="multiple" class="border rounded-lg">
|
||||
<Accordion.Item value="item-1" class="border-b">
|
||||
<Accordion.Header>
|
||||
<Accordion.Trigger class="flex w-full items-center justify-between p-4 hover:bg-gray-100">
|
||||
<span>Section 1</span>
|
||||
<span>▼</span>
|
||||
</Accordion.Trigger>
|
||||
</Accordion.Header>
|
||||
<Accordion.Content class="p-4 pt-0">
|
||||
Content for section 1. Multiple sections can be open at the same time.
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
|
||||
<Accordion.Item value="item-2" class="border-b">
|
||||
<Accordion.Header>
|
||||
<Accordion.Trigger class="flex w-full items-center justify-between p-4 hover:bg-gray-100">
|
||||
<span>Section 2</span>
|
||||
<span>▼</span>
|
||||
</Accordion.Trigger>
|
||||
</Accordion.Header>
|
||||
<Accordion.Content class="p-4 pt-0">
|
||||
Content for section 2.
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
|
||||
<Accordion.Item value="item-3">
|
||||
<Accordion.Header>
|
||||
<Accordion.Trigger class="flex w-full items-center justify-between p-4 hover:bg-gray-100">
|
||||
<span>Section 3</span>
|
||||
<span>▼</span>
|
||||
</Accordion.Trigger>
|
||||
</Accordion.Header>
|
||||
<Accordion.Content class="p-4 pt-0">
|
||||
Content for section 3.
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
</Accordion.Root>
|
||||
</section>
|
||||
|
||||
<!-- Disabled Accordion -->
|
||||
<section>
|
||||
<h2 class="text-lg font-semibold mb-3">Disabled Accordion</h2>
|
||||
<Accordion.Root type="single" disabled={true} class="border rounded-lg opacity-50">
|
||||
<Accordion.Item value="item-1">
|
||||
<Accordion.Header>
|
||||
<Accordion.Trigger class="flex w-full items-center justify-between p-4">
|
||||
<span>Disabled Item</span>
|
||||
<span>▼</span>
|
||||
</Accordion.Trigger>
|
||||
</Accordion.Header>
|
||||
<Accordion.Content class="p-4 pt-0">
|
||||
This content should not be accessible.
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
</Accordion.Root>
|
||||
</section>
|
||||
</div>
|
||||
<div class="page">
|
||||
|
||||
<header class="ph">
|
||||
<div class="ph-inner">
|
||||
<div class="ph-brand">
|
||||
<span class="ph-dot"></span>
|
||||
<span class="ph-title">ac<em>cordion</em></span>
|
||||
</div>
|
||||
<span class="ph-sub">acel · primitivo expandible</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="grid">
|
||||
|
||||
<!-- ── 1. SINGLE ──────────────────────────────────────────────── -->
|
||||
<section class="card" data-color="teal">
|
||||
<header class="card-hd">
|
||||
<span class="tag">01</span>
|
||||
<span class="card-title">type="single"</span>
|
||||
</header>
|
||||
<p class="card-desc">Solo un ítem puede estar abierto a la vez.</p>
|
||||
|
||||
<div class="state-row">
|
||||
<span class="state-label">value:</span>
|
||||
<span class="state-val" class:state-val--on={!!singleValue}>
|
||||
{singleValue ?? '—'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Accordion.Root type="single" bind:value={singleValue} class="accordion">
|
||||
<Accordion.Item value="q1" class="acc-item">
|
||||
<Accordion.Header class="acc-hd">
|
||||
<Accordion.Trigger class="acc-trigger">
|
||||
<span>¿Es accesible?</span>
|
||||
<span class="chevron">▾</span>
|
||||
</Accordion.Trigger>
|
||||
</Accordion.Header>
|
||||
<Accordion.Content class="acc-content">
|
||||
Sí. Sigue el patrón de diseño WAI-ARIA para acordeones,
|
||||
con roles y atributos ARIA correctos.
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
|
||||
<Accordion.Item value="q2" class="acc-item">
|
||||
<Accordion.Header class="acc-hd">
|
||||
<Accordion.Trigger class="acc-trigger">
|
||||
<span>¿Tiene estilos propios?</span>
|
||||
<span class="chevron">▾</span>
|
||||
</Accordion.Trigger>
|
||||
</Accordion.Header>
|
||||
<Accordion.Content class="acc-content">
|
||||
No incluye estilos por defecto. Está pensado para que
|
||||
tú definas el diseño completo desde el exterior.
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
|
||||
<Accordion.Item value="q3" class="acc-item">
|
||||
<Accordion.Header class="acc-hd">
|
||||
<Accordion.Trigger class="acc-trigger">
|
||||
<span>¿Está animado?</span>
|
||||
<span class="chevron">▾</span>
|
||||
</Accordion.Trigger>
|
||||
</Accordion.Header>
|
||||
<Accordion.Content class="acc-content">
|
||||
El content expone <code>data-state="open|closed"</code> y
|
||||
<code>--bits-accordion-content-height</code> para animar
|
||||
con CSS sin JavaScript adicional.
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
</Accordion.Root>
|
||||
</section>
|
||||
|
||||
<!-- ── 2. MULTIPLE ────────────────────────────────────────────── -->
|
||||
<section class="card" data-color="indigo">
|
||||
<header class="card-hd">
|
||||
<span class="tag">02</span>
|
||||
<span class="card-title">type="multiple"</span>
|
||||
</header>
|
||||
<p class="card-desc">Varios ítems pueden estar abiertos simultáneamente.</p>
|
||||
|
||||
<div class="state-row">
|
||||
<span class="state-label">value:</span>
|
||||
<span class="state-val" class:state-val--on={multipleValue.length > 0}>
|
||||
{multipleValue.length ? multipleValue.join(', ') : '—'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Accordion.Root type="multiple" bind:value={multipleValue} class="accordion">
|
||||
{#each ['Alpha', 'Beta', 'Gamma', 'Delta'] as name, i}
|
||||
<Accordion.Item value="m{i+1}" class="acc-item">
|
||||
<Accordion.Header class="acc-hd">
|
||||
<Accordion.Trigger class="acc-trigger">
|
||||
<span>{name}</span>
|
||||
<span class="chevron">▾</span>
|
||||
</Accordion.Trigger>
|
||||
</Accordion.Header>
|
||||
<Accordion.Content class="acc-content">
|
||||
Contenido de <strong>{name}</strong>. Abre otros ítems y
|
||||
este permanecerá visible.
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
{/each}
|
||||
</Accordion.Root>
|
||||
</section>
|
||||
|
||||
<!-- ── 3. DISABLED ROOT ───────────────────────────────────────── -->
|
||||
<section class="card" data-color="rose">
|
||||
<header class="card-hd">
|
||||
<span class="tag">03</span>
|
||||
<span class="card-title">Root deshabilitado</span>
|
||||
</header>
|
||||
<p class="card-desc">
|
||||
Con <code>disabled</code> en el Root, todos los triggers quedan inactivos.
|
||||
</p>
|
||||
|
||||
<Accordion.Root type="single" disabled class="accordion accordion--disabled">
|
||||
<Accordion.Item value="d1" class="acc-item">
|
||||
<Accordion.Header class="acc-hd">
|
||||
<Accordion.Trigger class="acc-trigger">
|
||||
<span>No se puede abrir</span>
|
||||
<span class="chevron">▾</span>
|
||||
</Accordion.Trigger>
|
||||
</Accordion.Header>
|
||||
<Accordion.Content class="acc-content">
|
||||
Este contenido no debería ser visible.
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
<Accordion.Item value="d2" class="acc-item">
|
||||
<Accordion.Header class="acc-hd">
|
||||
<Accordion.Trigger class="acc-trigger">
|
||||
<span>Tampoco este</span>
|
||||
<span class="chevron">▾</span>
|
||||
</Accordion.Trigger>
|
||||
</Accordion.Header>
|
||||
<Accordion.Content class="acc-content">
|
||||
Bloqueado a nivel de raíz.
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
</Accordion.Root>
|
||||
</section>
|
||||
|
||||
<!-- ── 4. ITEM DISABLED ───────────────────────────────────────── -->
|
||||
<section class="card" data-color="amber">
|
||||
<header class="card-hd">
|
||||
<span class="tag">04</span>
|
||||
<span class="card-title">Ítem individual deshabilitado</span>
|
||||
</header>
|
||||
<p class="card-desc">
|
||||
<code>disabled</code> en un <code>Item</code> concreto, los demás siguen activos.
|
||||
</p>
|
||||
|
||||
<Accordion.Root type="single" class="accordion">
|
||||
<Accordion.Item value="e1" class="acc-item">
|
||||
<Accordion.Header class="acc-hd">
|
||||
<Accordion.Trigger class="acc-trigger">
|
||||
<span>Ítem activo</span>
|
||||
<span class="chevron">▾</span>
|
||||
</Accordion.Trigger>
|
||||
</Accordion.Header>
|
||||
<Accordion.Content class="acc-content">
|
||||
Este ítem funciona con normalidad.
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
|
||||
<Accordion.Item value="e2" disabled class="acc-item acc-item--disabled">
|
||||
<Accordion.Header class="acc-hd">
|
||||
<Accordion.Trigger class="acc-trigger">
|
||||
<span>Ítem deshabilitado</span>
|
||||
<span class="chevron">▾</span>
|
||||
</Accordion.Trigger>
|
||||
</Accordion.Header>
|
||||
<Accordion.Content class="acc-content">
|
||||
No debería ser accesible.
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
|
||||
<Accordion.Item value="e3" class="acc-item">
|
||||
<Accordion.Header class="acc-hd">
|
||||
<Accordion.Trigger class="acc-trigger">
|
||||
<span>Ítem activo</span>
|
||||
<span class="chevron">▾</span>
|
||||
</Accordion.Trigger>
|
||||
</Accordion.Header>
|
||||
<Accordion.Content class="acc-content">
|
||||
Este ítem también funciona con normalidad.
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
</Accordion.Root>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.accordion-chevron {
|
||||
transition: transform 0.2s;
|
||||
:global(*, *::before, *::after) { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
.page {
|
||||
min-height : 100vh;
|
||||
background : #0e0e14;
|
||||
color : #d4d2cc;
|
||||
font-family: 'DM Mono', 'Fira Code', monospace;
|
||||
font-size : 13px;
|
||||
}
|
||||
|
||||
/* ── PAGE HEADER ─────────────────────────────────────────────────────── */
|
||||
.ph { border-bottom: 2px solid #2dd4bf; background: #080810; }
|
||||
.ph-inner {
|
||||
max-width: 1200px; margin: 0 auto;
|
||||
padding : .75rem 1.5rem;
|
||||
display : flex; align-items: center; justify-content: space-between;
|
||||
}
|
||||
|
||||
[data-state="open"] .accordion-chevron {
|
||||
transform: rotate(180deg);
|
||||
.ph-brand { display: flex; align-items: center; gap: .5rem; }
|
||||
.ph-dot { width: 8px; height: 8px; border-radius: 50%; background: #2dd4bf; }
|
||||
.ph-title {
|
||||
font-size: .85rem; font-weight: 700;
|
||||
letter-spacing: .15em; text-transform: uppercase; color: #f4f3ef;
|
||||
}
|
||||
|
||||
[data-state="closed"] .accordion-chevron {
|
||||
transform: rotate(0deg);
|
||||
.ph-title em { color: #2dd4bf; font-style: normal; }
|
||||
.ph-sub { font-size: .65rem; color: #555; letter-spacing: .1em; }
|
||||
|
||||
/* ── MAIN ────────────────────────────────────────────────────────────── */
|
||||
main { max-width: 1200px; margin: 0 auto; padding: 1.5rem; }
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||
gap: .75rem;
|
||||
}
|
||||
|
||||
/* ── CARDS ───────────────────────────────────────────────────────────── */
|
||||
.card {
|
||||
background : #16161f;
|
||||
border : 1px solid #26262f;
|
||||
border-radius: 6px;
|
||||
padding : 1.1rem;
|
||||
display : flex; flex-direction: column; gap: .75rem;
|
||||
border-top : 3px solid transparent;
|
||||
}
|
||||
.card[data-color="teal"] { border-top-color: #2dd4bf; }
|
||||
.card[data-color="indigo"] { border-top-color: #818cf8; }
|
||||
.card[data-color="rose"] { border-top-color: #fb7185; }
|
||||
.card[data-color="amber"] { border-top-color: #e5a100; }
|
||||
|
||||
.card-hd {
|
||||
display : flex; align-items: center; gap: .5rem;
|
||||
padding-bottom: .6rem;
|
||||
border-bottom : 1px solid #26262f;
|
||||
}
|
||||
.tag { font-size: .6rem; font-weight: 700; letter-spacing: .15em; color: #555; }
|
||||
.card-title { font-size: .75rem; font-weight: 600; color: #aaa; }
|
||||
.card-desc { font-size: .72rem; color: #555; line-height: 1.5; }
|
||||
|
||||
.state-row { display: flex; align-items: center; gap: .5rem; font-size: .72rem; }
|
||||
.state-label { color: #555; }
|
||||
.state-val { color: #444; font-weight: 700; transition: color .2s; }
|
||||
.state-val--on { color: #2dd4bf; }
|
||||
|
||||
/* ── ACCORDION ───────────────────────────────────────────────────────── */
|
||||
:global(.accordion) {
|
||||
border : 1px solid #26262f;
|
||||
border-radius: 4px;
|
||||
overflow : hidden;
|
||||
}
|
||||
:global(.accordion--disabled) { opacity: .45; }
|
||||
|
||||
:global(.acc-item) {
|
||||
border-bottom: 1px solid #26262f;
|
||||
}
|
||||
:global(.acc-item:last-child) { border-bottom: none; }
|
||||
:global(.acc-item--disabled) { opacity: .4; }
|
||||
|
||||
:global(.acc-hd) { display: block; }
|
||||
|
||||
:global(.acc-trigger) {
|
||||
width : 100%;
|
||||
display : flex; align-items: center; justify-content: space-between;
|
||||
padding : .65rem .85rem;
|
||||
background : transparent; border: none;
|
||||
color : #aaa;
|
||||
font-family : 'DM Mono', 'Fira Code', monospace;
|
||||
font-size : .75rem; font-weight: 600;
|
||||
cursor : pointer; text-align: left;
|
||||
transition : color .15s, background .15s;
|
||||
}
|
||||
:global(.acc-trigger:hover) { color: #f0ede6; background: #1e1e2a; }
|
||||
:global([data-state="open"] > .acc-hd .acc-trigger) { color: #f0ede6; }
|
||||
|
||||
:global(.acc-content) {
|
||||
padding : 0 .85rem;
|
||||
font-size : .75rem; color: #666; line-height: 1.65;
|
||||
overflow : hidden;
|
||||
}
|
||||
:global([data-state="open"] .acc-content) { padding: .65rem .85rem; }
|
||||
:global([data-state="closed"] .acc-content) { padding: 0 .85rem; }
|
||||
|
||||
.chevron {
|
||||
font-size : .8rem; color: #555;
|
||||
transition: transform .2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
:global([data-state="open"] .chevron) { transform: rotate(180deg); color: #2dd4bf; }
|
||||
:global([data-state="closed"] .chevron) { transform: rotate(0deg); }
|
||||
|
||||
code {
|
||||
background : #26262f; padding: .1rem .35rem;
|
||||
border-radius: 2px; font-size: .68rem;
|
||||
color: #2dd4bf; font-family: inherit;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -0,0 +1,399 @@
|
||||
<script lang="ts">
|
||||
import { AlertDialog } from '$uix/acel';
|
||||
|
||||
let controlledOpen = $state(false);
|
||||
let lastAction = $state<string | null>(null);
|
||||
|
||||
function setAction(label: string) {
|
||||
lastAction = label;
|
||||
setTimeout(() => lastAction = null, 2500);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
|
||||
<header class="ph">
|
||||
<div class="ph-inner">
|
||||
<div class="ph-brand">
|
||||
<span class="ph-dot"></span>
|
||||
<span class="ph-title">alert<em>dialog</em></span>
|
||||
</div>
|
||||
<span class="ph-sub">acel · primitivo de confirmación</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
|
||||
<!-- ── STATUS BAR ─────────────────────────────────────────────────── -->
|
||||
<div class="status-bar" class:status-bar--active={lastAction}>
|
||||
{#if lastAction}
|
||||
<span class="status-dot status-dot--on"></span>
|
||||
<span>acción ejecutada: <strong>{lastAction}</strong></span>
|
||||
{:else}
|
||||
<span class="status-dot"></span>
|
||||
<span class="muted">esperando interacción…</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
|
||||
<!-- ── 1. DESTRUCTIVE ──────────────────────────────────────────── -->
|
||||
<section class="card" data-color="red">
|
||||
<header class="card-hd">
|
||||
<span class="tag">01</span>
|
||||
<span class="card-title">Acción destructiva</span>
|
||||
</header>
|
||||
<p class="card-desc">Caso clásico: eliminar cuenta. No se puede cerrar con Escape.</p>
|
||||
|
||||
<AlertDialog.Root>
|
||||
<AlertDialog.Trigger class="btn btn--red">
|
||||
Eliminar cuenta
|
||||
</AlertDialog.Trigger>
|
||||
<AlertDialog.Portal>
|
||||
<AlertDialog.Overlay class="overlay" />
|
||||
<AlertDialog.Content class="dialog">
|
||||
<AlertDialog.Title class="dialog-title">
|
||||
¿Eliminar cuenta permanentemente?
|
||||
</AlertDialog.Title>
|
||||
<AlertDialog.Description class="dialog-desc">
|
||||
Esta acción no se puede deshacer. Se eliminarán todos tus datos,
|
||||
configuraciones e historial de forma permanente.
|
||||
</AlertDialog.Description>
|
||||
<div class="dialog-footer">
|
||||
<AlertDialog.Cancel class="btn btn--ghost" onclick={() => setAction('cancel — delete account')}>
|
||||
Cancelar
|
||||
</AlertDialog.Cancel>
|
||||
<AlertDialog.Action class="btn btn--red" onclick={() => setAction('confirm — delete account')}>
|
||||
Sí, eliminar
|
||||
</AlertDialog.Action>
|
||||
</div>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Portal>
|
||||
</AlertDialog.Root>
|
||||
</section>
|
||||
|
||||
<!-- ── 2. CONFIRMACIÓN NEUTRAL ────────────────────────────────── -->
|
||||
<section class="card" data-color="amber">
|
||||
<header class="card-hd">
|
||||
<span class="tag">02</span>
|
||||
<span class="card-title">Confirmación neutral</span>
|
||||
</header>
|
||||
<p class="card-desc">Confirmar una acción significativa sin ser destructiva.</p>
|
||||
|
||||
<AlertDialog.Root>
|
||||
<AlertDialog.Trigger class="btn btn--amber">
|
||||
Publicar cambios
|
||||
</AlertDialog.Trigger>
|
||||
<AlertDialog.Portal>
|
||||
<AlertDialog.Overlay class="overlay" />
|
||||
<AlertDialog.Content class="dialog">
|
||||
<AlertDialog.Title class="dialog-title">
|
||||
¿Publicar estos cambios?
|
||||
</AlertDialog.Title>
|
||||
<AlertDialog.Description class="dialog-desc">
|
||||
Los cambios serán visibles para todos los usuarios en tiempo real.
|
||||
Asegúrate de haberlos revisado antes de continuar.
|
||||
</AlertDialog.Description>
|
||||
<div class="dialog-footer">
|
||||
<AlertDialog.Cancel class="btn btn--ghost" onclick={() => setAction('cancel — publish')}>
|
||||
Revisar antes
|
||||
</AlertDialog.Cancel>
|
||||
<AlertDialog.Action class="btn btn--amber" onclick={() => setAction('confirm — publish')}>
|
||||
Publicar ahora
|
||||
</AlertDialog.Action>
|
||||
</div>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Portal>
|
||||
</AlertDialog.Root>
|
||||
</section>
|
||||
|
||||
<!-- ── 3. SOLO CANCEL ─────────────────────────────────────────── -->
|
||||
<section class="card" data-color="blue">
|
||||
<header class="card-hd">
|
||||
<span class="tag">03</span>
|
||||
<span class="card-title">Solo cancelar (aviso)</span>
|
||||
</header>
|
||||
<p class="card-desc">Diálogo informativo que solo permite confirmar lectura.</p>
|
||||
|
||||
<AlertDialog.Root>
|
||||
<AlertDialog.Trigger class="btn btn--blue">
|
||||
Ver aviso legal
|
||||
</AlertDialog.Trigger>
|
||||
<AlertDialog.Portal>
|
||||
<AlertDialog.Overlay class="overlay" />
|
||||
<AlertDialog.Content class="dialog">
|
||||
<AlertDialog.Title class="dialog-title">
|
||||
Aviso importante
|
||||
</AlertDialog.Title>
|
||||
<AlertDialog.Description class="dialog-desc">
|
||||
Este sistema está en fase beta. Algunos datos pueden no estar
|
||||
disponibles hasta la versión final. Las funcionalidades pueden
|
||||
cambiar sin previo aviso.
|
||||
</AlertDialog.Description>
|
||||
<div class="dialog-footer dialog-footer--center">
|
||||
<AlertDialog.Cancel class="btn btn--blue" onclick={() => setAction('acknowledged — notice')}>
|
||||
Entendido
|
||||
</AlertDialog.Cancel>
|
||||
</div>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Portal>
|
||||
</AlertDialog.Root>
|
||||
</section>
|
||||
|
||||
<!-- ── 4. CONTROLLED ─────────────────────────────────────────── -->
|
||||
<section class="card" data-color="violet">
|
||||
<header class="card-hd">
|
||||
<span class="tag">04</span>
|
||||
<span class="card-title">Controlado externamente</span>
|
||||
</header>
|
||||
<p class="card-desc">El estado <code>open</code> es controlado desde fuera del componente.</p>
|
||||
|
||||
<div class="state-row">
|
||||
<span class="state-label">open:</span>
|
||||
<span class="state-val" class:state-val--on={controlledOpen}>{controlledOpen}</span>
|
||||
</div>
|
||||
|
||||
<div class="btn-row">
|
||||
<button class="btn btn--violet" onclick={() => controlledOpen = true}>
|
||||
Abrir
|
||||
</button>
|
||||
<button class="btn btn--ghost" onclick={() => controlledOpen = false}>
|
||||
Cerrar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AlertDialog.Root bind:open={controlledOpen}>
|
||||
<AlertDialog.Portal>
|
||||
<AlertDialog.Overlay class="overlay" />
|
||||
<AlertDialog.Content class="dialog">
|
||||
<AlertDialog.Title class="dialog-title">
|
||||
Diálogo controlado
|
||||
</AlertDialog.Title>
|
||||
<AlertDialog.Description class="dialog-desc">
|
||||
Este diálogo fue abierto programáticamente. Su estado
|
||||
<code>open</code> es gestionado por la variable reactiva
|
||||
del componente padre.
|
||||
</AlertDialog.Description>
|
||||
<div class="dialog-footer">
|
||||
<AlertDialog.Cancel class="btn btn--ghost" onclick={() => setAction('cancel — controlled')}>
|
||||
Cerrar
|
||||
</AlertDialog.Cancel>
|
||||
<AlertDialog.Action class="btn btn--violet" onclick={() => setAction('confirm — controlled')}>
|
||||
Confirmar
|
||||
</AlertDialog.Action>
|
||||
</div>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Portal>
|
||||
</AlertDialog.Root>
|
||||
</section>
|
||||
|
||||
<!-- ── 5. SIN PORTAL ─────────────────────────────────────────── -->
|
||||
<section class="card" data-color="green">
|
||||
<header class="card-hd">
|
||||
<span class="tag">05</span>
|
||||
<span class="card-title">Sin Portal</span>
|
||||
</header>
|
||||
<p class="card-desc">Content renderizado inline (sin teleport), útil para pruebas.</p>
|
||||
|
||||
<AlertDialog.Root>
|
||||
<AlertDialog.Trigger class="btn btn--green">
|
||||
Abrir inline
|
||||
</AlertDialog.Trigger>
|
||||
<AlertDialog.Overlay class="overlay" />
|
||||
<AlertDialog.Content class="dialog">
|
||||
<AlertDialog.Title class="dialog-title">
|
||||
Diálogo inline
|
||||
</AlertDialog.Title>
|
||||
<AlertDialog.Description class="dialog-desc">
|
||||
Este diálogo no usa Portal, el DOM se inserta en el árbol del
|
||||
componente actual en lugar de en el body.
|
||||
</AlertDialog.Description>
|
||||
<div class="dialog-footer">
|
||||
<AlertDialog.Cancel class="btn btn--ghost" onclick={() => setAction('cancel — inline')}>
|
||||
Cancelar
|
||||
</AlertDialog.Cancel>
|
||||
<AlertDialog.Action class="btn btn--green" onclick={() => setAction('confirm — inline')}>
|
||||
OK
|
||||
</AlertDialog.Action>
|
||||
</div>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Root>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(*, *::before, *::after) { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
.page {
|
||||
min-height : 100vh;
|
||||
background : #0e0e14;
|
||||
color : #d4d2cc;
|
||||
font-family : 'DM Mono', 'Fira Code', monospace;
|
||||
font-size : 13px;
|
||||
}
|
||||
|
||||
/* ── PAGE HEADER ─────────────────────────────────────────────────────── */
|
||||
.ph {
|
||||
border-bottom: 2px solid #e5a100;
|
||||
background : #080810;
|
||||
}
|
||||
.ph-inner {
|
||||
max-width : 1200px; margin: 0 auto;
|
||||
padding : .75rem 1.5rem;
|
||||
display : flex; align-items: center; justify-content: space-between;
|
||||
}
|
||||
.ph-brand { display: flex; align-items: center; gap: .5rem; }
|
||||
.ph-dot { width: 8px; height: 8px; border-radius: 50%; background: #e5a100; }
|
||||
.ph-title {
|
||||
font-size: .85rem; font-weight: 700;
|
||||
letter-spacing: .15em; text-transform: uppercase; color: #f4f3ef;
|
||||
}
|
||||
.ph-title em { color: #e5a100; font-style: normal; }
|
||||
.ph-sub { font-size: .65rem; color: #555; letter-spacing: .1em; }
|
||||
|
||||
/* ── MAIN ────────────────────────────────────────────────────────────── */
|
||||
main {
|
||||
max-width: 1200px; margin: 0 auto;
|
||||
padding : 1.5rem;
|
||||
display : flex; flex-direction: column; gap: 1rem;
|
||||
}
|
||||
|
||||
/* ── STATUS BAR ──────────────────────────────────────────────────────── */
|
||||
.status-bar {
|
||||
display : flex; align-items: center; gap: .6rem;
|
||||
padding : .5rem .85rem;
|
||||
background : #16161f;
|
||||
border : 1px solid #26262f;
|
||||
border-radius: 4px;
|
||||
font-size : .72rem;
|
||||
transition : border-color .2s;
|
||||
}
|
||||
.status-bar--active { border-color: #e5a100; }
|
||||
.status-bar strong { color: #e5a100; }
|
||||
|
||||
.status-dot {
|
||||
width: 6px; height: 6px; border-radius: 50%;
|
||||
background: #333; flex-shrink: 0;
|
||||
transition: background .2s;
|
||||
}
|
||||
.status-dot--on { background: #e5a100; box-shadow: 0 0 6px #e5a100; }
|
||||
.muted { color: #444; }
|
||||
|
||||
/* ── GRID ────────────────────────────────────────────────────────────── */
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: .75rem;
|
||||
}
|
||||
|
||||
/* ── CARDS ───────────────────────────────────────────────────────────── */
|
||||
.card {
|
||||
background : #16161f;
|
||||
border : 1px solid #26262f;
|
||||
border-radius: 6px;
|
||||
padding : 1.1rem;
|
||||
display : flex; flex-direction: column; gap: .75rem;
|
||||
border-top : 3px solid transparent;
|
||||
}
|
||||
.card[data-color="red"] { border-top-color: #f87171; }
|
||||
.card[data-color="amber"] { border-top-color: #e5a100; }
|
||||
.card[data-color="blue"] { border-top-color: #60a5fa; }
|
||||
.card[data-color="violet"] { border-top-color: #a78bfa; }
|
||||
.card[data-color="green"] { border-top-color: #4ade80; }
|
||||
|
||||
.card-hd {
|
||||
display : flex; align-items: center; gap: .5rem;
|
||||
padding-bottom: .6rem;
|
||||
border-bottom: 1px solid #26262f;
|
||||
}
|
||||
.tag {
|
||||
font-size : .6rem; font-weight: 700; letter-spacing: .15em;
|
||||
color : #555;
|
||||
}
|
||||
.card-title { font-size: .75rem; font-weight: 600; color: #aaa; }
|
||||
.card-desc { font-size: .72rem; color: #555; line-height: 1.5; }
|
||||
|
||||
/* ── STATE ROW ───────────────────────────────────────────────────────── */
|
||||
.state-row { display: flex; align-items: center; gap: .5rem; font-size: .72rem; }
|
||||
.state-label { color: #555; }
|
||||
.state-val { color: #444; font-weight: 700; transition: color .2s; }
|
||||
.state-val--on { color: #a78bfa; }
|
||||
|
||||
.btn-row { display: flex; gap: .4rem; }
|
||||
|
||||
/* ── BUTTONS ─────────────────────────────────────────────────────────── */
|
||||
:global(.btn) {
|
||||
padding : .35rem .8rem;
|
||||
border : none; border-radius: 3px;
|
||||
font-family: 'DM Mono', 'Fira Code', monospace;
|
||||
font-size : .72rem; font-weight: 700;
|
||||
letter-spacing: .05em; cursor: pointer;
|
||||
transition : opacity .12s;
|
||||
}
|
||||
:global(.btn:hover) { opacity: .8; }
|
||||
|
||||
:global(.btn--red) { background: #7f1d1d; color: #fca5a5; }
|
||||
:global(.btn--amber) { background: #78350f; color: #fcd34d; }
|
||||
:global(.btn--blue) { background: #1e3a5f; color: #93c5fd; }
|
||||
:global(.btn--violet) { background: #2e1065; color: #c4b5fd; }
|
||||
:global(.btn--green) { background: #14532d; color: #86efac; }
|
||||
:global(.btn--ghost) {
|
||||
background: transparent;
|
||||
border : 1px solid #333; color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
:global(.btn--ghost:hover) { border-color: #555; color: #999; opacity: 1; }
|
||||
|
||||
/* ── OVERLAY ─────────────────────────────────────────────────────────── */
|
||||
:global(.overlay) {
|
||||
position : fixed; inset: 0;
|
||||
background: rgba(0, 0, 0, .65);
|
||||
backdrop-filter: blur(2px);
|
||||
z-index : 50;
|
||||
}
|
||||
|
||||
/* ── DIALOG ──────────────────────────────────────────────────────────── */
|
||||
:global(.dialog) {
|
||||
position : fixed;
|
||||
top : 50%; left: 50%;
|
||||
transform : translate(-50%, -50%);
|
||||
z-index : 51;
|
||||
width : min(480px, calc(100vw - 2rem));
|
||||
background: #1a1a24;
|
||||
border : 1px solid #2e2e3e;
|
||||
border-radius: 8px;
|
||||
padding : 1.75rem;
|
||||
display : flex; flex-direction: column; gap: 1rem;
|
||||
box-shadow: 0 24px 60px rgba(0,0,0,.6);
|
||||
}
|
||||
|
||||
:global(.dialog-title) {
|
||||
font-size : .95rem; font-weight: 700;
|
||||
color : #f0ede6;
|
||||
letter-spacing: .02em;
|
||||
}
|
||||
|
||||
:global(.dialog-desc) {
|
||||
font-size : .78rem; color: #777;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
:global(.dialog-footer) {
|
||||
display : flex; justify-content: flex-end; gap: .5rem;
|
||||
padding-top: .5rem;
|
||||
border-top : 1px solid #26262f;
|
||||
}
|
||||
:global(.dialog-footer--center) { justify-content: center; }
|
||||
|
||||
code {
|
||||
background : #26262f;
|
||||
padding : .1rem .35rem;
|
||||
border-radius: 2px;
|
||||
font-size : .7rem;
|
||||
color : #a78bfa;
|
||||
font-family : inherit;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,323 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$uix/acel';
|
||||
|
||||
let lastRef = $state<HTMLElement | null>(null);
|
||||
let clickLog = $state<string[]>([]);
|
||||
|
||||
function log(label: string) {
|
||||
clickLog = [label, ...clickLog].slice(0, 6);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
|
||||
<header class="ph">
|
||||
<div class="ph-inner">
|
||||
<div class="ph-brand">
|
||||
<span class="ph-dot"></span>
|
||||
<span class="ph-title">but<em>ton</em></span>
|
||||
</div>
|
||||
<span class="ph-sub">acel · primitivo de acción</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="grid">
|
||||
|
||||
<!-- ── 1. BUTTON ──────────────────────────────────────────────── -->
|
||||
<section class="card" data-color="indigo">
|
||||
<header class="card-hd">
|
||||
<span class="tag">01</span>
|
||||
<span class="card-title">Elemento <button></span>
|
||||
</header>
|
||||
<p class="card-desc">
|
||||
Sin <code>href</code> renderiza un <code><button></code> nativo
|
||||
con <code>data-button-root</code>.
|
||||
</p>
|
||||
|
||||
<div class="demo-row">
|
||||
<Button.Root class="btn btn--indigo" onclick={() => log('button · default')}>
|
||||
Default
|
||||
</Button.Root>
|
||||
<Button.Root class="btn btn--indigo" type="submit" onclick={() => log('button · submit')}>
|
||||
type=submit
|
||||
</Button.Root>
|
||||
<Button.Root class="btn btn--indigo" type="button" onclick={() => log('button · button')}>
|
||||
type=button
|
||||
</Button.Root>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── 2. ANCHOR ──────────────────────────────────────────────── -->
|
||||
<section class="card" data-color="teal">
|
||||
<header class="card-hd">
|
||||
<span class="tag">02</span>
|
||||
<span class="card-title">Elemento <a></span>
|
||||
</header>
|
||||
<p class="card-desc">
|
||||
Con <code>href</code> renderiza un <code><a></code>.
|
||||
Mismo API, distinto elemento DOM.
|
||||
</p>
|
||||
|
||||
<div class="demo-row">
|
||||
<Button.Root class="btn btn--teal" href="#top" onclick={() => log('anchor · href=#top')}>
|
||||
href="#top"
|
||||
</Button.Root>
|
||||
<Button.Root class="btn btn--teal" href="/test" onclick={() => log('anchor · /test')}>
|
||||
href="/test"
|
||||
</Button.Root>
|
||||
</div>
|
||||
|
||||
<div class="attr-row">
|
||||
<span class="attr-label">elemento renderizado:</span>
|
||||
<code><a href="…"></code>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── 3. DISABLED BUTTON ─────────────────────────────────────── -->
|
||||
<section class="card" data-color="rose">
|
||||
<header class="card-hd">
|
||||
<span class="tag">03</span>
|
||||
<span class="card-title">Disabled button</span>
|
||||
</header>
|
||||
<p class="card-desc">
|
||||
<code>disabled</code> en un <code><button></code> usa el atributo
|
||||
nativo. No dispara eventos.
|
||||
</p>
|
||||
|
||||
<div class="demo-row">
|
||||
<Button.Root class="btn btn--rose" disabled onclick={() => log('NUNCA')}>
|
||||
Botón deshabilitado
|
||||
</Button.Root>
|
||||
<Button.Root class="btn btn--ghost" disabled onclick={() => log('NUNCA')}>
|
||||
Ghost deshabilitado
|
||||
</Button.Root>
|
||||
</div>
|
||||
|
||||
<div class="attr-row">
|
||||
<span class="attr-label">attr:</span>
|
||||
<code>disabled</code>
|
||||
<span class="attr-label">aria:</span>
|
||||
<code>—</code>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── 4. DISABLED ANCHOR ────────────────────────────────────── -->
|
||||
<section class="card" data-color="amber">
|
||||
<header class="card-hd">
|
||||
<span class="tag">04</span>
|
||||
<span class="card-title">Disabled anchor</span>
|
||||
</header>
|
||||
<p class="card-desc">
|
||||
<code><a></code> no soporta <code>disabled</code> nativo.
|
||||
Se usa <code>aria-disabled</code> + <code>tabindex=-1</code> + <code>href</code> eliminado.
|
||||
</p>
|
||||
|
||||
<div class="demo-row">
|
||||
<Button.Root class="btn btn--amber btn--disabled-anchor" href="/test" disabled onclick={() => log('NUNCA')}>
|
||||
Anchor deshabilitado
|
||||
</Button.Root>
|
||||
</div>
|
||||
|
||||
<div class="attr-row">
|
||||
<span class="attr-label">attr:</span>
|
||||
<code>aria-disabled="true"</code>
|
||||
<code>tabindex="-1"</code>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── 5. BIND:REF ────────────────────────────────────────────── -->
|
||||
<section class="card" data-color="green">
|
||||
<header class="card-hd">
|
||||
<span class="tag">05</span>
|
||||
<span class="card-title">bind:ref</span>
|
||||
</header>
|
||||
<p class="card-desc">
|
||||
<code>bind:ref</code> expone la referencia al elemento DOM subyacente.
|
||||
</p>
|
||||
|
||||
<Button.Root class="btn btn--green" bind:ref={lastRef}
|
||||
onclick={() => log(`ref.tagName=${lastRef?.tagName}`)}>
|
||||
Capturar ref
|
||||
</Button.Root>
|
||||
|
||||
<div class="ref-panel">
|
||||
<span class="ref-label">ref</span>
|
||||
{#if lastRef}
|
||||
<div class="ref-row"><span class="ref-key">tagName</span><code>{lastRef.tagName}</code></div>
|
||||
<div class="ref-row"><span class="ref-key">dataset</span><code>{JSON.stringify(lastRef.dataset)}</code></div>
|
||||
{:else}
|
||||
<span class="ref-empty">null — haz clic en el botón</span>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── 6. CLICK LOG ───────────────────────────────────────────── -->
|
||||
<section class="card" data-color="violet">
|
||||
<header class="card-hd">
|
||||
<span class="tag">06</span>
|
||||
<span class="card-title">Click log</span>
|
||||
</header>
|
||||
<p class="card-desc">
|
||||
Registro de eventos de los otros paneles. Los disabled no aparecen.
|
||||
</p>
|
||||
|
||||
<div class="log-panel">
|
||||
<span class="log-label">eventos registrados</span>
|
||||
{#each clickLog as entry}
|
||||
<span class="log-entry">{entry}</span>
|
||||
{:else}
|
||||
<span class="log-empty">— haz clic en algún botón</span>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<button class="btn btn--ghost btn--sm" onclick={() => clickLog = []}>
|
||||
limpiar
|
||||
</button>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(*, *::before, *::after) { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
.page {
|
||||
min-height : 100vh;
|
||||
background : #0e0e14;
|
||||
color : #d4d2cc;
|
||||
font-family: 'DM Mono', 'Fira Code', monospace;
|
||||
font-size : 13px;
|
||||
}
|
||||
|
||||
/* ── PAGE HEADER ─────────────────────────────────────────────────────── */
|
||||
.ph { border-bottom: 2px solid #4ade80; background: #080810; }
|
||||
.ph-inner {
|
||||
max-width: 1200px; margin: 0 auto;
|
||||
padding : .75rem 1.5rem;
|
||||
display : flex; align-items: center; justify-content: space-between;
|
||||
}
|
||||
.ph-brand { display: flex; align-items: center; gap: .5rem; }
|
||||
.ph-dot { width: 8px; height: 8px; border-radius: 50%; background: #4ade80; }
|
||||
.ph-title {
|
||||
font-size: .85rem; font-weight: 700;
|
||||
letter-spacing: .15em; text-transform: uppercase; color: #f4f3ef;
|
||||
}
|
||||
.ph-title em { color: #4ade80; font-style: normal; }
|
||||
.ph-sub { font-size: .65rem; color: #555; letter-spacing: .1em; }
|
||||
|
||||
/* ── MAIN ────────────────────────────────────────────────────────────── */
|
||||
main { max-width: 1200px; margin: 0 auto; padding: 1.5rem; }
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(310px, 1fr));
|
||||
gap: .75rem;
|
||||
}
|
||||
|
||||
/* ── CARDS ───────────────────────────────────────────────────────────── */
|
||||
.card {
|
||||
background : #16161f;
|
||||
border : 1px solid #26262f;
|
||||
border-radius: 6px;
|
||||
padding : 1.1rem;
|
||||
display : flex; flex-direction: column; gap: .75rem;
|
||||
border-top : 3px solid transparent;
|
||||
}
|
||||
.card[data-color="indigo"] { border-top-color: #818cf8; }
|
||||
.card[data-color="teal"] { border-top-color: #2dd4bf; }
|
||||
.card[data-color="rose"] { border-top-color: #fb7185; }
|
||||
.card[data-color="amber"] { border-top-color: #e5a100; }
|
||||
.card[data-color="green"] { border-top-color: #4ade80; }
|
||||
.card[data-color="violet"] { border-top-color: #a78bfa; }
|
||||
|
||||
.card-hd {
|
||||
display : flex; align-items: center; gap: .5rem;
|
||||
padding-bottom: .6rem;
|
||||
border-bottom : 1px solid #26262f;
|
||||
}
|
||||
.tag { font-size: .6rem; font-weight: 700; letter-spacing: .15em; color: #555; }
|
||||
.card-title { font-size: .75rem; font-weight: 600; color: #aaa; }
|
||||
.card-desc { font-size: .72rem; color: #555; line-height: 1.5; }
|
||||
|
||||
.demo-row { display: flex; flex-wrap: wrap; gap: .4rem; }
|
||||
|
||||
.attr-row {
|
||||
display : flex; align-items: center; flex-wrap: wrap; gap: .4rem;
|
||||
font-size : .68rem;
|
||||
}
|
||||
.attr-label { color: #555; }
|
||||
|
||||
/* ── BUTTONS ─────────────────────────────────────────────────────────── */
|
||||
:global(.btn) {
|
||||
padding : .35rem .8rem;
|
||||
border : none; border-radius: 3px;
|
||||
font-family: 'DM Mono', 'Fira Code', monospace;
|
||||
font-size : .72rem; font-weight: 700;
|
||||
letter-spacing: .05em; cursor: pointer;
|
||||
text-decoration: none; display: inline-block;
|
||||
transition : opacity .12s;
|
||||
}
|
||||
:global(.btn:hover) { opacity: .8; }
|
||||
:global(.btn:disabled) { opacity: .35; cursor: not-allowed; }
|
||||
:global([aria-disabled="true"].btn) { opacity: .35; cursor: not-allowed; pointer-events: none; }
|
||||
|
||||
:global(.btn--indigo) { background: #1e1b4b; color: #a5b4fc; }
|
||||
:global(.btn--teal) { background: #0d2d2a; color: #5eead4; }
|
||||
:global(.btn--rose) { background: #4c0519; color: #fda4af; }
|
||||
:global(.btn--amber) { background: #78350f; color: #fcd34d; }
|
||||
:global(.btn--green) { background: #14532d; color: #86efac; }
|
||||
:global(.btn--ghost) {
|
||||
background: transparent; border: 1px solid #333;
|
||||
color: #666; font-weight: 500;
|
||||
}
|
||||
:global(.btn--ghost:hover) { border-color: #555; color: #999; opacity: 1; }
|
||||
|
||||
.btn {
|
||||
padding : .3rem .6rem;
|
||||
border : none; border-radius: 3px;
|
||||
font-family: 'DM Mono', 'Fira Code', monospace;
|
||||
font-size : .68rem; font-weight: 700;
|
||||
cursor : pointer; transition: opacity .12s;
|
||||
}
|
||||
.btn:hover { opacity: .8; }
|
||||
.btn--ghost {
|
||||
background: transparent; border: 1px solid #333;
|
||||
color: #666; font-weight: 500;
|
||||
}
|
||||
.btn--ghost:hover { border-color: #555; color: #999; opacity: 1; }
|
||||
.btn--sm { font-size: .62rem; padding: .2rem .5rem; }
|
||||
|
||||
/* ── LOG ─────────────────────────────────────────────────────────────── */
|
||||
.log-panel {
|
||||
display : flex; flex-direction: column; gap: .2rem;
|
||||
padding : .6rem;
|
||||
background : #0e0e14;
|
||||
border : 1px solid #26262f;
|
||||
border-radius : 3px;
|
||||
}
|
||||
.log-label { font-size: .6rem; color: #444; letter-spacing: .1em; text-transform: uppercase; margin-bottom: .1rem; }
|
||||
.log-entry { font-size: .68rem; color: #4ade80; }
|
||||
.log-empty { font-size: .68rem; color: #333; font-style: italic; }
|
||||
|
||||
/* ── REF PANEL ───────────────────────────────────────────────────────── */
|
||||
.ref-panel {
|
||||
display : flex; flex-direction: column; gap: .3rem;
|
||||
padding : .6rem;
|
||||
background : #0e0e14;
|
||||
border : 1px solid #26262f;
|
||||
border-radius : 3px;
|
||||
font-size : .72rem;
|
||||
}
|
||||
.ref-label { font-size: .6rem; color: #444; letter-spacing: .1em; text-transform: uppercase; }
|
||||
.ref-row { display: flex; align-items: center; gap: .5rem; }
|
||||
.ref-key { color: #555; min-width: 4rem; }
|
||||
.ref-empty { color: #333; font-style: italic; font-size: .68rem; }
|
||||
|
||||
code {
|
||||
background : #26262f; padding: .1rem .35rem;
|
||||
border-radius: 2px; font-size: .68rem;
|
||||
color : #818cf8; font-family: inherit;
|
||||
}
|
||||
</style>
|
||||
@ -1,126 +1,311 @@
|
||||
<script lang="ts">
|
||||
import { Collapsible } from '$uix/acel';
|
||||
|
||||
let controlled = $state(false);
|
||||
let changeLog = $state<string[]>([]);
|
||||
|
||||
function logChange(v: boolean) {
|
||||
changeLog = [`onOpenChange(${v})`, ...changeLog].slice(0, 5);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-8 max-w-2xl mx-auto">
|
||||
<h1 class="text-2xl font-bold mb-6">Collapsible Component Test</h1>
|
||||
|
||||
<div class="space-y-8">
|
||||
<!-- Basic Collapsible -->
|
||||
<section>
|
||||
<h2 class="text-lg font-semibold mb-3">Basic Collapsible</h2>
|
||||
<Collapsible.Root class="border rounded-lg">
|
||||
<Collapsible.Trigger class="flex w-full items-center justify-between p-4 hover:bg-gray-100 rounded-lg">
|
||||
<span>Click to expand</span>
|
||||
<span class="collapsible-chevron">▼</span>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content class="p-4 border-t">
|
||||
<p>This content is hidden by default and will be shown when the trigger is clicked.</p>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
</section>
|
||||
|
||||
<!-- Controlled Collapsible -->
|
||||
<section>
|
||||
<h2 class="text-lg font-semibold mb-3">Controlled Collapsible (bind:open)</h2>
|
||||
<script lang="ts">
|
||||
let isOpen = $state(false);
|
||||
</script>
|
||||
|
||||
<div class="flex gap-2 mb-3">
|
||||
<button
|
||||
class="px-3 py-1 bg-blue-500 text-white rounded"
|
||||
onclick={() => isOpen = true}
|
||||
>
|
||||
Open
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-1 bg-gray-500 text-white rounded"
|
||||
onclick={() => isOpen = false}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-1 bg-green-500 text-white rounded"
|
||||
onclick={() => isOpen = !isOpen}
|
||||
>
|
||||
Toggle
|
||||
</button>
|
||||
<div class="page">
|
||||
|
||||
<header class="ph">
|
||||
<div class="ph-inner">
|
||||
<div class="ph-brand">
|
||||
<span class="ph-dot"></span>
|
||||
<span class="ph-title">col<em>lapsible</em></span>
|
||||
</div>
|
||||
|
||||
<Collapsible.Root bind:open={isOpen} class="border rounded-lg">
|
||||
<Collapsible.Trigger class="flex w-full items-center justify-between p-4 hover:bg-gray-100 rounded-lg">
|
||||
<span>Controlled content (open: {isOpen})</span>
|
||||
<span class="collapsible-chevron">▼</span>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content class="p-4 border-t">
|
||||
<p>This collapsible is controlled externally. The open state is: <strong>{isOpen ? 'open' : 'closed'}</strong></p>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
</section>
|
||||
|
||||
<!-- Disabled Collapsible -->
|
||||
<section>
|
||||
<h2 class="text-lg font-semibold mb-3">Disabled Collapsible</h2>
|
||||
<Collapsible.Root disabled={true} class="border rounded-lg opacity-50">
|
||||
<Collapsible.Trigger class="flex w-full items-center justify-between p-4 cursor-not-allowed">
|
||||
<span>Disabled - cannot be opened</span>
|
||||
<span>▼</span>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content class="p-4 border-t">
|
||||
<p>This content should not be accessible.</p>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
</section>
|
||||
|
||||
<!-- Multiple Collapsibles -->
|
||||
<section>
|
||||
<h2 class="text-lg font-semibold mb-3">Multiple Independent Collapsibles</h2>
|
||||
<div class="space-y-2">
|
||||
<Collapsible.Root class="border rounded-lg">
|
||||
<Collapsible.Trigger class="flex w-full items-center justify-between p-4 hover:bg-gray-100 rounded-lg">
|
||||
<span>Section 1</span>
|
||||
<span class="collapsible-chevron">▼</span>
|
||||
<span class="ph-sub">acel · primitivo colapsable</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div class="grid">
|
||||
|
||||
<!-- ── 1. BÁSICO ──────────────────────────────────────────────── -->
|
||||
<section class="card" data-color="violet">
|
||||
<header class="card-hd">
|
||||
<span class="tag">01</span>
|
||||
<span class="card-title">Básico</span>
|
||||
</header>
|
||||
<p class="card-desc">
|
||||
Trigger y Content sin estado externo. El Trigger interno
|
||||
actúa como toggle automático.
|
||||
</p>
|
||||
|
||||
<Collapsible.Root class="coll">
|
||||
<Collapsible.Trigger class="coll-trigger">
|
||||
<span>Ver detalles</span>
|
||||
<span class="chevron">▾</span>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content class="p-4 border-t">
|
||||
<p>Content for section 1. Each collapsible is independent.</p>
|
||||
<Collapsible.Content class="coll-content">
|
||||
<p>
|
||||
Este contenido está oculto por defecto. El componente gestiona
|
||||
su propio estado interno sin necesidad de código externo.
|
||||
</p>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
|
||||
<Collapsible.Root class="border rounded-lg">
|
||||
<Collapsible.Trigger class="flex w-full items-center justify-between p-4 hover:bg-gray-100 rounded-lg">
|
||||
<span>Section 2</span>
|
||||
<span class="collapsible-chevron">▼</span>
|
||||
</section>
|
||||
|
||||
<!-- ── 2. CONTROLADO ──────────────────────────────────────────── -->
|
||||
<section class="card" data-color="teal">
|
||||
<header class="card-hd">
|
||||
<span class="tag">02</span>
|
||||
<span class="card-title">Controlado externamente</span>
|
||||
</header>
|
||||
<p class="card-desc">
|
||||
<code>bind:open</code> sincroniza el estado con una variable del padre.
|
||||
</p>
|
||||
|
||||
<div class="state-row">
|
||||
<span class="state-label">open:</span>
|
||||
<span class="state-val" class:state-val--on={controlled}>{controlled}</span>
|
||||
</div>
|
||||
|
||||
<div class="btn-row">
|
||||
<button class="btn btn--teal" onclick={() => controlled = true}>Abrir</button>
|
||||
<button class="btn btn--ghost" onclick={() => controlled = false}>Cerrar</button>
|
||||
<button class="btn btn--ghost" onclick={() => controlled = !controlled}>Toggle</button>
|
||||
</div>
|
||||
|
||||
<Collapsible.Root bind:open={controlled} class="coll">
|
||||
<Collapsible.Trigger class="coll-trigger">
|
||||
<span>Estado controlado</span>
|
||||
<span class="chevron">▾</span>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content class="p-4 border-t">
|
||||
<p>Content for section 2. Multiple collapsibles can be open at the same time.</p>
|
||||
<Collapsible.Content class="coll-content">
|
||||
<p>
|
||||
Controlado desde fuera. El trigger interno también funciona
|
||||
y actualiza la variable del padre.
|
||||
</p>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
|
||||
<Collapsible.Root class="border rounded-lg">
|
||||
<Collapsible.Trigger class="flex w-full items-center justify-between p-4 hover:bg-gray-100 rounded-lg">
|
||||
<span>Section 3</span>
|
||||
<span class="collapsible-chevron">▼</span>
|
||||
</section>
|
||||
|
||||
<!-- ── 3. CALLBACK ────────────────────────────────────────────── -->
|
||||
<section class="card" data-color="amber">
|
||||
<header class="card-hd">
|
||||
<span class="tag">03</span>
|
||||
<span class="card-title">Callback onOpenChange</span>
|
||||
</header>
|
||||
<p class="card-desc">
|
||||
Registra cada cambio de estado en tiempo real.
|
||||
</p>
|
||||
|
||||
<Collapsible.Root onOpenChange={logChange} class="coll">
|
||||
<Collapsible.Trigger class="coll-trigger">
|
||||
<span>Abrir / cerrar</span>
|
||||
<span class="chevron">▾</span>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content class="p-4 border-t">
|
||||
<p>Content for section 3. Unlike an accordion, all sections can be open simultaneously.</p>
|
||||
<Collapsible.Content class="coll-content">
|
||||
<p>Cada apertura y cierre genera un evento. Cierra para ver el log.</p>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="log-panel">
|
||||
<span class="log-label">onOpenChange</span>
|
||||
{#each changeLog as entry}
|
||||
<span class="log-entry">{entry}</span>
|
||||
{:else}
|
||||
<span class="log-empty">— sin eventos aún</span>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── 4. DISABLED ────────────────────────────────────────────── -->
|
||||
<section class="card" data-color="rose">
|
||||
<header class="card-hd">
|
||||
<span class="tag">04</span>
|
||||
<span class="card-title">Deshabilitado</span>
|
||||
</header>
|
||||
<p class="card-desc">
|
||||
<code>disabled</code> bloquea el trigger. El contenido no puede abrirse.
|
||||
</p>
|
||||
|
||||
<Collapsible.Root disabled class="coll coll--disabled">
|
||||
<Collapsible.Trigger class="coll-trigger">
|
||||
<span>No se puede abrir</span>
|
||||
<span class="chevron">▾</span>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content class="coll-content">
|
||||
<p>Este contenido no debería ser visible.</p>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
</section>
|
||||
|
||||
<!-- ── 5. MÚLTIPLES INDEPENDIENTES ───────────────────────────── -->
|
||||
<section class="card card--wide" data-color="indigo">
|
||||
<header class="card-hd">
|
||||
<span class="tag">05</span>
|
||||
<span class="card-title">Múltiples independientes</span>
|
||||
</header>
|
||||
<p class="card-desc">
|
||||
A diferencia del Accordion, varios Collapsibles pueden estar abiertos simultáneamente.
|
||||
</p>
|
||||
|
||||
<div class="multi-grid">
|
||||
{#each [
|
||||
{ title: 'Configuración general', body: 'Ajustes globales de la aplicación. Idioma, zona horaria y preferencias de notificación.' },
|
||||
{ title: 'Seguridad y privacidad', body: 'Contraseña, autenticación en dos pasos y permisos de acceso a datos.' },
|
||||
{ title: 'Integraciones', body: 'Conecta con servicios externos. GitHub, Slack, Google Workspace y más.' },
|
||||
] as item}
|
||||
<Collapsible.Root class="coll">
|
||||
<Collapsible.Trigger class="coll-trigger">
|
||||
<span>{item.title}</span>
|
||||
<span class="chevron">▾</span>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content class="coll-content">
|
||||
<p>{item.body}</p>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.collapsible-chevron {
|
||||
transition: transform 0.2s;
|
||||
:global(*, *::before, *::after) { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
.page {
|
||||
min-height : 100vh;
|
||||
background : #0e0e14;
|
||||
color : #d4d2cc;
|
||||
font-family: 'DM Mono', 'Fira Code', monospace;
|
||||
font-size : 13px;
|
||||
}
|
||||
|
||||
/* ── PAGE HEADER ─────────────────────────────────────────────────────── */
|
||||
.ph { border-bottom: 2px solid #a78bfa; background: #080810; }
|
||||
.ph-inner {
|
||||
max-width: 1200px; margin: 0 auto;
|
||||
padding : .75rem 1.5rem;
|
||||
display : flex; align-items: center; justify-content: space-between;
|
||||
}
|
||||
.ph-brand { display: flex; align-items: center; gap: .5rem; }
|
||||
.ph-dot { width: 8px; height: 8px; border-radius: 50%; background: #a78bfa; }
|
||||
.ph-title {
|
||||
font-size: .85rem; font-weight: 700;
|
||||
letter-spacing: .15em; text-transform: uppercase; color: #f4f3ef;
|
||||
}
|
||||
.ph-title em { color: #a78bfa; font-style: normal; }
|
||||
.ph-sub { font-size: .65rem; color: #555; letter-spacing: .1em; }
|
||||
|
||||
/* ── MAIN ────────────────────────────────────────────────────────────── */
|
||||
main { max-width: 1200px; margin: 0 auto; padding: 1.5rem; }
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: .75rem;
|
||||
}
|
||||
|
||||
/* ── CARDS ───────────────────────────────────────────────────────────── */
|
||||
.card {
|
||||
background : #16161f;
|
||||
border : 1px solid #26262f;
|
||||
border-radius: 6px;
|
||||
padding : 1.1rem;
|
||||
display : flex; flex-direction: column; gap: .75rem;
|
||||
border-top : 3px solid transparent;
|
||||
}
|
||||
.card[data-color="violet"] { border-top-color: #a78bfa; }
|
||||
.card[data-color="teal"] { border-top-color: #2dd4bf; }
|
||||
.card[data-color="amber"] { border-top-color: #e5a100; }
|
||||
.card[data-color="rose"] { border-top-color: #fb7185; }
|
||||
.card[data-color="indigo"] { border-top-color: #818cf8; }
|
||||
.card--wide { grid-column: 1 / -1; }
|
||||
|
||||
.card-hd {
|
||||
display : flex; align-items: center; gap: .5rem;
|
||||
padding-bottom: .6rem;
|
||||
border-bottom : 1px solid #26262f;
|
||||
}
|
||||
.tag { font-size: .6rem; font-weight: 700; letter-spacing: .15em; color: #555; }
|
||||
.card-title { font-size: .75rem; font-weight: 600; color: #aaa; }
|
||||
.card-desc { font-size: .72rem; color: #555; line-height: 1.5; }
|
||||
|
||||
.state-row { display: flex; align-items: center; gap: .5rem; font-size: .72rem; }
|
||||
.state-label { color: #555; }
|
||||
.state-val { color: #444; font-weight: 700; transition: color .2s; }
|
||||
.state-val--on { color: #2dd4bf; }
|
||||
|
||||
.btn-row { display: flex; gap: .4rem; }
|
||||
|
||||
/* ── BUTTONS ─────────────────────────────────────────────────────────── */
|
||||
.btn {
|
||||
padding : .3rem .65rem;
|
||||
border : none; border-radius: 3px;
|
||||
font-family: 'DM Mono', 'Fira Code', monospace;
|
||||
font-size : .68rem; font-weight: 700;
|
||||
cursor : pointer; transition: opacity .12s;
|
||||
}
|
||||
|
||||
[data-state="open"] .collapsible-chevron {
|
||||
transform: rotate(180deg);
|
||||
.btn:hover { opacity: .8; }
|
||||
.btn--teal { background: #0d2d2a; color: #5eead4; }
|
||||
.btn--ghost {
|
||||
background: transparent; border: 1px solid #333;
|
||||
color: #666; font-weight: 500;
|
||||
}
|
||||
|
||||
[data-state="closed"] .collapsible-chevron {
|
||||
transform: rotate(0deg);
|
||||
.btn--ghost:hover { border-color: #555; color: #999; opacity: 1; }
|
||||
|
||||
/* ── LOG PANEL ───────────────────────────────────────────────────────── */
|
||||
.log-panel {
|
||||
display : flex; flex-direction: column; gap: .2rem;
|
||||
padding : .6rem;
|
||||
background : #0e0e14;
|
||||
border : 1px solid #26262f;
|
||||
border-radius : 3px;
|
||||
}
|
||||
.log-label { font-size: .6rem; color: #444; letter-spacing: .1em; text-transform: uppercase; margin-bottom: .1rem; }
|
||||
.log-entry { font-size: .68rem; color: #e5a100; }
|
||||
.log-empty { font-size: .68rem; color: #333; font-style: italic; }
|
||||
|
||||
/* ── COLLAPSIBLE ─────────────────────────────────────────────────────── */
|
||||
:global(.coll) {
|
||||
border : 1px solid #26262f;
|
||||
border-radius: 4px;
|
||||
overflow : hidden;
|
||||
}
|
||||
:global(.coll--disabled) { opacity: .4; }
|
||||
|
||||
:global(.coll-trigger) {
|
||||
width : 100%;
|
||||
display : flex; align-items: center; justify-content: space-between;
|
||||
padding : .65rem .85rem;
|
||||
background : transparent; border: none;
|
||||
color : #aaa;
|
||||
font-family: 'DM Mono', 'Fira Code', monospace;
|
||||
font-size : .75rem; font-weight: 600;
|
||||
cursor : pointer; text-align: left;
|
||||
transition : color .15s, background .15s;
|
||||
}
|
||||
:global(.coll-trigger:hover) { color: #f0ede6; background: #1e1e2a; }
|
||||
:global([data-state="open"] > .coll-trigger) { color: #f0ede6; border-bottom: 1px solid #26262f; }
|
||||
|
||||
:global(.coll-content) {
|
||||
font-size : .75rem; color: #666; line-height: 1.65;
|
||||
overflow : hidden;
|
||||
}
|
||||
:global([data-state="open"] .coll-content) { padding: .65rem .85rem; }
|
||||
:global([data-state="closed"] .coll-content) { padding: 0; }
|
||||
|
||||
.chevron {
|
||||
font-size: .8rem; color: #555;
|
||||
transition: transform .2s; flex-shrink: 0;
|
||||
}
|
||||
:global([data-state="open"] .chevron) { transform: rotate(180deg); color: #a78bfa; }
|
||||
:global([data-state="closed"] .chevron) { transform: rotate(0deg); }
|
||||
|
||||
/* ── MULTI GRID ──────────────────────────────────────────────────────── */
|
||||
.multi-grid { display: flex; flex-direction: column; gap: .4rem; }
|
||||
|
||||
code {
|
||||
background: #26262f; padding: .1rem .35rem;
|
||||
border-radius: 2px; font-size: .68rem;
|
||||
color: #a78bfa; font-family: inherit;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -0,0 +1,433 @@
|
||||
<script lang="ts">
|
||||
import { Dialog } from '$uix/acel';
|
||||
|
||||
let controlledOpen = $state(false);
|
||||
let onChangeLog = $state<string[]>([]);
|
||||
let onCompleteLog = $state<string[]>([]);
|
||||
|
||||
function logChange(v: boolean) {
|
||||
onChangeLog = [`onOpenChange(${v})`, ...onChangeLog].slice(0, 4);
|
||||
}
|
||||
function logComplete(v: boolean) {
|
||||
onCompleteLog = [`onOpenChangeComplete(${v})`, ...onCompleteLog].slice(0, 4);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
|
||||
<header class="ph">
|
||||
<div class="ph-inner">
|
||||
<div class="ph-brand">
|
||||
<span class="ph-dot"></span>
|
||||
<span class="ph-title">dia<em>log</em></span>
|
||||
</div>
|
||||
<span class="ph-sub">acel · primitivo de diálogo</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
|
||||
<div class="grid">
|
||||
|
||||
<!-- ── 1. BÁSICO ──────────────────────────────────────────────── -->
|
||||
<section class="card" data-color="indigo">
|
||||
<header class="card-hd">
|
||||
<span class="tag">01</span>
|
||||
<span class="card-title">Básico con cierre</span>
|
||||
</header>
|
||||
<p class="card-desc">
|
||||
Dialog con botón <code>Close</code> interno y cierre por Escape o fondo.
|
||||
</p>
|
||||
|
||||
<Dialog.Root>
|
||||
<Dialog.Trigger class="btn btn--indigo">
|
||||
Abrir diálogo
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="overlay" />
|
||||
<Dialog.Content class="dialog">
|
||||
<div class="dialog-header">
|
||||
<Dialog.Title class="dialog-title">Título del diálogo</Dialog.Title>
|
||||
<Dialog.Close class="close-btn" aria-label="Cerrar">✕</Dialog.Close>
|
||||
</div>
|
||||
<Dialog.Description class="dialog-desc">
|
||||
Este es un diálogo básico. Puedes cerrarlo con el botón ✕,
|
||||
presionando Escape, o haciendo clic en el overlay.
|
||||
</Dialog.Description>
|
||||
<div class="dialog-footer">
|
||||
<Dialog.Close class="btn btn--ghost">Cancelar</Dialog.Close>
|
||||
<Dialog.Close class="btn btn--indigo">Aceptar</Dialog.Close>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
</section>
|
||||
|
||||
<!-- ── 2. FORM DIALOG ────────────────────────────────────────── -->
|
||||
<section class="card" data-color="teal">
|
||||
<header class="card-hd">
|
||||
<span class="tag">02</span>
|
||||
<span class="card-title">Con formulario</span>
|
||||
</header>
|
||||
<p class="card-desc">
|
||||
Dialog con campos de entrada. El contenido puede ser cualquier HTML.
|
||||
</p>
|
||||
|
||||
<Dialog.Root>
|
||||
<Dialog.Trigger class="btn btn--teal">
|
||||
Editar perfil
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="overlay" />
|
||||
<Dialog.Content class="dialog dialog--wide">
|
||||
<div class="dialog-header">
|
||||
<Dialog.Title class="dialog-title">Editar perfil</Dialog.Title>
|
||||
<Dialog.Close class="close-btn" aria-label="Cerrar">✕</Dialog.Close>
|
||||
</div>
|
||||
<Dialog.Description class="dialog-desc">
|
||||
Actualiza tu información de perfil. Los cambios se guardarán al confirmar.
|
||||
</Dialog.Description>
|
||||
<div class="form-grid">
|
||||
<label class="field">
|
||||
<span class="field-label">Nombre</span>
|
||||
<input class="field-input" type="text" placeholder="Tu nombre" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">Email</span>
|
||||
<input class="field-input" type="email" placeholder="tu@email.com" />
|
||||
</label>
|
||||
<label class="field field--full">
|
||||
<span class="field-label">Bio</span>
|
||||
<textarea class="field-input field-textarea" placeholder="Cuéntanos sobre ti…"></textarea>
|
||||
</label>
|
||||
</div>
|
||||
<div class="dialog-footer">
|
||||
<Dialog.Close class="btn btn--ghost">Descartar</Dialog.Close>
|
||||
<Dialog.Close class="btn btn--teal">Guardar cambios</Dialog.Close>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
</section>
|
||||
|
||||
<!-- ── 3. CALLBACKS ────────────────────────────────────────────── -->
|
||||
<section class="card" data-color="amber">
|
||||
<header class="card-hd">
|
||||
<span class="tag">03</span>
|
||||
<span class="card-title">Callbacks de estado</span>
|
||||
</header>
|
||||
<p class="card-desc">
|
||||
Muestra <code>onOpenChange</code> y <code>onOpenChangeComplete</code> en tiempo real.
|
||||
</p>
|
||||
|
||||
<Dialog.Root onOpenChange={logChange} onOpenChangeComplete={logComplete}>
|
||||
<Dialog.Trigger class="btn btn--amber">Abrir</Dialog.Trigger>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="overlay" />
|
||||
<Dialog.Content class="dialog">
|
||||
<div class="dialog-header">
|
||||
<Dialog.Title class="dialog-title">Diálogo con callbacks</Dialog.Title>
|
||||
<Dialog.Close class="close-btn" aria-label="Cerrar">✕</Dialog.Close>
|
||||
</div>
|
||||
<Dialog.Description class="dialog-desc">
|
||||
Cierra el diálogo y observa los logs de callbacks abajo.
|
||||
</Dialog.Description>
|
||||
<div class="dialog-footer">
|
||||
<Dialog.Close class="btn btn--amber">Cerrar</Dialog.Close>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
|
||||
<div class="log-panel">
|
||||
<div class="log-section">
|
||||
<span class="log-label">onOpenChange</span>
|
||||
{#each onChangeLog as entry}
|
||||
<span class="log-entry">{entry}</span>
|
||||
{:else}
|
||||
<span class="log-empty">—</span>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="log-section">
|
||||
<span class="log-label">onOpenChangeComplete</span>
|
||||
{#each onCompleteLog as entry}
|
||||
<span class="log-entry log-entry--complete">{entry}</span>
|
||||
{:else}
|
||||
<span class="log-empty">—</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── 4. CONTROLLED ──────────────────────────────────────────── -->
|
||||
<section class="card" data-color="violet">
|
||||
<header class="card-hd">
|
||||
<span class="tag">04</span>
|
||||
<span class="card-title">Controlado externamente</span>
|
||||
</header>
|
||||
<p class="card-desc">
|
||||
<code>bind:open</code> permite controlar el estado desde el padre.
|
||||
</p>
|
||||
|
||||
<div class="state-row">
|
||||
<span class="state-label">open:</span>
|
||||
<span class="state-val" class:state-val--on={controlledOpen}>{controlledOpen}</span>
|
||||
</div>
|
||||
|
||||
<div class="btn-row">
|
||||
<button class="btn btn--violet" onclick={() => controlledOpen = true}>Abrir</button>
|
||||
<button class="btn btn--ghost" onclick={() => controlledOpen = false}>Cerrar</button>
|
||||
</div>
|
||||
|
||||
<Dialog.Root bind:open={controlledOpen}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay class="overlay" />
|
||||
<Dialog.Content class="dialog">
|
||||
<div class="dialog-header">
|
||||
<Dialog.Title class="dialog-title">Diálogo controlado</Dialog.Title>
|
||||
<Dialog.Close class="close-btn" aria-label="Cerrar">✕</Dialog.Close>
|
||||
</div>
|
||||
<Dialog.Description class="dialog-desc">
|
||||
El estado <code>open</code> es gestionado externamente.
|
||||
Al cerrar se actualiza la variable del padre.
|
||||
</Dialog.Description>
|
||||
<div class="dialog-footer">
|
||||
<Dialog.Close class="btn btn--ghost">Cancelar</Dialog.Close>
|
||||
<Dialog.Close class="btn btn--violet">Confirmar</Dialog.Close>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
</section>
|
||||
|
||||
<!-- ── 5. SIN OVERLAY ─────────────────────────────────────────── -->
|
||||
<section class="card" data-color="rose">
|
||||
<header class="card-hd">
|
||||
<span class="tag">05</span>
|
||||
<span class="card-title">Sin overlay</span>
|
||||
</header>
|
||||
<p class="card-desc">
|
||||
El <code>Overlay</code> es opcional. Aquí solo aparece el contenido flotante.
|
||||
</p>
|
||||
|
||||
<Dialog.Root>
|
||||
<Dialog.Trigger class="btn btn--rose">Abrir sin overlay</Dialog.Trigger>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Content class="dialog dialog--bordered">
|
||||
<div class="dialog-header">
|
||||
<Dialog.Title class="dialog-title">Sin fondo oscuro</Dialog.Title>
|
||||
<Dialog.Close class="close-btn" aria-label="Cerrar">✕</Dialog.Close>
|
||||
</div>
|
||||
<Dialog.Description class="dialog-desc">
|
||||
Este diálogo no tiene overlay detrás. La página sigue visible.
|
||||
Cierra con Escape o con el botón ✕.
|
||||
</Dialog.Description>
|
||||
<div class="dialog-footer">
|
||||
<Dialog.Close class="btn btn--rose">Cerrar</Dialog.Close>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(*, *::before, *::after) { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
.page {
|
||||
min-height : 100vh;
|
||||
background : #0e0e14;
|
||||
color : #d4d2cc;
|
||||
font-family: 'DM Mono', 'Fira Code', monospace;
|
||||
font-size : 13px;
|
||||
}
|
||||
|
||||
/* ── PAGE HEADER ──────────────────────────────────────────────────────── */
|
||||
.ph {
|
||||
border-bottom: 2px solid #818cf8;
|
||||
background : #080810;
|
||||
}
|
||||
.ph-inner {
|
||||
max-width: 1200px; margin: 0 auto;
|
||||
padding : .75rem 1.5rem;
|
||||
display : flex; align-items: center; justify-content: space-between;
|
||||
}
|
||||
.ph-brand { display: flex; align-items: center; gap: .5rem; }
|
||||
.ph-dot { width: 8px; height: 8px; border-radius: 50%; background: #818cf8; }
|
||||
.ph-title {
|
||||
font-size: .85rem; font-weight: 700;
|
||||
letter-spacing: .15em; text-transform: uppercase; color: #f4f3ef;
|
||||
}
|
||||
.ph-title em { color: #818cf8; font-style: normal; }
|
||||
.ph-sub { font-size: .65rem; color: #555; letter-spacing: .1em; }
|
||||
|
||||
/* ── MAIN ─────────────────────────────────────────────────────────────── */
|
||||
main {
|
||||
max-width: 1200px; margin: 0 auto;
|
||||
padding : 1.5rem;
|
||||
}
|
||||
|
||||
/* ── GRID ──────────────────────────────────────────────────────────────── */
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: .75rem;
|
||||
}
|
||||
|
||||
/* ── CARDS ────────────────────────────────────────────────────────────── */
|
||||
.card {
|
||||
background : #16161f;
|
||||
border : 1px solid #26262f;
|
||||
border-radius: 6px;
|
||||
padding : 1.1rem;
|
||||
display : flex; flex-direction: column; gap: .75rem;
|
||||
border-top : 3px solid transparent;
|
||||
}
|
||||
.card[data-color="indigo"] { border-top-color: #818cf8; }
|
||||
.card[data-color="teal"] { border-top-color: #2dd4bf; }
|
||||
.card[data-color="amber"] { border-top-color: #e5a100; }
|
||||
.card[data-color="violet"] { border-top-color: #a78bfa; }
|
||||
.card[data-color="rose"] { border-top-color: #fb7185; }
|
||||
|
||||
.card-hd {
|
||||
display : flex; align-items: center; gap: .5rem;
|
||||
padding-bottom: .6rem;
|
||||
border-bottom : 1px solid #26262f;
|
||||
}
|
||||
.tag { font-size: .6rem; font-weight: 700; letter-spacing: .15em; color: #555; }
|
||||
.card-title { font-size: .75rem; font-weight: 600; color: #aaa; }
|
||||
.card-desc { font-size: .72rem; color: #555; line-height: 1.5; }
|
||||
|
||||
/* ── STATE ────────────────────────────────────────────────────────────── */
|
||||
.state-row { display: flex; align-items: center; gap: .5rem; font-size: .72rem; }
|
||||
.state-label { color: #555; }
|
||||
.state-val { color: #444; font-weight: 700; transition: color .2s; }
|
||||
.state-val--on { color: #a78bfa; }
|
||||
.btn-row { display: flex; gap: .4rem; }
|
||||
|
||||
/* ── LOG PANEL ────────────────────────────────────────────────────────── */
|
||||
.log-panel {
|
||||
display : flex; flex-direction: column; gap: .4rem;
|
||||
padding : .65rem;
|
||||
background : #0e0e14;
|
||||
border : 1px solid #26262f;
|
||||
border-radius : 3px;
|
||||
}
|
||||
.log-section { display: flex; flex-direction: column; gap: .2rem; }
|
||||
.log-label { font-size: .6rem; color: #444; letter-spacing: .1em; text-transform: uppercase; }
|
||||
.log-entry { font-size: .68rem; color: #e5a100; }
|
||||
.log-entry--complete { color: #4ade80; }
|
||||
.log-empty { font-size: .68rem; color: #333; font-style: italic; }
|
||||
|
||||
/* ── BUTTONS ──────────────────────────────────────────────────────────── */
|
||||
:global(.btn) {
|
||||
padding : .35rem .8rem;
|
||||
border : none; border-radius: 3px;
|
||||
font-family: 'DM Mono', 'Fira Code', monospace;
|
||||
font-size : .72rem; font-weight: 700;
|
||||
letter-spacing: .05em; cursor: pointer;
|
||||
transition : opacity .12s;
|
||||
}
|
||||
:global(.btn:hover) { opacity: .8; }
|
||||
|
||||
:global(.btn--indigo) { background: #1e1b4b; color: #a5b4fc; }
|
||||
:global(.btn--teal) { background: #0d2d2a; color: #5eead4; }
|
||||
:global(.btn--amber) { background: #78350f; color: #fcd34d; }
|
||||
:global(.btn--violet) { background: #2e1065; color: #c4b5fd; }
|
||||
:global(.btn--rose) { background: #4c0519; color: #fda4af; }
|
||||
:global(.btn--ghost) {
|
||||
background: transparent;
|
||||
border : 1px solid #333; color: #666; font-weight: 500;
|
||||
}
|
||||
:global(.btn--ghost:hover) { border-color: #555; color: #999; opacity: 1; }
|
||||
|
||||
/* ── OVERLAY ──────────────────────────────────────────────────────────── */
|
||||
:global(.overlay) {
|
||||
position : fixed; inset: 0;
|
||||
background : rgba(0, 0, 0, .65);
|
||||
backdrop-filter: blur(2px);
|
||||
z-index : 50;
|
||||
}
|
||||
|
||||
/* ── DIALOG ───────────────────────────────────────────────────────────── */
|
||||
:global(.dialog) {
|
||||
position : fixed;
|
||||
top : 50%; left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index : 51;
|
||||
width : min(480px, calc(100vw - 2rem));
|
||||
background : #1a1a24;
|
||||
border : 1px solid #2e2e3e;
|
||||
border-radius: 8px;
|
||||
padding : 1.75rem;
|
||||
display : flex; flex-direction: column; gap: 1rem;
|
||||
box-shadow : 0 24px 60px rgba(0,0,0,.6);
|
||||
}
|
||||
:global(.dialog--wide) { width: min(560px, calc(100vw - 2rem)); }
|
||||
:global(.dialog--bordered) { border-color: #818cf8; box-shadow: 0 0 0 1px #818cf840, 0 24px 60px rgba(0,0,0,.6); }
|
||||
|
||||
:global(.dialog-header) {
|
||||
display : flex;
|
||||
align-items : flex-start;
|
||||
justify-content: space-between;
|
||||
gap : .5rem;
|
||||
}
|
||||
:global(.dialog-title) {
|
||||
font-size : .95rem; font-weight: 700;
|
||||
color : #f0ede6; letter-spacing: .02em;
|
||||
}
|
||||
:global(.close-btn) {
|
||||
background : transparent; border: none;
|
||||
color : #555; cursor: pointer;
|
||||
font-size : .9rem; padding: .1rem .25rem;
|
||||
line-height: 1; border-radius: 3px;
|
||||
transition : color .12s, background .12s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
:global(.close-btn:hover) { color: #f0ede6; background: #26262f; }
|
||||
|
||||
:global(.dialog-desc) {
|
||||
font-size : .78rem; color: #777;
|
||||
line-height: 1.65;
|
||||
}
|
||||
:global(.dialog-footer) {
|
||||
display : flex; justify-content: flex-end; gap: .5rem;
|
||||
padding-top: .5rem;
|
||||
border-top : 1px solid #26262f;
|
||||
}
|
||||
|
||||
/* ── FORM ─────────────────────────────────────────────────────────────── */
|
||||
:global(.form-grid) {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: .65rem;
|
||||
}
|
||||
:global(.field) { display: flex; flex-direction: column; gap: .25rem; }
|
||||
:global(.field--full) { grid-column: 1 / -1; }
|
||||
:global(.field-label) { font-size: .65rem; color: #555; letter-spacing: .08em; text-transform: uppercase; }
|
||||
:global(.field-input) {
|
||||
background : #0e0e14;
|
||||
border : 1px solid #26262f;
|
||||
border-radius: 3px;
|
||||
padding : .4rem .6rem;
|
||||
color : #d4d2cc;
|
||||
font-family : 'DM Mono', 'Fira Code', monospace;
|
||||
font-size : .75rem;
|
||||
outline : none;
|
||||
transition : border-color .15s;
|
||||
}
|
||||
:global(.field-input:focus) { border-color: #818cf8; }
|
||||
:global(.field-textarea) { resize: vertical; min-height: 72px; }
|
||||
|
||||
code {
|
||||
background : #26262f;
|
||||
padding : .1rem .35rem;
|
||||
border-radius: 2px;
|
||||
font-size : .7rem;
|
||||
color : #818cf8;
|
||||
font-family : inherit;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,225 @@
|
||||
<script lang="ts">
|
||||
import { Tabs } from '$uix/acel';
|
||||
|
||||
let selectedTab = $state('account');
|
||||
let manualTab = $state('overview');
|
||||
</script>
|
||||
|
||||
<div class="p-8 max-w-2xl mx-auto">
|
||||
<h1 class="text-2xl font-bold mb-6">Tabs Component Test</h1>
|
||||
|
||||
<div class="space-y-8">
|
||||
<!-- Basic Tabs -->
|
||||
<section>
|
||||
<h2 class="text-lg font-semibold mb-3">Basic Tabs</h2>
|
||||
<Tabs.Root class="w-full">
|
||||
<Tabs.List class="flex border-b border-gray-200 dark:border-gray-700">
|
||||
<Tabs.Trigger
|
||||
value="tab1"
|
||||
class="px-4 py-2 -mb-px border-b-2 border-transparent hover:border-gray-300 data-[state=active]:border-blue-500 data-[state=active]:text-blue-600 transition-colors"
|
||||
>
|
||||
Tab 1
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger
|
||||
value="tab2"
|
||||
class="px-4 py-2 -mb-px border-b-2 border-transparent hover:border-gray-300 data-[state=active]:border-blue-500 data-[state=active]:text-blue-600 transition-colors"
|
||||
>
|
||||
Tab 2
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger
|
||||
value="tab3"
|
||||
class="px-4 py-2 -mb-px border-b-2 border-transparent hover:border-gray-300 data-[state=active]:border-blue-500 data-[state=active]:text-blue-600 transition-colors"
|
||||
>
|
||||
Tab 3
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
<Tabs.Content value="tab1" class="p-4">
|
||||
<p>Content for Tab 1. This is the first tab's content.</p>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="tab2" class="p-4">
|
||||
<p>Content for Tab 2. This is the second tab's content.</p>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="tab3" class="p-4">
|
||||
<p>Content for Tab 3. This is the third tab's content.</p>
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
</section>
|
||||
|
||||
<!-- Controlled Tabs -->
|
||||
<section>
|
||||
<h2 class="text-lg font-semibold mb-3">Controlled Tabs (bind:value)</h2>
|
||||
|
||||
<div class="flex gap-2 mb-3">
|
||||
<button
|
||||
class="px-3 py-1 bg-blue-500 text-white rounded text-sm"
|
||||
onclick={() => selectedTab = 'account'}
|
||||
>
|
||||
Account
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-1 bg-green-500 text-white rounded text-sm"
|
||||
onclick={() => selectedTab = 'password'}
|
||||
>
|
||||
Password
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-1 bg-purple-500 text-white rounded text-sm"
|
||||
onclick={() => selectedTab = 'settings'}
|
||||
>
|
||||
Settings
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-500 mb-3">Current tab: <strong>{selectedTab}</strong></p>
|
||||
|
||||
<Tabs.Root bind:value={selectedTab} class="w-full border rounded-lg">
|
||||
<Tabs.List class="flex bg-gray-50 dark:bg-gray-800 rounded-t-lg">
|
||||
<Tabs.Trigger
|
||||
value="account"
|
||||
class="flex-1 px-4 py-3 data-[state=active]:bg-white data-[state=active]:dark:bg-gray-900 data-[state=active]:border-b-2 data-[state=active]:border-blue-500 transition-colors rounded-tl-lg"
|
||||
>
|
||||
Account
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger
|
||||
value="password"
|
||||
class="flex-1 px-4 py-3 data-[state=active]:bg-white data-[state=active]:dark:bg-gray-900 data-[state=active]:border-b-2 data-[state=active]:border-blue-500 transition-colors"
|
||||
>
|
||||
Password
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger
|
||||
value="settings"
|
||||
class="flex-1 px-4 py-3 data-[state=active]:bg-white data-[state=active]:dark:bg-gray-900 data-[state=active]:border-b-2 data-[state=active]:border-blue-500 transition-colors rounded-tr-lg"
|
||||
>
|
||||
Settings
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
<Tabs.Content value="account" class="p-4">
|
||||
<h3 class="font-semibold mb-2">Account Settings</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400">Manage your account settings and preferences.</p>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="password" class="p-4">
|
||||
<h3 class="font-semibold mb-2">Password</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400">Change your password and security settings.</p>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="settings" class="p-4">
|
||||
<h3 class="font-semibold mb-2">Settings</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400">Configure your application settings.</p>
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
</section>
|
||||
|
||||
<!-- Manual Activation -->
|
||||
<section>
|
||||
<h2 class="text-lg font-semibold mb-3">Manual Activation (click only, no focus activation)</h2>
|
||||
<Tabs.Root bind:value={manualTab} activationMode="manual" class="w-full border rounded-lg">
|
||||
<Tabs.List class="flex bg-gray-50 dark:bg-gray-800 rounded-t-lg">
|
||||
<Tabs.Trigger
|
||||
value="overview"
|
||||
class="px-4 py-2 data-[state=active]:bg-blue-100 data-[state=active]:dark:bg-blue-900 data-[state=active]:text-blue-700 data-[state=active]:dark:text-blue-300 transition-colors rounded-tl-lg"
|
||||
>
|
||||
Overview
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger
|
||||
value="details"
|
||||
class="px-4 py-2 data-[state=active]:bg-blue-100 data-[state=active]:dark:bg-blue-900 data-[state=active]:text-blue-700 data-[state=active]:dark:text-blue-300 transition-colors"
|
||||
>
|
||||
Details
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger
|
||||
value="analytics"
|
||||
class="px-4 py-2 data-[state=active]:bg-blue-100 data-[state=active]:dark:bg-blue-900 data-[state=active]:text-blue-700 data-[state=active]:dark:text-blue-300 transition-colors rounded-tr-lg"
|
||||
>
|
||||
Analytics
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
<Tabs.Content value="overview" class="p-4">
|
||||
<p>Overview content - tabs only change on click, not on focus.</p>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="details" class="p-4">
|
||||
<p>Details content - use arrow keys to navigate, Enter/Space to activate.</p>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="analytics" class="p-4">
|
||||
<p>Analytics content - manual activation mode.</p>
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
</section>
|
||||
|
||||
<!-- Disabled Tabs -->
|
||||
<section>
|
||||
<h2 class="text-lg font-semibold mb-3">With Disabled Tab</h2>
|
||||
<Tabs.Root class="w-full border rounded-lg">
|
||||
<Tabs.List class="flex bg-gray-50 dark:bg-gray-800 rounded-t-lg">
|
||||
<Tabs.Trigger
|
||||
value="enabled1"
|
||||
class="px-4 py-2 data-[state=active]:bg-blue-500 data-[state=active]:text-white transition-colors rounded-tl-lg"
|
||||
>
|
||||
Enabled 1
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger
|
||||
value="disabled"
|
||||
disabled={true}
|
||||
class="px-4 py-2 opacity-50 cursor-not-allowed data-[state=active]:bg-blue-500 data-[state=active]:text-white transition-colors"
|
||||
>
|
||||
Disabled
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger
|
||||
value="enabled2"
|
||||
class="px-4 py-2 data-[state=active]:bg-blue-500 data-[state=active]:text-white transition-colors rounded-tr-lg"
|
||||
>
|
||||
Enabled 2
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
<Tabs.Content value="enabled1" class="p-4">
|
||||
<p>First enabled tab content.</p>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="disabled" class="p-4">
|
||||
<p>This content is not accessible because the tab is disabled.</p>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="enabled2" class="p-4">
|
||||
<p>Second enabled tab content.</p>
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
</section>
|
||||
|
||||
<!-- Vertical Tabs -->
|
||||
<section>
|
||||
<h2 class="text-lg font-semibold mb-3">Vertical Tabs</h2>
|
||||
<Tabs.Root orientation="vertical" class="flex w-full border rounded-lg">
|
||||
<Tabs.List class="flex flex-col bg-gray-50 dark:bg-gray-800 w-40 rounded-l-lg border-r">
|
||||
<Tabs.Trigger
|
||||
value="vtab1"
|
||||
class="px-4 py-3 text-left data-[state=active]:bg-blue-500 data-[state=active]:text-white transition-colors rounded-tl-lg"
|
||||
>
|
||||
Section A
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger
|
||||
value="vtab2"
|
||||
class="px-4 py-3 text-left data-[state=active]:bg-blue-500 data-[state=active]:text-white transition-colors"
|
||||
>
|
||||
Section B
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger
|
||||
value="vtab3"
|
||||
class="px-4 py-3 text-left data-[state=active]:bg-blue-500 data-[state=active]:text-white transition-colors rounded-bl-lg"
|
||||
>
|
||||
Section C
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
<div class="flex-1">
|
||||
<Tabs.Content value="vtab1" class="p-4">
|
||||
<h3 class="font-semibold mb-2">Section A</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400">Content for vertical section A. Use up/down arrow keys to navigate.</p>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="vtab2" class="p-4">
|
||||
<h3 class="font-semibold mb-2">Section B</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400">Content for vertical section B.</p>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="vtab3" class="p-4">
|
||||
<h3 class="font-semibold mb-2">Section C</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400">Content for vertical section C.</p>
|
||||
</Tabs.Content>
|
||||
</div>
|
||||
</Tabs.Root>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import { boxWith, mergeProps } from "../../utilities/index";
|
||||
import { DialogActionState } from "../../dialog/dialog.svelte";
|
||||
import type { AlertDialogActionProps } from "../types";
|
||||
import { createId } from "../../internal/create-id";
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
id = createId(uid),
|
||||
children,
|
||||
child,
|
||||
ref = $bindable(null),
|
||||
...restProps
|
||||
}: AlertDialogActionProps = $props();
|
||||
|
||||
const actionState = DialogActionState.create({
|
||||
id: boxWith(() => id),
|
||||
ref: boxWith(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, actionState.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<button {...mergedProps} bind:this={ref}>
|
||||
{@render children?.()}
|
||||
</button>
|
||||
{/if}
|
||||
@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { boxWith, mergeProps } from "../../utilities/index";
|
||||
import { AlertDialogCancelState } from "../../dialog/dialog.svelte";
|
||||
import type { AlertDialogCancelProps } from "../types";
|
||||
import { createId } from "../../internal/create-id";
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
id = createId(uid),
|
||||
children,
|
||||
child,
|
||||
ref = $bindable(null),
|
||||
disabled = false,
|
||||
...restProps
|
||||
}: AlertDialogCancelProps = $props();
|
||||
|
||||
const cancelState = AlertDialogCancelState.create({
|
||||
id: boxWith(() => id),
|
||||
ref: boxWith(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
disabled: boxWith(() => Boolean(disabled)),
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, cancelState.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<button {...mergedProps} bind:this={ref}>
|
||||
{@render children?.()}
|
||||
</button>
|
||||
{/if}
|
||||
@ -0,0 +1,59 @@
|
||||
<script lang="ts">
|
||||
import { boxWith, mergeProps, afterSleep } from "../../utilities/index";
|
||||
import { DialogContentState } from "../../dialog/dialog.svelte";
|
||||
import type { AlertDialogContentProps } from "../types";
|
||||
import { createId } from "../../internal/create-id";
|
||||
import { noop } from "../../internal/noop";
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
id = createId(uid),
|
||||
children,
|
||||
child,
|
||||
ref = $bindable(null),
|
||||
forceMount = false,
|
||||
onCloseAutoFocus = noop,
|
||||
onEscapeKeydown = noop,
|
||||
onOpenAutoFocus = noop,
|
||||
...restProps
|
||||
}: AlertDialogContentProps = $props();
|
||||
|
||||
const contentState = DialogContentState.create({
|
||||
id: boxWith(() => id),
|
||||
ref: boxWith(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, contentState.props));
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
onEscapeKeydown(e);
|
||||
// alert-dialog ignores Escape by default (must use action/cancel)
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (contentState.root.opts.open.current) {
|
||||
onOpenAutoFocus(new Event("focus"));
|
||||
afterSleep(0, () => ref?.focus());
|
||||
} else {
|
||||
onCloseAutoFocus(new Event("focus"));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
{#if contentState.shouldRender || forceMount}
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps, ...contentState.snippetProps })}
|
||||
{:else}
|
||||
<div {...mergedProps} bind:this={ref}>
|
||||
{@render children?.(contentState.snippetProps)}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { boxWith } from "../../utilities/index";
|
||||
import { DialogRootState } from "../../dialog/dialog.svelte";
|
||||
import type { AlertDialogRootProps } from "../types";
|
||||
import { noop } from "../../internal/noop";
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
onOpenChange = noop,
|
||||
onOpenChangeComplete = noop,
|
||||
children,
|
||||
}: AlertDialogRootProps = $props();
|
||||
|
||||
DialogRootState.create({
|
||||
variant: boxWith(() => "alert-dialog"),
|
||||
open: boxWith(
|
||||
() => open,
|
||||
(v) => {
|
||||
open = v;
|
||||
onOpenChange(v);
|
||||
}
|
||||
),
|
||||
onOpenChangeComplete: boxWith(() => onOpenChangeComplete),
|
||||
});
|
||||
</script>
|
||||
|
||||
{@render children?.()}
|
||||
@ -0,0 +1,24 @@
|
||||
export { default as Root } from "./components/alert-dialog.svelte";
|
||||
export { default as Trigger } from "../dialog/components/dialog-trigger.svelte";
|
||||
export { default as Content } from "./components/alert-dialog-content.svelte";
|
||||
export { default as Overlay } from "../dialog/components/dialog-overlay.svelte";
|
||||
export { default as Portal } from "../comn/portal/portal.svelte";
|
||||
export { default as Title } from "../dialog/components/dialog-title.svelte";
|
||||
export { default as Description } from "../dialog/components/dialog-description.svelte";
|
||||
export { default as Action } from "./components/alert-dialog-action.svelte";
|
||||
export { default as Cancel } from "./components/alert-dialog-cancel.svelte";
|
||||
|
||||
export type {
|
||||
AlertDialogRootProps as RootProps,
|
||||
AlertDialogTriggerProps as TriggerProps,
|
||||
AlertDialogContentProps as ContentProps,
|
||||
AlertDialogOverlayProps as OverlayProps,
|
||||
AlertDialogTitleProps as TitleProps,
|
||||
AlertDialogPortalProps as PortalProps,
|
||||
AlertDialogDescriptionProps as DescriptionProps,
|
||||
AlertDialogActionProps as ActionProps,
|
||||
AlertDialogCancelProps as CancelProps,
|
||||
} from "./types";
|
||||
|
||||
|
||||
|
||||
@ -0,0 +1,2 @@
|
||||
export * as AlertDialog from "./exports";
|
||||
export * from "./exports";
|
||||
@ -0,0 +1,45 @@
|
||||
import type {
|
||||
DialogRootProps,
|
||||
DialogPortalProps,
|
||||
DialogOverlayProps,
|
||||
DialogContentProps,
|
||||
DialogTitleProps,
|
||||
DialogDescriptionProps,
|
||||
DialogCloseProps,
|
||||
} from "../dialog/types";
|
||||
import type { WithChild, Without } from "../utilities/types";
|
||||
import type { AcelPrimitiveButtonAttributes } from "../shared/attributes";
|
||||
|
||||
// Alert-dialog re-usa los tipos del dialog en su mayoría
|
||||
|
||||
export type AlertDialogRootProps = DialogRootProps;
|
||||
|
||||
export type AlertDialogTriggerPropsWithoutHTML = WithChild<{ disabled?: boolean }>;
|
||||
export type AlertDialogTriggerProps = AlertDialogTriggerPropsWithoutHTML &
|
||||
Without<AcelPrimitiveButtonAttributes, AlertDialogTriggerPropsWithoutHTML>;
|
||||
|
||||
export type AlertDialogPortalProps = DialogPortalProps;
|
||||
|
||||
export type AlertDialogOverlayProps = DialogOverlayProps;
|
||||
|
||||
export type AlertDialogContentPropsWithoutHTML = WithChild<{
|
||||
forceMount?: boolean;
|
||||
onOpenAutoFocus?: (event: Event) => void;
|
||||
onCloseAutoFocus?: (event: Event) => void;
|
||||
onEscapeKeydown?: (event: KeyboardEvent) => void;
|
||||
}>;
|
||||
export type AlertDialogContentProps = DialogContentProps;
|
||||
|
||||
export type AlertDialogTitlePropsWithoutHTML = WithChild<{ level?: 1 | 2 | 3 | 4 | 5 | 6 }>;
|
||||
export type AlertDialogTitleProps = DialogTitleProps;
|
||||
|
||||
export type AlertDialogDescriptionProps = DialogDescriptionProps;
|
||||
|
||||
// Action cierra el diálogo (acción principal, sin cancelar)
|
||||
export type AlertDialogActionPropsWithoutHTML = WithChild;
|
||||
export type AlertDialogActionProps = DialogCloseProps;
|
||||
|
||||
// Cancel cierra el diálogo (acción secundaria/cancelar)
|
||||
export type AlertDialogCancelPropsWithoutHTML = WithChild<{ disabled?: boolean }>;
|
||||
export type AlertDialogCancelProps = AlertDialogCancelPropsWithoutHTML &
|
||||
Without<AcelPrimitiveButtonAttributes, AlertDialogCancelPropsWithoutHTML>;
|
||||
@ -0,0 +1,28 @@
|
||||
// oxlint-disable no-var
|
||||
import type { ReadableBox } from "./utilities/box/box-extras.svelte";
|
||||
|
||||
// Global type declarations for acel runtime state
|
||||
declare global {
|
||||
// Unique ID counter for acel components
|
||||
var acelIdCounter: { current: number };
|
||||
|
||||
// Dismissible layer stack for handling outside clicks
|
||||
var acelDismissableLayers: Map<unknown, ReadableBox<string>>;
|
||||
|
||||
// Escape key layer stack
|
||||
var acelEscapeLayers: Map<unknown, ReadableBox<string>>;
|
||||
|
||||
// Text selection layer stack
|
||||
var acelTextSelectionLayers: Map<unknown, ReadableBox<boolean>>;
|
||||
|
||||
// Body lock stack for modals
|
||||
var acelBodyLockStackCount: {
|
||||
readonly map: Map<string, boolean>;
|
||||
resetBodyStyle: () => void;
|
||||
};
|
||||
|
||||
// Animation state
|
||||
var acelAnimationsDisabled: boolean;
|
||||
}
|
||||
|
||||
export {};
|
||||
@ -1 +1,2 @@
|
||||
export * as Button from "./exports.ts";
|
||||
export * from "./exports.ts";
|
||||
@ -0,0 +1,106 @@
|
||||
import { Context } from "runed";
|
||||
import type { AcelConfigPropsWithoutChildren } from "./types.ts";
|
||||
import type { ReadableBoxedValues } from "../../utilities/types.ts";
|
||||
import { boxWith, type ReadableBox } from "../../utilities/index.ts";
|
||||
|
||||
type AcelConfigStateProps = ReadableBoxedValues<AcelConfigPropsWithoutChildren>;
|
||||
|
||||
export const AcelConfigContext = new Context<AcelConfigState>("AcelConfig");
|
||||
|
||||
/**
|
||||
* Gets the current Acel UI configuration state from the context.
|
||||
*
|
||||
* Returns a default configuration (where all values are `undefined`) if no configuration is found.
|
||||
*/
|
||||
export function getAcelConfig() {
|
||||
const fallback = new AcelConfigState(null, {});
|
||||
return AcelConfigContext.getOr(fallback).opts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and sets a new Acel UI configuration state that inherits from parent configs.
|
||||
*
|
||||
* @param opts - Configuration options for this level
|
||||
* @returns The configuration state instance
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // In a component that wants to set a default portal target
|
||||
* const config = useAcelConfig({ defaultPortalTo: box("#some-element") });
|
||||
*
|
||||
* // Child components will inherit this config and can override specific values
|
||||
* const childConfig = useAcelConfig({ someOtherProp: box("value") });
|
||||
* // childConfig still has defaultPortalTo="#some-element" from parent
|
||||
* ```
|
||||
*/
|
||||
export function useAcelConfig(opts: AcelConfigStateProps) {
|
||||
return AcelConfigContext.set(new AcelConfigState(AcelConfigContext.getOr(null), opts));
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration state that inherits from parent configurations.
|
||||
*
|
||||
* @example
|
||||
* Config resolution:
|
||||
* ```
|
||||
* Level 1: { defaultPortalTo: "#some-element", theme: "dark" }
|
||||
* Level 2: { spacing: "large" } // inherits defaultPortalTo="#some-element", theme="dark"
|
||||
* Level 3: { theme: "light" } // inherits defaultPortalTo="#some-element", spacing="large", overrides theme="light"
|
||||
* ```
|
||||
*/
|
||||
export class AcelConfigState {
|
||||
readonly opts: Required<AcelConfigStateProps>;
|
||||
|
||||
constructor(parent: AcelConfigState | null, opts: AcelConfigStateProps) {
|
||||
const resolveConfigOption = createConfigResolver(parent, opts);
|
||||
this.opts = {
|
||||
defaultPortalTo: resolveConfigOption((config) => config.defaultPortalTo),
|
||||
defaultLocale: resolveConfigOption((config) => config.defaultLocale),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
type ConfigOptionGetter<T> = (config: AcelConfigStateProps) => ReadableBox<T> | undefined;
|
||||
type ConfigOptionResolver = <T>(getter: ConfigOptionGetter<T>) => ReadableBox<T | undefined>;
|
||||
|
||||
/**
|
||||
* Returns a config resolver that resolves a given config option's value.
|
||||
*
|
||||
* The resolver creates reactive boxes that resolve config option values using this priority:
|
||||
* 1. Current level's value (if defined)
|
||||
* 2. Parent level's value (if defined and current is undefined)
|
||||
* 3. `undefined` (if no value is found in either parent or child)
|
||||
*
|
||||
* @param parent - Parent configuration state (null if this is root level)
|
||||
* @param currentOpts - Current level's configuration options
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Given this hierarchy:
|
||||
* // Root: { defaultPortalTo: "#some-element" }
|
||||
* // Child: { someOtherProp: "value" } // no defaultPortalTo specified
|
||||
*
|
||||
* const resolveConfigOption = createConfigResolver(parent, opts);
|
||||
* const portalTo = resolveConfigOption(config => config.defaultPortalTo);
|
||||
*
|
||||
* // portalTo.current === "#some-element" (inherited from parent)
|
||||
* // even when child didn't specify `defaultPortalTo`
|
||||
* ```
|
||||
*/
|
||||
function createConfigResolver(
|
||||
parent: AcelConfigState | null,
|
||||
currentOpts: AcelConfigStateProps
|
||||
): ConfigOptionResolver {
|
||||
return <T>(getter: ConfigOptionGetter<T>) => {
|
||||
const configOption = boxWith(() => {
|
||||
// try current opts first
|
||||
const value = getter(currentOpts)?.current;
|
||||
if (value !== undefined) return value;
|
||||
// if no parent, return undefined
|
||||
if (parent === null) return undefined;
|
||||
// get value from parent (which already has its own chain resolved)
|
||||
return getter(parent.opts)?.current;
|
||||
});
|
||||
return configOption;
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
<script lang="ts">
|
||||
import type { AcelConfigProps } from "../types.ts";
|
||||
import { useAcelConfig } from "../acel-config.ts";
|
||||
import { boxWith } from "../../../utilities/box";
|
||||
|
||||
let { children, defaultPortalTo, defaultLocale }: AcelConfigProps = $props();
|
||||
|
||||
useAcelConfig({
|
||||
defaultPortalTo: boxWith(() => defaultPortalTo),
|
||||
defaultLocale: boxWith(() => defaultLocale),
|
||||
});
|
||||
</script>
|
||||
|
||||
{@render children?.()}
|
||||
@ -0,0 +1,2 @@
|
||||
export { default as BitsConfig } from "./components/config.svelte";
|
||||
export { getAcelConfig, AcelConfigState } from "./acel-config.js";
|
||||
@ -0,0 +1 @@
|
||||
export * from "./exports.js";
|
||||
@ -0,0 +1,43 @@
|
||||
|
||||
import { boxWith, type ReadableBox } from "../../utilities/index.ts";
|
||||
import type { Getter } from "../../utilities/types.ts";
|
||||
import { type AcelConfigState, getAcelConfig } from "./acel-config.ts";
|
||||
|
||||
/**
|
||||
* Creates a generic prop resolver that follows a standard priority chain:
|
||||
* 1. The getter's prop value (if defined)
|
||||
* 2. The config default value (if no getter prop value is defined)
|
||||
* 3. The fallback value (if no config value found)
|
||||
*/
|
||||
function createPropResolver<T>(
|
||||
configOption: (config: AcelConfigState["opts"]) => { current: T | undefined },
|
||||
fallback: T
|
||||
) {
|
||||
return (getProp: Getter<T | undefined>): ReadableBox<T> => {
|
||||
const config = getAcelConfig();
|
||||
return boxWith(() => {
|
||||
// 1. return the prop's value, if provided
|
||||
const propValue = getProp();
|
||||
if (propValue !== undefined) return propValue;
|
||||
// 2. return the resolved config option value, if available
|
||||
const option = configOption(config).current;
|
||||
if (option !== undefined) return option;
|
||||
// 3. return the fallback if no other value is found
|
||||
return fallback;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a locale value using the prop, the config default, or a fallback.
|
||||
*
|
||||
* Default value: `"en"`
|
||||
*/
|
||||
export const resolveLocaleProp = createPropResolver((config) => config.defaultLocale, "en");
|
||||
|
||||
/**
|
||||
* Resolves a portal's `to` value using the prop, the config default, or a fallback.
|
||||
*
|
||||
* Default value: `"body"`
|
||||
*/
|
||||
export const resolvePortalToProp = createPropResolver((config) => config.defaultPortalTo, "body");
|
||||
@ -0,0 +1,15 @@
|
||||
import type { WithChildren } from "../../internal/types.ts";
|
||||
import type { PortalTarget } from "../portal/types.ts";
|
||||
|
||||
export type AcelConfigPropsWithoutChildren = {
|
||||
/**
|
||||
* The default portal `to`/target to use for the `Portal` components throughout the app.
|
||||
*/
|
||||
defaultPortalTo?: PortalTarget;
|
||||
|
||||
/**
|
||||
* The default locale to use for the components that support localization.
|
||||
*/
|
||||
defaultLocale?: string;
|
||||
};
|
||||
export type AcelConfigProps = WithChildren<AcelConfigPropsWithoutChildren>;
|
||||
@ -0,0 +1,2 @@
|
||||
export { default as Portal } from "./portal.svelte";
|
||||
export type { PortalProps } from "./types.js";
|
||||
@ -0,0 +1,9 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
const { children }: { children?: Snippet } = $props();
|
||||
</script>
|
||||
|
||||
{#key children}
|
||||
{@render children?.()}
|
||||
{/key}
|
||||
@ -0,0 +1,70 @@
|
||||
<script lang="ts">
|
||||
import { getAllContexts, mount, unmount } from "svelte";
|
||||
import { DEV } from "esm-env";
|
||||
import { watch } from "runed";
|
||||
import PortalConsumer from "./portal-consumer.svelte";
|
||||
import type { PortalProps } from "./types.ts";
|
||||
import { isBrowser } from "../../internal/is.ts";
|
||||
import { resolvePortalToProp } from "../config/prop-resolvers.ts";
|
||||
|
||||
let { to: toProp, children, disabled }: PortalProps = $props();
|
||||
|
||||
const to = resolvePortalToProp(() => toProp);
|
||||
const context = getAllContexts();
|
||||
|
||||
let target = $derived(getTarget());
|
||||
|
||||
function getTarget() {
|
||||
if (!isBrowser || disabled) return null;
|
||||
|
||||
let localTarget: Element | null = null;
|
||||
|
||||
if (typeof to.current === "string") {
|
||||
const target = document.querySelector(to.current);
|
||||
if (DEV && target === null) {
|
||||
throw new Error(`Target element "${to.current}" not found.`);
|
||||
}
|
||||
localTarget = target;
|
||||
} else {
|
||||
localTarget = to.current;
|
||||
}
|
||||
|
||||
if (DEV && !(localTarget instanceof Element)) {
|
||||
const type = localTarget === null ? "null" : typeof localTarget;
|
||||
throw new TypeError(
|
||||
`Unknown portal target type: ${type}. Allowed types: string (query selector) or Element.`
|
||||
);
|
||||
}
|
||||
|
||||
return localTarget;
|
||||
}
|
||||
|
||||
let instance: ReturnType<typeof mount> | null;
|
||||
|
||||
function unmountInstance() {
|
||||
if (instance) {
|
||||
unmount(instance);
|
||||
instance = null;
|
||||
}
|
||||
}
|
||||
|
||||
watch([() => target, () => disabled], ([target, disabled]) => {
|
||||
if (!target || disabled) {
|
||||
unmountInstance();
|
||||
return;
|
||||
}
|
||||
instance = mount(PortalConsumer, {
|
||||
target: target,
|
||||
props: { children },
|
||||
context,
|
||||
});
|
||||
|
||||
return () => {
|
||||
unmountInstance();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if disabled}
|
||||
{@render children?.()}
|
||||
{/if}
|
||||
@ -0,0 +1,24 @@
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
export type PortalTarget = Element | string;
|
||||
|
||||
export type PortalProps = {
|
||||
/**
|
||||
* Where to portal the content to.
|
||||
*
|
||||
* @default document.body
|
||||
*/
|
||||
to?: PortalTarget;
|
||||
|
||||
/**
|
||||
* Disable portalling and render the component inline
|
||||
*
|
||||
* @defaultValue false
|
||||
*/
|
||||
disabled?: boolean;
|
||||
|
||||
/**
|
||||
* The children content to render within the portal.
|
||||
*/
|
||||
children?: Snippet;
|
||||
};
|
||||
@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import { boxWith, mergeProps } from "../../utilities/index";
|
||||
import { DialogCloseState } from "../dialog.svelte";
|
||||
import type { DialogCloseProps } from "../types";
|
||||
import { createId } from "../../internal/create-id";
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
children,
|
||||
child,
|
||||
id = createId(uid),
|
||||
ref = $bindable(null),
|
||||
disabled = false,
|
||||
...restProps
|
||||
}: DialogCloseProps = $props();
|
||||
|
||||
const closeState = DialogCloseState.create({
|
||||
variant: boxWith(() => "close"),
|
||||
id: boxWith(() => id),
|
||||
ref: boxWith(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
disabled: boxWith(() => Boolean(disabled)),
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, closeState.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<button {...mergedProps} bind:this={ref}>
|
||||
{@render children?.()}
|
||||
</button>
|
||||
{/if}
|
||||
@ -0,0 +1,61 @@
|
||||
<script lang="ts">
|
||||
import { boxWith, mergeProps, afterSleep } from "../../utilities/index";
|
||||
import { DialogContentState } from "../dialog.svelte";
|
||||
import type { DialogContentProps } from "../types";
|
||||
import { createId } from "../../internal/create-id";
|
||||
import { noop } from "../../internal/noop";
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
id = createId(uid),
|
||||
children,
|
||||
child,
|
||||
ref = $bindable(null),
|
||||
forceMount = false,
|
||||
onCloseAutoFocus = noop,
|
||||
onEscapeKeydown = noop,
|
||||
onOpenAutoFocus = noop,
|
||||
...restProps
|
||||
}: DialogContentProps = $props();
|
||||
|
||||
const contentState = DialogContentState.create({
|
||||
id: boxWith(() => id),
|
||||
ref: boxWith(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, contentState.props));
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
onEscapeKeydown(e);
|
||||
if (!e.defaultPrevented) {
|
||||
contentState.root.handleClose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (contentState.root.opts.open.current) {
|
||||
onOpenAutoFocus(new Event("focus"));
|
||||
afterSleep(0, () => ref?.focus());
|
||||
} else {
|
||||
onCloseAutoFocus(new Event("focus"));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
{#if contentState.shouldRender || forceMount}
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps, ...contentState.snippetProps })}
|
||||
{:else}
|
||||
<div {...mergedProps} bind:this={ref}>
|
||||
{@render children?.(contentState.snippetProps)}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import { boxWith, mergeProps } from "../../utilities/index";
|
||||
import { DialogDescriptionState } from "../dialog.svelte";
|
||||
import type { DialogDescriptionProps } from "../types";
|
||||
import { createId } from "../../internal/create-id";
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
id = createId(uid),
|
||||
children,
|
||||
child,
|
||||
ref = $bindable(null),
|
||||
...restProps
|
||||
}: DialogDescriptionProps = $props();
|
||||
|
||||
const descriptionState = DialogDescriptionState.create({
|
||||
id: boxWith(() => id),
|
||||
ref: boxWith(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, descriptionState.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps} bind:this={ref}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import { boxWith, mergeProps } from "../../utilities/index";
|
||||
import { DialogOverlayState } from "../dialog.svelte";
|
||||
import type { DialogOverlayProps } from "../types";
|
||||
import { createId } from "../../internal/create-id";
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
id = createId(uid),
|
||||
forceMount = false,
|
||||
child,
|
||||
children,
|
||||
ref = $bindable(null),
|
||||
...restProps
|
||||
}: DialogOverlayProps = $props();
|
||||
|
||||
const overlayState = DialogOverlayState.create({
|
||||
id: boxWith(() => id),
|
||||
ref: boxWith(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, overlayState.props));
|
||||
</script>
|
||||
|
||||
{#if overlayState.shouldRender || forceMount}
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps, ...overlayState.snippetProps })}
|
||||
{:else}
|
||||
<div {...mergedProps} bind:this={ref}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { boxWith, mergeProps } from "../../utilities/index";
|
||||
import { DialogTitleState } from "../dialog.svelte";
|
||||
import type { DialogTitleProps } from "../types";
|
||||
import { createId } from "../../internal/create-id";
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
id = createId(uid),
|
||||
ref = $bindable(null),
|
||||
child,
|
||||
children,
|
||||
level = 2,
|
||||
...restProps
|
||||
}: DialogTitleProps = $props();
|
||||
|
||||
const titleState = DialogTitleState.create({
|
||||
id: boxWith(() => id),
|
||||
level: boxWith(() => level),
|
||||
ref: boxWith(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, titleState.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps} bind:this={ref}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { boxWith, mergeProps } from "../../utilities/index";
|
||||
import { DialogTriggerState } from "../dialog.svelte";
|
||||
import type { DialogTriggerProps } from "../types";
|
||||
import { createId } from "../../internal/create-id";
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
id = createId(uid),
|
||||
ref = $bindable(null),
|
||||
children,
|
||||
child,
|
||||
disabled = false,
|
||||
...restProps
|
||||
}: DialogTriggerProps = $props();
|
||||
|
||||
const triggerState = DialogTriggerState.create({
|
||||
id: boxWith(() => id),
|
||||
ref: boxWith(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
disabled: boxWith(() => Boolean(disabled)),
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, triggerState.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<button {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</button>
|
||||
{/if}
|
||||
@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { boxWith } from "../../utilities/index";
|
||||
import { DialogRootState } from "../dialog.svelte";
|
||||
import type { DialogRootProps } from "../types";
|
||||
import { noop } from "../../internal/noop";
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
onOpenChange = noop,
|
||||
onOpenChangeComplete = noop,
|
||||
children,
|
||||
}: DialogRootProps = $props();
|
||||
|
||||
DialogRootState.create({
|
||||
variant: boxWith(() => "dialog"),
|
||||
open: boxWith(
|
||||
() => open,
|
||||
(v) => {
|
||||
open = v;
|
||||
onOpenChange(v);
|
||||
}
|
||||
),
|
||||
onOpenChangeComplete: boxWith(() => onOpenChangeComplete),
|
||||
});
|
||||
</script>
|
||||
|
||||
{@render children?.()}
|
||||
@ -0,0 +1,473 @@
|
||||
import {
|
||||
attachRef,
|
||||
boxWith,
|
||||
onDestroyEffect,
|
||||
type ReadableBoxedValues,
|
||||
type WritableBoxedValues,
|
||||
} from "../utilities/index";
|
||||
import { Context, watch } from "runed";
|
||||
import {
|
||||
createAcelAttrs,
|
||||
boolToStr,
|
||||
getDataOpenClosed,
|
||||
boolToEmptyStrOrUndef,
|
||||
} from "../internal/attrs";
|
||||
import type {
|
||||
BitsKeyboardEvent,
|
||||
BitsMouseEvent,
|
||||
OnChangeFn,
|
||||
RefAttachment,
|
||||
WithRefOpts,
|
||||
} from "../internal/types";
|
||||
import { kbd } from "../internal/kbd";
|
||||
import { PresenceManager } from "../internal/presence-manager.svelte";
|
||||
|
||||
type DialogVariant = "alert-dialog" | "dialog";
|
||||
|
||||
const dialogAttrs = createAcelAttrs({
|
||||
component: "dialog",
|
||||
parts: ["content", "trigger", "overlay", "title", "description", "close", "cancel", "action"] as const,
|
||||
});
|
||||
|
||||
const DialogRootContext = new Context<DialogRootState>("Dialog.Root | AlertDialog.Root");
|
||||
|
||||
interface DialogRootStateOpts
|
||||
extends WritableBoxedValues<{ open: boolean }>,
|
||||
ReadableBoxedValues<{
|
||||
variant: DialogVariant;
|
||||
onOpenChangeComplete: OnChangeFn<boolean>;
|
||||
}> {}
|
||||
|
||||
export class DialogRootState {
|
||||
static create(opts: DialogRootStateOpts) {
|
||||
const parent = DialogRootContext.getOr(null);
|
||||
return DialogRootContext.set(new DialogRootState(opts, parent));
|
||||
}
|
||||
|
||||
readonly opts: DialogRootStateOpts;
|
||||
triggerNode = $state<HTMLElement | null>(null);
|
||||
contentNode = $state<HTMLElement | null>(null);
|
||||
overlayNode = $state<HTMLElement | null>(null);
|
||||
descriptionNode = $state<HTMLElement | null>(null);
|
||||
contentId = $state<string | undefined>(undefined);
|
||||
titleId = $state<string | undefined>(undefined);
|
||||
triggerId = $state<string | undefined>(undefined);
|
||||
descriptionId = $state<string | undefined>(undefined);
|
||||
cancelNode = $state<HTMLElement | null>(null);
|
||||
nestedOpenCount = $state(0);
|
||||
readonly depth: number;
|
||||
readonly parent: DialogRootState | null;
|
||||
contentPresence: PresenceManager;
|
||||
overlayPresence: PresenceManager;
|
||||
|
||||
constructor(opts: DialogRootStateOpts, parent: DialogRootState | null) {
|
||||
this.opts = opts;
|
||||
this.parent = parent;
|
||||
this.depth = parent ? parent.depth + 1 : 0;
|
||||
this.handleOpen = this.handleOpen.bind(this);
|
||||
this.handleClose = this.handleClose.bind(this);
|
||||
|
||||
this.contentPresence = new PresenceManager({
|
||||
ref: boxWith(() => this.contentNode),
|
||||
open: this.opts.open,
|
||||
onComplete: () => {
|
||||
this.opts.onOpenChangeComplete.current(this.opts.open.current);
|
||||
},
|
||||
});
|
||||
|
||||
this.overlayPresence = new PresenceManager({
|
||||
ref: boxWith(() => this.overlayNode),
|
||||
open: this.opts.open,
|
||||
});
|
||||
|
||||
watch(
|
||||
() => this.opts.open.current,
|
||||
(isOpen) => {
|
||||
if (!this.parent) return;
|
||||
if (isOpen) {
|
||||
this.parent.incrementNested();
|
||||
} else {
|
||||
this.parent.decrementNested();
|
||||
}
|
||||
},
|
||||
{ lazy: true }
|
||||
);
|
||||
|
||||
onDestroyEffect(() => {
|
||||
if (this.opts.open.current) {
|
||||
this.parent?.decrementNested();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleOpen() {
|
||||
if (this.opts.open.current) return;
|
||||
this.opts.open.current = true;
|
||||
}
|
||||
|
||||
handleClose() {
|
||||
if (!this.opts.open.current) return;
|
||||
this.opts.open.current = false;
|
||||
}
|
||||
|
||||
getBitsAttr: typeof dialogAttrs.getAttr = (part) => {
|
||||
return dialogAttrs.getAttr(part, this.opts.variant.current);
|
||||
};
|
||||
|
||||
incrementNested() {
|
||||
this.nestedOpenCount++;
|
||||
this.parent?.incrementNested();
|
||||
}
|
||||
|
||||
decrementNested() {
|
||||
if (this.nestedOpenCount === 0) return;
|
||||
this.nestedOpenCount--;
|
||||
this.parent?.decrementNested();
|
||||
}
|
||||
|
||||
readonly sharedProps = $derived.by(
|
||||
() => ({ "data-state": getDataOpenClosed(this.opts.open.current) }) as const
|
||||
);
|
||||
}
|
||||
|
||||
interface DialogTriggerStateOpts extends WithRefOpts, ReadableBoxedValues<{ disabled: boolean }> {}
|
||||
|
||||
export class DialogTriggerState {
|
||||
static create(opts: DialogTriggerStateOpts) {
|
||||
return new DialogTriggerState(opts, DialogRootContext.get());
|
||||
}
|
||||
|
||||
readonly opts: DialogTriggerStateOpts;
|
||||
readonly root: DialogRootState;
|
||||
readonly attachment: RefAttachment;
|
||||
|
||||
constructor(opts: DialogTriggerStateOpts, root: DialogRootState) {
|
||||
this.opts = opts;
|
||||
this.root = root;
|
||||
this.attachment = attachRef(this.opts.ref, (v) => {
|
||||
this.root.triggerNode = v;
|
||||
this.root.triggerId = v?.id;
|
||||
});
|
||||
this.onclick = this.onclick.bind(this);
|
||||
this.onkeydown = this.onkeydown.bind(this);
|
||||
}
|
||||
|
||||
onclick(e: BitsMouseEvent) {
|
||||
if (this.opts.disabled.current) return;
|
||||
if (e.button > 0) return;
|
||||
this.root.handleOpen();
|
||||
}
|
||||
|
||||
onkeydown(e: BitsKeyboardEvent) {
|
||||
if (this.opts.disabled.current) return;
|
||||
if (e.key === kbd.SPACE || e.key === kbd.ENTER) {
|
||||
e.preventDefault();
|
||||
this.root.handleOpen();
|
||||
}
|
||||
}
|
||||
|
||||
readonly props = $derived.by(
|
||||
() =>
|
||||
({
|
||||
id: this.opts.id.current,
|
||||
"aria-haspopup": "dialog",
|
||||
"aria-expanded": boolToStr(this.root.opts.open.current),
|
||||
"aria-controls": this.root.contentId,
|
||||
[this.root.getBitsAttr("trigger")]: "",
|
||||
onkeydown: this.onkeydown,
|
||||
onclick: this.onclick,
|
||||
disabled: this.opts.disabled.current ? true : undefined,
|
||||
...this.root.sharedProps,
|
||||
...this.attachment,
|
||||
}) as const
|
||||
);
|
||||
}
|
||||
|
||||
interface DialogCloseStateOpts
|
||||
extends WithRefOpts,
|
||||
ReadableBoxedValues<{ variant: "action" | "cancel" | "close"; disabled: boolean }> {}
|
||||
|
||||
export class DialogCloseState {
|
||||
static create(opts: DialogCloseStateOpts) {
|
||||
return new DialogCloseState(opts, DialogRootContext.get());
|
||||
}
|
||||
|
||||
readonly opts: DialogCloseStateOpts;
|
||||
readonly root: DialogRootState;
|
||||
readonly attachment: RefAttachment;
|
||||
|
||||
constructor(opts: DialogCloseStateOpts, root: DialogRootState) {
|
||||
this.opts = opts;
|
||||
this.root = root;
|
||||
this.attachment = attachRef(this.opts.ref);
|
||||
this.onclick = this.onclick.bind(this);
|
||||
this.onkeydown = this.onkeydown.bind(this);
|
||||
}
|
||||
|
||||
onclick(e: BitsMouseEvent) {
|
||||
if (this.opts.disabled.current) return;
|
||||
if (e.button > 0) return;
|
||||
this.root.handleClose();
|
||||
}
|
||||
|
||||
onkeydown(e: BitsKeyboardEvent) {
|
||||
if (this.opts.disabled.current) return;
|
||||
if (e.key === kbd.SPACE || e.key === kbd.ENTER) {
|
||||
e.preventDefault();
|
||||
this.root.handleClose();
|
||||
}
|
||||
}
|
||||
|
||||
readonly props = $derived.by(
|
||||
() =>
|
||||
({
|
||||
id: this.opts.id.current,
|
||||
[this.root.getBitsAttr(this.opts.variant.current)]: "",
|
||||
onclick: this.onclick,
|
||||
onkeydown: this.onkeydown,
|
||||
disabled: this.opts.disabled.current ? true : undefined,
|
||||
tabindex: 0,
|
||||
...this.root.sharedProps,
|
||||
...this.attachment,
|
||||
}) as const
|
||||
);
|
||||
}
|
||||
|
||||
interface DialogActionStateOpts extends WithRefOpts {}
|
||||
|
||||
export class DialogActionState {
|
||||
static create(opts: DialogActionStateOpts) {
|
||||
return new DialogActionState(opts, DialogRootContext.get());
|
||||
}
|
||||
|
||||
readonly opts: DialogActionStateOpts;
|
||||
readonly root: DialogRootState;
|
||||
readonly attachment: RefAttachment;
|
||||
|
||||
constructor(opts: DialogActionStateOpts, root: DialogRootState) {
|
||||
this.opts = opts;
|
||||
this.root = root;
|
||||
this.attachment = attachRef(this.opts.ref);
|
||||
}
|
||||
|
||||
readonly props = $derived.by(
|
||||
() =>
|
||||
({
|
||||
id: this.opts.id.current,
|
||||
[this.root.getBitsAttr("action")]: "",
|
||||
...this.root.sharedProps,
|
||||
...this.attachment,
|
||||
}) as const
|
||||
);
|
||||
}
|
||||
|
||||
interface DialogTitleStateOpts extends WithRefOpts, ReadableBoxedValues<{ level: 1 | 2 | 3 | 4 | 5 | 6 }> {}
|
||||
|
||||
export class DialogTitleState {
|
||||
static create(opts: DialogTitleStateOpts) {
|
||||
return new DialogTitleState(opts, DialogRootContext.get());
|
||||
}
|
||||
|
||||
readonly opts: DialogTitleStateOpts;
|
||||
readonly root: DialogRootState;
|
||||
readonly attachment: RefAttachment;
|
||||
|
||||
constructor(opts: DialogTitleStateOpts, root: DialogRootState) {
|
||||
this.opts = opts;
|
||||
this.root = root;
|
||||
this.root.titleId = this.opts.id.current;
|
||||
this.attachment = attachRef(this.opts.ref);
|
||||
|
||||
watch.pre(
|
||||
() => this.opts.id.current,
|
||||
(id) => { this.root.titleId = id; }
|
||||
);
|
||||
}
|
||||
|
||||
readonly props = $derived.by(
|
||||
() =>
|
||||
({
|
||||
id: this.opts.id.current,
|
||||
role: "heading",
|
||||
"aria-level": this.opts.level.current,
|
||||
[this.root.getBitsAttr("title")]: "",
|
||||
...this.root.sharedProps,
|
||||
...this.attachment,
|
||||
}) as const
|
||||
);
|
||||
}
|
||||
|
||||
interface DialogDescriptionStateOpts extends WithRefOpts {}
|
||||
|
||||
export class DialogDescriptionState {
|
||||
static create(opts: DialogDescriptionStateOpts) {
|
||||
return new DialogDescriptionState(opts, DialogRootContext.get());
|
||||
}
|
||||
|
||||
readonly opts: DialogDescriptionStateOpts;
|
||||
readonly root: DialogRootState;
|
||||
readonly attachment: RefAttachment;
|
||||
|
||||
constructor(opts: DialogDescriptionStateOpts, root: DialogRootState) {
|
||||
this.opts = opts;
|
||||
this.root = root;
|
||||
this.root.descriptionId = this.opts.id.current;
|
||||
this.attachment = attachRef(this.opts.ref, (v) => { this.root.descriptionNode = v; });
|
||||
|
||||
watch.pre(
|
||||
() => this.opts.id.current,
|
||||
(id) => { this.root.descriptionId = id; }
|
||||
);
|
||||
}
|
||||
|
||||
readonly props = $derived.by(
|
||||
() =>
|
||||
({
|
||||
id: this.opts.id.current,
|
||||
[this.root.getBitsAttr("description")]: "",
|
||||
...this.root.sharedProps,
|
||||
...this.attachment,
|
||||
}) as const
|
||||
);
|
||||
}
|
||||
|
||||
interface DialogContentStateOpts extends WithRefOpts {}
|
||||
|
||||
export class DialogContentState {
|
||||
static create(opts: DialogContentStateOpts) {
|
||||
return new DialogContentState(opts, DialogRootContext.get());
|
||||
}
|
||||
|
||||
readonly opts: DialogContentStateOpts;
|
||||
readonly root: DialogRootState;
|
||||
readonly attachment: RefAttachment;
|
||||
|
||||
constructor(opts: DialogContentStateOpts, root: DialogRootState) {
|
||||
this.opts = opts;
|
||||
this.root = root;
|
||||
this.attachment = attachRef(this.opts.ref, (v) => {
|
||||
this.root.contentNode = v;
|
||||
this.root.contentId = v?.id;
|
||||
});
|
||||
}
|
||||
|
||||
readonly snippetProps = $derived.by(() => ({ open: this.root.opts.open.current }));
|
||||
|
||||
readonly props = $derived.by(
|
||||
() =>
|
||||
({
|
||||
id: this.opts.id.current,
|
||||
role: this.root.opts.variant.current === "alert-dialog" ? "alertdialog" : "dialog",
|
||||
"aria-modal": "true",
|
||||
"aria-describedby": this.root.descriptionId,
|
||||
"aria-labelledby": this.root.titleId,
|
||||
[this.root.getBitsAttr("content")]: "",
|
||||
style: {
|
||||
pointerEvents: "auto",
|
||||
outline: this.root.opts.variant.current === "alert-dialog" ? "none" : undefined,
|
||||
"--acel-dialog-depth": this.root.depth,
|
||||
"--acel-dialog-nested-count": this.root.nestedOpenCount,
|
||||
contain: "layout style",
|
||||
},
|
||||
tabindex: this.root.opts.variant.current === "alert-dialog" ? -1 : undefined,
|
||||
"data-nested-open": boolToEmptyStrOrUndef(this.root.nestedOpenCount > 0),
|
||||
"data-nested": boolToEmptyStrOrUndef(this.root.parent !== null),
|
||||
...this.root.sharedProps,
|
||||
...this.attachment,
|
||||
}) as const
|
||||
);
|
||||
|
||||
get shouldRender() {
|
||||
return this.root.contentPresence.shouldRender;
|
||||
}
|
||||
}
|
||||
|
||||
interface DialogOverlayStateOpts extends WithRefOpts {}
|
||||
|
||||
export class DialogOverlayState {
|
||||
static create(opts: DialogOverlayStateOpts) {
|
||||
return new DialogOverlayState(opts, DialogRootContext.get());
|
||||
}
|
||||
|
||||
readonly opts: DialogOverlayStateOpts;
|
||||
readonly root: DialogRootState;
|
||||
readonly attachment: RefAttachment;
|
||||
|
||||
constructor(opts: DialogOverlayStateOpts, root: DialogRootState) {
|
||||
this.opts = opts;
|
||||
this.root = root;
|
||||
this.attachment = attachRef(this.opts.ref, (v) => (this.root.overlayNode = v));
|
||||
}
|
||||
|
||||
readonly snippetProps = $derived.by(() => ({ open: this.root.opts.open.current }));
|
||||
|
||||
readonly props = $derived.by(
|
||||
() =>
|
||||
({
|
||||
id: this.opts.id.current,
|
||||
[this.root.getBitsAttr("overlay")]: "",
|
||||
style: {
|
||||
pointerEvents: "auto",
|
||||
"--acel-dialog-depth": this.root.depth,
|
||||
"--acel-dialog-nested-count": this.root.nestedOpenCount,
|
||||
},
|
||||
"data-nested-open": boolToEmptyStrOrUndef(this.root.nestedOpenCount > 0),
|
||||
"data-nested": boolToEmptyStrOrUndef(this.root.parent !== null),
|
||||
...this.root.sharedProps,
|
||||
...this.attachment,
|
||||
}) as const
|
||||
);
|
||||
|
||||
get shouldRender() {
|
||||
return this.root.overlayPresence.shouldRender;
|
||||
}
|
||||
}
|
||||
|
||||
interface AlertDialogCancelStateOpts
|
||||
extends WithRefOpts,
|
||||
ReadableBoxedValues<{ disabled: boolean }> {}
|
||||
|
||||
export class AlertDialogCancelState {
|
||||
static create(opts: AlertDialogCancelStateOpts) {
|
||||
return new AlertDialogCancelState(opts, DialogRootContext.get());
|
||||
}
|
||||
|
||||
readonly opts: AlertDialogCancelStateOpts;
|
||||
readonly root: DialogRootState;
|
||||
readonly attachment: RefAttachment;
|
||||
|
||||
constructor(opts: AlertDialogCancelStateOpts, root: DialogRootState) {
|
||||
this.opts = opts;
|
||||
this.root = root;
|
||||
this.attachment = attachRef(this.opts.ref, (v) => (this.root.cancelNode = v));
|
||||
this.onclick = this.onclick.bind(this);
|
||||
this.onkeydown = this.onkeydown.bind(this);
|
||||
}
|
||||
|
||||
onclick(e: BitsMouseEvent) {
|
||||
if (this.opts.disabled.current) return;
|
||||
if (e.button > 0) return;
|
||||
this.root.handleClose();
|
||||
}
|
||||
|
||||
onkeydown(e: BitsKeyboardEvent) {
|
||||
if (this.opts.disabled.current) return;
|
||||
if (e.key === kbd.SPACE || e.key === kbd.ENTER) {
|
||||
e.preventDefault();
|
||||
this.root.handleClose();
|
||||
}
|
||||
}
|
||||
|
||||
readonly props = $derived.by(
|
||||
() =>
|
||||
({
|
||||
id: this.opts.id.current,
|
||||
[this.root.getBitsAttr("cancel")]: "",
|
||||
onclick: this.onclick,
|
||||
onkeydown: this.onkeydown,
|
||||
tabindex: 0,
|
||||
...this.root.sharedProps,
|
||||
...this.attachment,
|
||||
}) as const
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
export { default as Root } from "./components/dialog.svelte";
|
||||
export { default as Trigger } from "./components/dialog-trigger.svelte";
|
||||
export { default as Content } from "./components/dialog-content.svelte";
|
||||
export { default as Overlay } from "./components/dialog-overlay.svelte";
|
||||
export { default as Title } from "./components/dialog-title.svelte";
|
||||
export { default as Description } from "./components/dialog-description.svelte";
|
||||
export { default as Close } from "./components/dialog-close.svelte";
|
||||
export { default as Portal } from "../comn/portal/portal.svelte";
|
||||
|
||||
export type {
|
||||
DialogRootProps as RootProps,
|
||||
DialogTriggerProps as TriggerProps,
|
||||
DialogPortalProps as PortalProps,
|
||||
DialogContentProps as ContentProps,
|
||||
DialogOverlayProps as OverlayProps,
|
||||
DialogTitleProps as TitleProps,
|
||||
DialogDescriptionProps as DescriptionProps,
|
||||
DialogCloseProps as CloseProps,
|
||||
} from "./types";
|
||||
@ -0,0 +1,2 @@
|
||||
export * as Dialog from "./exports";
|
||||
export * from "./exports";
|
||||
@ -0,0 +1,102 @@
|
||||
import type { HTMLButtonAttributes, HTMLAttributes } from "svelte/elements";
|
||||
import type {
|
||||
OnChangeFn,
|
||||
WithChild,
|
||||
WithChildren,
|
||||
Without,
|
||||
} from "../utilities/types";
|
||||
import type {
|
||||
AcelPrimitiveButtonAttributes,
|
||||
AcelPrimitiveDivAttributes,
|
||||
} from "../shared/attributes";
|
||||
import type { PortalProps } from "../comn/portal/types";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// ROOT
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export type DialogRootPropsWithoutHTML = WithChildren<{
|
||||
open?: boolean;
|
||||
onOpenChange?: OnChangeFn<boolean>;
|
||||
onOpenChangeComplete?: OnChangeFn<boolean>;
|
||||
}>;
|
||||
|
||||
export type DialogRootProps = DialogRootPropsWithoutHTML;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TRIGGER
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export type DialogTriggerPropsWithoutHTML = WithChild;
|
||||
|
||||
export type DialogTriggerProps = DialogTriggerPropsWithoutHTML &
|
||||
Without<AcelPrimitiveButtonAttributes, DialogTriggerPropsWithoutHTML>;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// PORTAL
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export type DialogPortalPropsWithoutHTML = PortalProps;
|
||||
export type DialogPortalProps = DialogPortalPropsWithoutHTML;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// OVERLAY
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export type DialogOverlaySnippetProps = { open: boolean };
|
||||
|
||||
export type DialogOverlayPropsWithoutHTML = WithChild<
|
||||
{ forceMount?: boolean },
|
||||
DialogOverlaySnippetProps
|
||||
>;
|
||||
|
||||
export type DialogOverlayProps = DialogOverlayPropsWithoutHTML &
|
||||
Without<AcelPrimitiveDivAttributes, DialogOverlayPropsWithoutHTML>;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// CONTENT
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export type DialogContentSnippetProps = { open: boolean };
|
||||
|
||||
export type DialogContentPropsWithoutHTML = WithChild<
|
||||
{
|
||||
forceMount?: boolean;
|
||||
onOpenAutoFocus?: (event: Event) => void;
|
||||
onCloseAutoFocus?: (event: Event) => void;
|
||||
onEscapeKeydown?: (event: KeyboardEvent) => void;
|
||||
},
|
||||
DialogContentSnippetProps
|
||||
>;
|
||||
|
||||
export type DialogContentProps = DialogContentPropsWithoutHTML &
|
||||
Without<AcelPrimitiveDivAttributes, DialogContentPropsWithoutHTML>;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// TITLE
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export type DialogTitlePropsWithoutHTML = WithChild<{
|
||||
level?: 1 | 2 | 3 | 4 | 5 | 6;
|
||||
}>;
|
||||
|
||||
export type DialogTitleProps = DialogTitlePropsWithoutHTML &
|
||||
Without<AcelPrimitiveDivAttributes, DialogTitlePropsWithoutHTML>;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// DESCRIPTION
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export type DialogDescriptionPropsWithoutHTML = WithChild;
|
||||
|
||||
export type DialogDescriptionProps = DialogDescriptionPropsWithoutHTML &
|
||||
Without<AcelPrimitiveDivAttributes, DialogDescriptionPropsWithoutHTML>;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// CLOSE
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
export type DialogClosePropsWithoutHTML = WithChild;
|
||||
|
||||
export type DialogCloseProps = DialogClosePropsWithoutHTML &
|
||||
Without<AcelPrimitiveButtonAttributes, DialogClosePropsWithoutHTML>;
|
||||
@ -1,9 +1,9 @@
|
||||
globalThis.bitsIdCounter ??= { current: 0 };
|
||||
globalThis.acelIdCounter ??= { current: 0 };
|
||||
|
||||
/**
|
||||
* Generates a unique ID based on a global counter.
|
||||
*/
|
||||
export function useId(prefix = "bits") {
|
||||
globalThis.bitsIdCounter.current++;
|
||||
return `${prefix}-${globalThis.bitsIdCounter.current}`;
|
||||
export function useId(prefix = "acel") {
|
||||
globalThis.acelIdCounter.current++;
|
||||
return `${prefix}-${globalThis.acelIdCounter.current}`;
|
||||
}
|
||||
|
||||
@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { boxWith, mergeProps } from "../../utilities/index";
|
||||
import type { TabsContentProps } from "../types";
|
||||
import { TabsContentState } from "../tabs.svelte";
|
||||
import { createId } from "../../internal/create-id";
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
children,
|
||||
child,
|
||||
id = createId(uid),
|
||||
ref = $bindable(null),
|
||||
value,
|
||||
...restProps
|
||||
}: TabsContentProps = $props();
|
||||
|
||||
const contentState = TabsContentState.create({
|
||||
value: boxWith(() => value),
|
||||
id: boxWith(() => id),
|
||||
ref: boxWith(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, contentState.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import { boxWith, mergeProps } from "../../utilities/index";
|
||||
import type { TabsListProps } from "../types";
|
||||
import { TabsListState } from "../tabs.svelte";
|
||||
import { createId } from "../../internal/create-id";
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
child,
|
||||
children,
|
||||
id = createId(uid),
|
||||
ref = $bindable(null),
|
||||
...restProps
|
||||
}: TabsListProps = $props();
|
||||
|
||||
const listState = TabsListState.create({
|
||||
id: boxWith(() => id),
|
||||
ref: boxWith(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, listState.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import { boxWith, mergeProps } from "../../utilities/index";
|
||||
import type { TabsTriggerProps } from "../types";
|
||||
import { TabsTriggerState } from "../tabs.svelte";
|
||||
import { createId } from "../../internal/create-id";
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
child,
|
||||
children,
|
||||
disabled = false,
|
||||
id = createId(uid),
|
||||
type = "button",
|
||||
value,
|
||||
ref = $bindable(null),
|
||||
...restProps
|
||||
}: TabsTriggerProps = $props();
|
||||
|
||||
const triggerState = TabsTriggerState.create({
|
||||
id: boxWith(() => id),
|
||||
disabled: boxWith(() => disabled ?? false),
|
||||
value: boxWith(() => value),
|
||||
ref: boxWith(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, triggerState.props, { type }));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<button {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</button>
|
||||
{/if}
|
||||
@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
import { boxWith, mergeProps } from "../../utilities/index";
|
||||
import type { TabsRootProps } from "../types";
|
||||
import { TabsRootState } from "../tabs.svelte";
|
||||
import { createId } from "../../internal/create-id";
|
||||
import { noop } from "../../internal/noop";
|
||||
|
||||
const uid = $props.id();
|
||||
|
||||
let {
|
||||
id = createId(uid),
|
||||
ref = $bindable(null),
|
||||
value = $bindable(""),
|
||||
onValueChange = noop,
|
||||
orientation = "horizontal",
|
||||
loop = true,
|
||||
activationMode = "automatic",
|
||||
disabled = false,
|
||||
children,
|
||||
child,
|
||||
...restProps
|
||||
}: TabsRootProps = $props();
|
||||
|
||||
const rootState = TabsRootState.create({
|
||||
id: boxWith(() => id),
|
||||
value: boxWith(
|
||||
() => value,
|
||||
(v) => {
|
||||
value = v;
|
||||
onValueChange(v);
|
||||
}
|
||||
),
|
||||
orientation: boxWith(() => orientation),
|
||||
loop: boxWith(() => loop),
|
||||
activationMode: boxWith(() => activationMode),
|
||||
disabled: boxWith(() => disabled),
|
||||
ref: boxWith(
|
||||
() => ref,
|
||||
(v) => (ref = v)
|
||||
),
|
||||
});
|
||||
|
||||
const mergedProps = $derived(mergeProps(restProps, rootState.props));
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: mergedProps })}
|
||||
{:else}
|
||||
<div {...mergedProps}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
{/if}
|
||||
@ -0,0 +1,11 @@
|
||||
export { default as Root } from "./components/tabs.svelte";
|
||||
export { default as Content } from "./components/tabs-content.svelte";
|
||||
export { default as List } from "./components/tabs-list.svelte";
|
||||
export { default as Trigger } from "./components/tabs-trigger.svelte";
|
||||
|
||||
export type {
|
||||
TabsRootProps as RootProps,
|
||||
TabsContentProps as ContentProps,
|
||||
TabsTriggerProps as TriggerProps,
|
||||
TabsListProps as ListProps,
|
||||
} from "./types";
|
||||
@ -0,0 +1 @@
|
||||
export * from "./exports";
|
||||
@ -0,0 +1,277 @@
|
||||
import { SvelteMap } from "svelte/reactivity";
|
||||
import {
|
||||
attachRef,
|
||||
boxWith,
|
||||
type ReadableBoxedValues,
|
||||
type WritableBoxedValues,
|
||||
} from "../utilities/index";
|
||||
import { Context, watch } from "runed";
|
||||
import type { TabsActivationMode } from "./types";
|
||||
import {
|
||||
createAcelAttrs,
|
||||
boolToStr,
|
||||
boolToEmptyStrOrUndef,
|
||||
boolToTrueOrUndef,
|
||||
} from "../internal/attrs";
|
||||
import { kbd } from "../internal/kbd";
|
||||
import type {
|
||||
AcelFocusEvent,
|
||||
AcelKeyboardEvent,
|
||||
AcelMouseEvent,
|
||||
RefAttachment,
|
||||
WithRefOpts,
|
||||
} from "../internal/types";
|
||||
import { RovingFocusGroup } from "../internal/roving-focus-group";
|
||||
|
||||
const tabsAttrs = createAcelAttrs({
|
||||
component: "tabs",
|
||||
parts: ["root", "list", "trigger", "content"],
|
||||
});
|
||||
|
||||
const TabsRootContext = new Context<TabsRootState>("Tabs.Root");
|
||||
|
||||
interface TabsRootStateOpts
|
||||
extends WithRefOpts,
|
||||
ReadableBoxedValues<{
|
||||
orientation: "horizontal" | "vertical";
|
||||
loop: boolean;
|
||||
activationMode: TabsActivationMode;
|
||||
disabled: boolean;
|
||||
}>,
|
||||
WritableBoxedValues<{
|
||||
value: string;
|
||||
}> {}
|
||||
|
||||
export class TabsRootState {
|
||||
static create(opts: TabsRootStateOpts) {
|
||||
return TabsRootContext.set(new TabsRootState(opts));
|
||||
}
|
||||
readonly opts: TabsRootStateOpts;
|
||||
readonly attachment: RefAttachment;
|
||||
readonly rovingFocusGroup: RovingFocusGroup;
|
||||
triggerIds = $state<string[]>([]);
|
||||
// holds the trigger ID for each value to associate it with the content
|
||||
readonly valueToTriggerId = new SvelteMap<string, string>();
|
||||
// holds the content ID for each value to associate it with the trigger
|
||||
readonly valueToContentId = new SvelteMap<string, string>();
|
||||
|
||||
constructor(opts: TabsRootStateOpts) {
|
||||
this.opts = opts;
|
||||
this.attachment = attachRef(opts.ref);
|
||||
this.rovingFocusGroup = new RovingFocusGroup({
|
||||
candidateAttr: tabsAttrs.trigger,
|
||||
rootNode: this.opts.ref,
|
||||
loop: this.opts.loop,
|
||||
orientation: this.opts.orientation,
|
||||
});
|
||||
}
|
||||
|
||||
registerTrigger(id: string, value: string) {
|
||||
this.triggerIds.push(id);
|
||||
this.valueToTriggerId.set(value, id);
|
||||
|
||||
// returns the deregister function
|
||||
return () => {
|
||||
this.triggerIds = this.triggerIds.filter((triggerId) => triggerId !== id);
|
||||
this.valueToTriggerId.delete(value);
|
||||
};
|
||||
}
|
||||
|
||||
registerContent(id: string, value: string) {
|
||||
this.valueToContentId.set(value, id);
|
||||
|
||||
// returns the deregister function
|
||||
return () => {
|
||||
this.valueToContentId.delete(value);
|
||||
};
|
||||
}
|
||||
|
||||
setValue(v: string) {
|
||||
this.opts.value.current = v;
|
||||
}
|
||||
|
||||
readonly props = $derived.by(
|
||||
() =>
|
||||
({
|
||||
id: this.opts.id.current,
|
||||
"data-orientation": this.opts.orientation.current,
|
||||
[tabsAttrs.root]: "",
|
||||
...this.attachment,
|
||||
}) as const
|
||||
);
|
||||
}
|
||||
|
||||
interface TabsListStateOpts extends WithRefOpts {}
|
||||
|
||||
export class TabsListState {
|
||||
static create(opts: TabsListStateOpts) {
|
||||
return new TabsListState(opts, TabsRootContext.get());
|
||||
}
|
||||
readonly opts: TabsListStateOpts;
|
||||
readonly root: TabsRootState;
|
||||
readonly attachment: RefAttachment;
|
||||
readonly #isDisabled = $derived.by(() => this.root.opts.disabled.current);
|
||||
|
||||
constructor(opts: TabsListStateOpts, root: TabsRootState) {
|
||||
this.opts = opts;
|
||||
this.root = root;
|
||||
this.attachment = attachRef(opts.ref);
|
||||
}
|
||||
|
||||
readonly props = $derived.by(
|
||||
() =>
|
||||
({
|
||||
id: this.opts.id.current,
|
||||
role: "tablist",
|
||||
"aria-orientation": this.root.opts.orientation.current,
|
||||
"data-orientation": this.root.opts.orientation.current,
|
||||
[tabsAttrs.list]: "",
|
||||
"data-disabled": boolToEmptyStrOrUndef(this.#isDisabled),
|
||||
...this.attachment,
|
||||
}) as const
|
||||
);
|
||||
}
|
||||
|
||||
interface TabsTriggerStateOpts
|
||||
extends WithRefOpts,
|
||||
ReadableBoxedValues<{
|
||||
value: string;
|
||||
disabled: boolean;
|
||||
}> {}
|
||||
|
||||
export class TabsTriggerState {
|
||||
static create(opts: TabsTriggerStateOpts) {
|
||||
return new TabsTriggerState(opts, TabsRootContext.get());
|
||||
}
|
||||
readonly opts: TabsTriggerStateOpts;
|
||||
readonly root: TabsRootState;
|
||||
readonly attachment: RefAttachment;
|
||||
#tabIndex = $state(0);
|
||||
readonly #isActive = $derived.by(
|
||||
() => this.root.opts.value.current === this.opts.value.current
|
||||
);
|
||||
readonly #isDisabled = $derived.by(
|
||||
() => this.opts.disabled.current || this.root.opts.disabled.current
|
||||
);
|
||||
readonly #ariaControls = $derived.by(() =>
|
||||
this.root.valueToContentId.get(this.opts.value.current)
|
||||
);
|
||||
|
||||
constructor(opts: TabsTriggerStateOpts, root: TabsRootState) {
|
||||
this.opts = opts;
|
||||
this.root = root;
|
||||
this.attachment = attachRef(opts.ref);
|
||||
watch([() => this.opts.id.current, () => this.opts.value.current], ([id, value]) => {
|
||||
return this.root.registerTrigger(id, value);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
this.root.triggerIds.length;
|
||||
if (this.#isActive || !this.root.opts.value.current) {
|
||||
this.#tabIndex = 0;
|
||||
} else {
|
||||
this.#tabIndex = -1;
|
||||
}
|
||||
});
|
||||
this.onfocus = this.onfocus.bind(this);
|
||||
this.onclick = this.onclick.bind(this);
|
||||
this.onkeydown = this.onkeydown.bind(this);
|
||||
}
|
||||
|
||||
#activate() {
|
||||
if (this.root.opts.value.current === this.opts.value.current) return;
|
||||
this.root.setValue(this.opts.value.current);
|
||||
}
|
||||
|
||||
onfocus(_: AcelFocusEvent) {
|
||||
if (this.root.opts.activationMode.current !== "automatic" || this.#isDisabled) return;
|
||||
this.#activate();
|
||||
}
|
||||
|
||||
onclick(_: AcelMouseEvent) {
|
||||
if (this.#isDisabled) return;
|
||||
this.#activate();
|
||||
}
|
||||
|
||||
onkeydown(e: AcelKeyboardEvent) {
|
||||
if (this.#isDisabled) return;
|
||||
if (e.key === kbd.SPACE || e.key === kbd.ENTER) {
|
||||
e.preventDefault();
|
||||
this.#activate();
|
||||
return;
|
||||
}
|
||||
this.root.rovingFocusGroup.handleKeydown(this.opts.ref.current, e);
|
||||
}
|
||||
|
||||
readonly props = $derived.by(
|
||||
() =>
|
||||
({
|
||||
id: this.opts.id.current,
|
||||
role: "tab",
|
||||
"data-state": getTabDataState(this.#isActive),
|
||||
"data-value": this.opts.value.current,
|
||||
"data-orientation": this.root.opts.orientation.current,
|
||||
"data-disabled": boolToEmptyStrOrUndef(this.#isDisabled),
|
||||
"aria-selected": boolToStr(this.#isActive),
|
||||
"aria-controls": this.#ariaControls,
|
||||
[tabsAttrs.trigger]: "",
|
||||
disabled: boolToTrueOrUndef(this.#isDisabled),
|
||||
tabindex: this.#tabIndex,
|
||||
//
|
||||
onclick: this.onclick,
|
||||
onfocus: this.onfocus,
|
||||
onkeydown: this.onkeydown,
|
||||
...this.attachment,
|
||||
}) as const
|
||||
);
|
||||
}
|
||||
|
||||
interface TabsContentStateOpts
|
||||
extends WithRefOpts,
|
||||
ReadableBoxedValues<{
|
||||
value: string;
|
||||
}> {}
|
||||
|
||||
export class TabsContentState {
|
||||
static create(opts: TabsContentStateOpts) {
|
||||
return new TabsContentState(opts, TabsRootContext.get());
|
||||
}
|
||||
readonly opts: TabsContentStateOpts;
|
||||
readonly root: TabsRootState;
|
||||
readonly attachment: RefAttachment;
|
||||
readonly #isActive = $derived.by(
|
||||
() => this.root.opts.value.current === this.opts.value.current
|
||||
);
|
||||
readonly #ariaLabelledBy = $derived.by(() =>
|
||||
this.root.valueToTriggerId.get(this.opts.value.current)
|
||||
);
|
||||
|
||||
constructor(opts: TabsContentStateOpts, root: TabsRootState) {
|
||||
this.opts = opts;
|
||||
this.root = root;
|
||||
this.attachment = attachRef(opts.ref);
|
||||
watch([() => this.opts.id.current, () => this.opts.value.current], ([id, value]) => {
|
||||
return this.root.registerContent(id, value);
|
||||
});
|
||||
}
|
||||
|
||||
readonly props = $derived.by(
|
||||
() =>
|
||||
({
|
||||
id: this.opts.id.current,
|
||||
role: "tabpanel",
|
||||
hidden: boolToTrueOrUndef(!this.#isActive),
|
||||
tabindex: 0,
|
||||
"data-value": this.opts.value.current,
|
||||
"data-state": getTabDataState(this.#isActive),
|
||||
"aria-labelledby": this.#ariaLabelledBy,
|
||||
"data-orientation": this.root.opts.orientation.current,
|
||||
[tabsAttrs.content]: "",
|
||||
...this.attachment,
|
||||
}) as const
|
||||
);
|
||||
}
|
||||
|
||||
function getTabDataState(condition: boolean): "active" | "inactive" {
|
||||
return condition ? "active" : "inactive";
|
||||
}
|
||||
@ -0,0 +1,89 @@
|
||||
import type {
|
||||
OnChangeFn,
|
||||
WithChild,
|
||||
Without
|
||||
} from "../internal/types";
|
||||
import type {
|
||||
AcelPrimitiveButtonAttributes,
|
||||
AcelPrimitiveDivAttributes
|
||||
} from "../shared/attributes";
|
||||
|
||||
export type TabsActivationMode = "manual" | "automatic";
|
||||
|
||||
export type TabsRootPropsWithoutHTML = WithChild<{
|
||||
/**
|
||||
* The value of the selected tab.
|
||||
*/
|
||||
value?: string;
|
||||
|
||||
/**
|
||||
* A callback function called when the selected tab changes.
|
||||
*/
|
||||
onValueChange?: OnChangeFn<string>;
|
||||
|
||||
/**
|
||||
* The orientation of the tabs.
|
||||
*
|
||||
* @defaultValue "horizontal"
|
||||
*/
|
||||
orientation?: "horizontal" | "vertical";
|
||||
|
||||
/**
|
||||
* Whether to loop through the tabs when reaching the end
|
||||
* when using the keyboard.
|
||||
*
|
||||
* @defaultValue true
|
||||
*/
|
||||
loop?: boolean;
|
||||
|
||||
/**
|
||||
* How the tabs should be activated. If set to `'automatic'`, the tabs
|
||||
* will be activated when the trigger is focused. If set to `'manual'`,
|
||||
* the tabs will be activated when the trigger is pressed.
|
||||
*
|
||||
* @defaultValue "automatic"
|
||||
*/
|
||||
activationMode?: TabsActivationMode;
|
||||
|
||||
/**
|
||||
* Whether the tabs are disabled or not.
|
||||
*
|
||||
* @defaultValue false
|
||||
*/
|
||||
disabled?: boolean;
|
||||
}>;
|
||||
|
||||
export type TabsRootProps = TabsRootPropsWithoutHTML &
|
||||
Without<AcelPrimitiveDivAttributes, TabsRootPropsWithoutHTML>;
|
||||
|
||||
export type TabsListPropsWithoutHTML = WithChild;
|
||||
|
||||
export type TabsListProps = TabsListPropsWithoutHTML &
|
||||
Without<AcelPrimitiveDivAttributes, TabsListPropsWithoutHTML>;
|
||||
|
||||
export type TabsTriggerPropsWithoutHTML = WithChild<{
|
||||
/**
|
||||
* The value of the tab associated with this trigger.
|
||||
*/
|
||||
value: string;
|
||||
|
||||
/**
|
||||
* Whether the trigger is disabled or not.
|
||||
*
|
||||
* @defaultValue false
|
||||
*/
|
||||
disabled?: boolean | null | undefined;
|
||||
}>;
|
||||
|
||||
export type TabsTriggerProps = TabsTriggerPropsWithoutHTML &
|
||||
Without<AcelPrimitiveButtonAttributes, TabsTriggerPropsWithoutHTML>;
|
||||
|
||||
export type TabsContentPropsWithoutHTML = WithChild<{
|
||||
/**
|
||||
* The value of the tab associated with this content.
|
||||
*/
|
||||
value: string;
|
||||
}>;
|
||||
|
||||
export type TabsContentProps = TabsContentPropsWithoutHTML &
|
||||
Without<AcelPrimitiveDivAttributes, TabsContentPropsWithoutHTML>;
|
||||
@ -0,0 +1,3 @@
|
||||
export * from './box-extras.svelte';
|
||||
export * from './unbox.svelte';
|
||||
export * from './box.svelte';
|
||||
@ -1,5 +1,5 @@
|
||||
import type { EventCallback } from "./events/events.ts";
|
||||
import type { ReadableBox } from "./box/box-extras.svelte.ts";
|
||||
import type { EventCallback } from "./events.ts";
|
||||
import type { ReadableBox } from "../box/box-extras.svelte.ts";
|
||||
|
||||
/**
|
||||
* Composes event handlers into a single function that can be called with an event.
|
||||
@ -1,5 +1,5 @@
|
||||
import { untrack } from "svelte";
|
||||
import { isBox, type WritableBox } from "./box/box-extras.svelte.ts";
|
||||
import { isBox, type WritableBox } from "../box/box-extras.svelte.ts";
|
||||
import { createAttachmentKey } from "svelte/attachments";
|
||||
|
||||
type RefSetter<T> = (v: T) => void;
|
||||
@ -1,4 +1,4 @@
|
||||
import type { Getter } from "$lib/types.js";
|
||||
import type { Getter } from "../types.js";
|
||||
import { watch } from "runed";
|
||||
|
||||
/**
|
||||
@ -1,7 +1,7 @@
|
||||
import type { WritableBox } from "$lib/box/box-extras.svelte.js";
|
||||
import type { Box, Getter } from "$lib/types.js";
|
||||
import type { WritableBox } from "../box/box-extras.svelte.ts";
|
||||
import type { Box, Getter } from "../types.ts";
|
||||
import { watch } from "runed";
|
||||
import { onDestroyEffect } from "./effects/on-destroy-effect.svelte.js";
|
||||
import { onDestroyEffect } from "../effects/on-destroy-effect.svelte.ts";
|
||||
|
||||
type UseRefByIdProps = {
|
||||
/**
|
||||
Loading…
Reference in new issue