feat: port Dialog to acel, refactor alert-dialog, fix TS errors, add test pages

- 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
dev 7 months ago
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)"
]
}
}

11
package-lock.json generated

@ -30,6 +30,7 @@
"svelte": "^5.51.0",
"svelte-check": "^4.4.2",
"tailwindcss": "^4.1.18",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vitest": "^4.1.0",
@ -2637,6 +2638,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/tailwindcss-animate": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz",
"integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"tailwindcss": ">=3.0.0 || insiders"
}
},
"node_modules/tapable": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.1.tgz",

@ -30,15 +30,16 @@
"svelte": "^5.51.0",
"svelte-check": "^4.4.2",
"tailwindcss": "^4.1.18",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vitest": "^4.1.0",
"vitest-browser-svelte": "^2.0.2"
},
"dependencies": {
"csstype": "^3.2.3",
"@floating-ui/core": "^1.7.1",
"@floating-ui/dom": "^1.7.1",
"csstype": "^3.2.3",
"esm-env": "^1.1.2",
"runed": "0.37.1",
"tabbable": "^6.2.0"

@ -471,7 +471,7 @@ function getCachedDTF(locale: string, options: Intl.DateTimeFormatOptions = {}):
if (typeof options.hour12 === 'boolean' && hasBuggyHour12Behavior()) {
options = { ...options };
const pref = hour12Prefs[String(options.hour12)]?.[locale.split('-')[0]];
options.hourCycle = pref ?? (options.hour12 ? 'h12' : 'h23');
options.hourCycle = (pref ?? (options.hour12 ? 'h12' : 'h23')) as Intl.DateTimeFormatOptions['hourCycle'];
delete options.hour12;
}

@ -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,198 @@
/*
* 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.
*/
let formatterCache = new Map<string, Intl.DateTimeFormat>();
interface DateRangeFormatPart extends Intl.DateTimeFormatPart {
source: 'startRange' | 'endRange' | 'shared'
}
/** A wrapper around Intl.DateTimeFormat that fixes various browser bugs, and polyfills new features. */
export class DateFormatter implements Intl.DateTimeFormat {
private formatter: Intl.DateTimeFormat;
private options: Intl.DateTimeFormatOptions;
private resolvedHourCycle: Intl.DateTimeFormatOptions['hourCycle'];
constructor(locale: string, options: Intl.DateTimeFormatOptions = {}) {
this.formatter = getCachedDateFormatter(locale, options);
this.options = options;
}
/** Formats a date as a string according to the locale and format options passed to the constructor. */
format(value: Date): string {
return this.formatter.format(value);
}
/** Formats a date to an array of parts such as separators, numbers, punctuation, and more. */
formatToParts(value: Date): Intl.DateTimeFormatPart[] {
return this.formatter.formatToParts(value);
}
/** Formats a date range as a string. */
formatRange(start: Date, end: Date): string {
// @ts-ignore
if (typeof this.formatter.formatRange === 'function') {
// @ts-ignore
return this.formatter.formatRange(start, end);
}
if (end < start) {
throw new RangeError('End date must be >= start date');
}
// Very basic fallback for old browsers.
return `${this.formatter.format(start)} – ${this.formatter.format(end)}`;
}
/** Formats a date range as an array of parts. */
formatRangeToParts(start: Date, end: Date): DateRangeFormatPart[] {
// @ts-ignore
if (typeof this.formatter.formatRangeToParts === 'function') {
// @ts-ignore
return this.formatter.formatRangeToParts(start, end);
}
if (end < start) {
throw new RangeError('End date must be >= start date');
}
let startParts = this.formatter.formatToParts(start);
let endParts = this.formatter.formatToParts(end);
return [
...startParts.map(p => ({...p, source: 'startRange'} as DateRangeFormatPart)),
{type: 'literal', value: ' – ', source: 'shared'},
...endParts.map(p => ({...p, source: 'endRange'} as DateRangeFormatPart))
];
}
/** Returns the resolved formatting options based on the values passed to the constructor. */
resolvedOptions(): Intl.ResolvedDateTimeFormatOptions {
let resolvedOptions = this.formatter.resolvedOptions();
if (hasBuggyResolvedHourCycle()) {
if (!this.resolvedHourCycle) {
this.resolvedHourCycle = getResolvedHourCycle(resolvedOptions.locale, this.options);
}
resolvedOptions.hourCycle = this.resolvedHourCycle;
resolvedOptions.hour12 = this.resolvedHourCycle === 'h11' || this.resolvedHourCycle === 'h12';
}
// Safari uses a different name for the Ethiopic (Amete Alem) calendar.
// https://bugs.webkit.org/show_bug.cgi?id=241564
if (resolvedOptions.calendar === 'ethiopic-amete-alem') {
resolvedOptions.calendar = 'ethioaa';
}
return resolvedOptions;
}
}
// There are multiple bugs involving the hour12 and hourCycle options in various browser engines.
// - Chrome [1] (and the ECMA 402 spec [2]) resolve hour12: false in English and other locales to h24 (24:00 - 23:59)
// rather than h23 (00:00 - 23:59). Same can happen with hour12: true in French, which Chrome resolves to h11 (00:00 - 11:59)
// rather than h12 (12:00 - 11:59).
// - WebKit returns an incorrect hourCycle resolved option in the French locale due to incorrect parsing of 'h' literal
// in the resolved pattern. It also formats incorrectly when specifying the hourCycle option for the same reason. [3]
// [1] https://bugs.chromium.org/p/chromium/issues/detail?id=1045791
// [2] https://github.com/tc39/ecma402/issues/402
// [3] https://bugs.webkit.org/show_bug.cgi?id=229313
// https://github.com/unicode-org/cldr/blob/018b55eff7ceb389c7e3fc44e2f657eae3b10b38/common/supplemental/supplementalData.xml#L4774-L4802
const hour12Preferences = {
true: {
// Only Japanese uses the h11 style for 12 hour time. All others use h12.
ja: 'h11'
},
false: {
// All locales use h23 for 24 hour time. None use h24.
}
};
function getCachedDateFormatter(locale: string, options: Intl.DateTimeFormatOptions = {}): Intl.DateTimeFormat {
// Work around buggy hour12 behavior in Chrome / ECMA 402 spec by using hourCycle instead.
// Only apply the workaround if the issue is detected, because the hourCycle option is buggy in Safari.
if (typeof options.hour12 === 'boolean' && hasBuggyHour12Behavior()) {
options = {...options};
let pref = (hour12Preferences[String(options.hour12) as keyof typeof hour12Preferences] as Record<string, string>)[locale.split('-')[0]];
let defaultHourCycle = options.hour12 ? 'h12' : 'h23';
options.hourCycle = (pref ?? defaultHourCycle) as Intl.DateTimeFormatOptions['hourCycle'];
delete options.hour12;
}
let cacheKey = locale + (options ? Object.entries(options).sort((a, b) => a[0] < b[0] ? -1 : 1).join() : '');
if (formatterCache.has(cacheKey)) {
return formatterCache.get(cacheKey)!;
}
let numberFormatter = new Intl.DateTimeFormat(locale, options);
formatterCache.set(cacheKey, numberFormatter);
return numberFormatter;
}
let _hasBuggyHour12Behavior: boolean | null = null;
function hasBuggyHour12Behavior() {
if (_hasBuggyHour12Behavior == null) {
_hasBuggyHour12Behavior = new Intl.DateTimeFormat('en-US', {
hour: 'numeric',
hour12: false
}).format(new Date(2020, 2, 3, 0)) === '24';
}
return _hasBuggyHour12Behavior;
}
let _hasBuggyResolvedHourCycle: boolean | null = null;
function hasBuggyResolvedHourCycle() {
if (_hasBuggyResolvedHourCycle == null) {
_hasBuggyResolvedHourCycle = new Intl.DateTimeFormat('fr', {
hour: 'numeric',
hour12: false
}).resolvedOptions().hourCycle === 'h12';
}
return _hasBuggyResolvedHourCycle;
}
function getResolvedHourCycle(locale: string, options: Intl.DateTimeFormatOptions) {
if (!options.timeStyle && !options.hour) {
return undefined;
}
// Work around buggy results in resolved hourCycle and hour12 options in WebKit.
// Format the minimum possible hour and maximum possible hour in a day and parse the results.
locale = locale.replace(/(-u-)?-nu-[a-zA-Z0-9]+/, '');
locale += (locale.includes('-u-') ? '' : '-u') + '-nu-latn';
let formatter = getCachedDateFormatter(locale, {
...options,
timeZone: undefined // use local timezone
});
let min = parseInt(formatter.formatToParts(new Date(2020, 2, 3, 0)).find(p => p.type === 'hour')!.value, 10);
let max = parseInt(formatter.formatToParts(new Date(2020, 2, 3, 23)).find(p => p.type === 'hour')!.value, 10);
if (min === 0 && max === 23) {
return 'h23';
}
if (min === 24 && max === 23) {
return 'h24';
}
if (min === 0 && max === 11) {
return 'h11';
}
if (min === 12 && max === 11) {
return 'h12';
}
throw new Error('Unexpected hour cycle result');
}

@ -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
};

@ -48,4 +48,9 @@ export interface DatiInstance {
getDateOrder : () => DatiDateOrder;
/** Orden de fecha actual. Reactivo en la versión svelte. */
readonly dateOrder : DatiDateOrder;
}
}

@ -9,3 +9,5 @@ export * from './curr_engine.ts';
export * from './dati_engine.ts';
export * from './numr_engine.ts';
export * from './unit_engine.ts';
export * from './date/index.ts';

@ -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 &lt;button&gt;</span>
</header>
<p class="card-desc">
Sin <code>href</code> renderiza un <code>&lt;button&gt;</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 &lt;a&gt;</span>
</header>
<p class="card-desc">
Con <code>href</code> renderiza un <code>&lt;a&gt;</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>&lt;a href="…"&gt;</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>&lt;button&gt;</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>&lt;a&gt;</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>

@ -8,8 +8,8 @@ import {
} from "../utilities/index";
import { Context, watch } from "runed";
import type {
BitsKeyboardEvent,
BitsMouseEvent,
AcelKeyboardEvent,
AcelMouseEvent,
RefAttachment,
WithRefOpts,
} from "../internal/types";
@ -242,7 +242,7 @@ export class AccordionTriggerState {
return new AccordionTriggerState(props, AccordionItemContext.get());
}
onclick(e: BitsMouseEvent): void {
onclick(e: AcelMouseEvent): void {
if (this.#isDisabled || e.button !== 0) {
e.preventDefault();
return;
@ -250,7 +250,7 @@ export class AccordionTriggerState {
this.itemState.updateValue();
}
onkeydown(e: BitsKeyboardEvent): void {
onkeydown(e: AcelKeyboardEvent): void {
if (this.#isDisabled) return;
if (e.key === kbd.SPACE || e.key === kbd.ENTER) {

@ -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";

@ -14,8 +14,8 @@ import {
} from "../internal/attrs";
import { kbd } from "../internal/kbd";
import type {
BitsKeyboardEvent,
BitsMouseEvent,
AcelKeyboardEvent,
AcelMouseEvent,
OnChangeFn,
RefAttachment,
WithRefOpts,
@ -190,10 +190,10 @@ export class CollapsibleContentState {
({
id: this.opts.id.current,
style: {
"--bits-collapsible-content-height": this.#height
"--acel-collapsible-content-height": this.#height
? `${this.#height}px`
: undefined,
"--bits-collapsible-content-width": this.#width
"--acel-collapsible-content-width": this.#width
? `${this.#width}px`
: undefined,
},
@ -242,13 +242,13 @@ export class CollapsibleTriggerState {
this.onkeydown = this.onkeydown.bind(this);
}
onclick(e: BitsMouseEvent) {
onclick(e: AcelMouseEvent) {
if (this.#isDisabled) return;
if (e.button !== 0) return e.preventDefault();
this.root.toggleOpen();
}
onkeydown(e: BitsKeyboardEvent) {
onkeydown(e: AcelKeyboardEvent) {
if (this.#isDisabled) return;
if (e.key === kbd.SPACE || e.key === kbd.ENTER) {

@ -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>;

@ -25,3 +25,8 @@ export { Modal, ModalHost, Drawer } from './modal';
export * as Accordion from './accordion';
export * as Button from './button';
export * as Collapsible from './collapsible';
export * as Tabs from './tabs';
// Overlay
export * as AlertDialog from './alert-dialog';
export * as Dialog from './dialog';

@ -81,14 +81,14 @@ export type WithRefOpts<T = {}> = T &
ReadableBoxedValues<{ id: string }> &
WritableBoxedValues<{ ref: HTMLElement | null }>;
export type BitsEvent<T extends Event = Event, U extends HTMLElement = HTMLElement> = T & {
export type AcelEvent<T extends Event = Event, U extends HTMLElement = HTMLElement> = T & {
currentTarget: U;
};
export type BitsPointerEvent<T extends HTMLElement = HTMLElement> = BitsEvent<PointerEvent, T>;
export type BitsKeyboardEvent<T extends HTMLElement = HTMLElement> = BitsEvent<KeyboardEvent, T>;
export type BitsMouseEvent<T extends HTMLElement = HTMLElement> = BitsEvent<MouseEvent, T>;
export type BitsFocusEvent<T extends HTMLElement = HTMLElement> = BitsEvent<FocusEvent, T>;
export type BitsInputEvent<T extends HTMLElement = HTMLElement> = BitsEvent<InputEvent, T>;
export type AcelPointerEvent<T extends HTMLElement = HTMLElement> = AcelEvent<PointerEvent, T>;
export type AcelKeyboardEvent<T extends HTMLElement = HTMLElement> = AcelEvent<KeyboardEvent, T>;
export type AcelMouseEvent<T extends HTMLElement = HTMLElement> = AcelEvent<MouseEvent, T>;
export type AcelFocusEvent<T extends HTMLElement = HTMLElement> = AcelEvent<FocusEvent, T>;
export type AcelInputEvent<T extends HTMLElement = HTMLElement> = AcelEvent<InputEvent, T>;
export type RefAttachment<T extends HTMLElement = HTMLElement> = ReturnType<typeof attachRef<T>>;

@ -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}`;
}

@ -1,10 +1,16 @@
import type { CalendarDateTime, DateValue, Time, ZonedDateTime } from "@internationalized/date";
import type {
CalendarDateTime,
DateValue,
Time,
ZonedDateTime
} from "@/glob/lib";
import type {
DATE_SEGMENT_PARTS,
EDITABLE_SEGMENT_PARTS,
NON_EDITABLE_SEGMENT_PARTS,
EDITABLE_TIME_SEGMENT_PARTS,
} from "$lib/internal/date-time/field/parts.js";
} from "../../internal/date-time/field/parts.ts";
export type Granularity = "day" | "hour" | "minute" | "second";
export type TimeGranularity = "hour" | "minute" | "second";

@ -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.

@ -12,20 +12,20 @@ export {
} from "./box/box-extras.svelte.ts";
export { unbox } from "./box/unbox.svelte.ts";
export * from "./types.ts";
export { composeHandlers } from "./compose-handlers.ts";
export { composeHandlers } from "./events/compose-handlers.ts";
export { cssToStyleObj } from "./css/css.ts";
export { executeCallbacks } from "./callbacks/execute-callbacks.ts";
export { addEventListener, type EventCallback } from "./events/events.ts";
export { mergeProps } from "./props/merge-props.ts";
export { styleToString } from "./css/css.ts";
export { srOnlyStyles, srOnlyStylesString } from "./css/css.ts";
export { useRefById } from "./use-ref-by-id.svelte.ts";
export { useRefById } from "./svelte/use-ref-by-id.svelte.ts";
export { pascalCase, camelCase, kebabCase } from "../../../lib/util/strings/strings.ts";
export { onDestroyEffect } from "./effects/on-destroy-effect.svelte.ts";
export { onMountEffect } from "./effects/on-mount-effect.svelte.ts";
export { afterSleep } from "./after/after.ts";
export { afterTick } from "./after/after.ts";
export { useOnChange } from "./use-on-change.svelte.ts";
export { useOnChange } from "./svelte/use-on-change.svelte.ts";
export { styleToCSS } from "./css/css.ts";
export {
isHTMLElement,
@ -42,4 +42,4 @@ export {
getParentNode
} from "./dom/dom.ts";
export { DOMContext } from "./dom/dom-context.svelte.ts";
export { attachRef } from "./attach-ref.ts";
export { attachRef } from "./svelte/attach-ref.ts";

@ -3,7 +3,7 @@
*/
import { clsx } from "clsx";
import type { EventCallback } from "../events/events.ts";
import { composeHandlers } from "../compose-handlers.ts";
import { composeHandlers } from "../events/compose-handlers.ts";
import { cssToStyleObj } from "../css/css.ts";
import { isClassValue } from "../is.ts";
import { executeCallbacks } from "../callbacks/execute-callbacks.ts";

@ -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…
Cancel
Save

Powered by TurnKey Linux.