soma: dias date library, Calendar + RangeCalendar, Command fixes, doc updates

**Date library reorganisation** (no new third-party dependency)

- New `src/lib/util/dias/` with `_vendor/` (Adobe Apache 2.0 vendored code,
  ATTRIBUTION.md preserved) and a public facade split into `types`,
  `values`, `calendars`, `queries`, `operations`, `parse`, `format`,
  `index`. Consumers import only from `$lib/util/dias`.
- All date-related `Intl` consolidated in `dias/format.ts` —
  `DateFormatter`, cache, `resolveDateOrder`, `resolveHourCycle`,
  placeholders, defaults.
- `ext/dates` now a thin reactive wrapper delegating to `dias`.
- Aligned App-layer `HourCycle` to canonical numeric form `12 | 24`
  (matches `Intl.DateTimeFormat`'s `hour12`). Removed string form
  `'12h' | '24h'` from `ext/app/types`, `ext/dates`,
  `ext/presentation/locale-defaults`, and their tests.
- Repointed every consumer: `soma/external/dates`,
  `terra/external/dates`, eight `routes/test/*` pages.
- Legacy `src/lib/util/dates/` left untouched by request.

**Tier 3 components**

- `soma/components/calendar/` — 14 parts, single + multiple selection,
  `MonthSelect`/`YearSelect`, ISO/simple week numbers,
  `isDateHoliday` matcher, `data-weekend` flag, navigation live-
  region, RTL flip, full ARIA, 27-item checklist verified.
- `soma/components/range-calendar/` — 14 parts, range selection with
  live hover preview (novel vs ark/bits/radix/react-aria), `minDays`
  / `maxDays` / `allowSingleDay`, third-click deselects only the
  clicked endpoint.
- Test pages at `/test/soma/calendar` and `/test/soma/range-calendar`
  with seven demos each. Links added to the soma index under a new
  "Dates" section.
- READMEs with anatomy, parts, props, ARIA, keyboard, data-attrs, and
  a competitive comparison table.

**Command fixes**

- Resolved O(N^2) reactive cascade: introduced
  `visibleSet: $derived<Set<string>>` on the root provider;
  per-item `isVisible` is now an O(1) `has()` lookup instead of
  calling back into the provider, which was re-running all items'
  derivations on every `itemsVersion` bump.
- Added `dir` prop with fallback to `soma.presentation.getDir()`;
  horizontal arrows flip in RTL via `getDirectionalKeys`; root
  receives `dir` attribute.

**Doc updates (session outcomes)**

- `CLAUDE.md`: replaced obsolete `@/glob/lib` date references with
  `$lib/util/dias`; added canonical date-layer section; reinforced
  `Provider` export rule and `data-{component}[-{part}]` naming
  (never `data-soma-*`); clarified translation convention.
- `src/uix/soma/COMPONENT_GUIDE.md`: new checklist items 26
  (component README required) and 27 (grep for data-attr
  consistency); new Common Mistakes 11 (`data-soma-*` prefix), 12
  (wrong date module), 13 (string `HourCycle`).
- `src/uix/soma/SOMA_ARCHITECTURE.md`: clarified data-attr naming
  (canonical emitter is `createAttrs`; validator checks enum
  values only, not names); new section covering date types and
  formatting through `$soma/external/dates` → `$lib/util/dias`;
  documented numeric `HourCycle`.

**Infrastructure**

- `PrimitiveHeaderAttributes` and `PrimitiveSelectAttributes` added
  to `$soma/types`.
- `getLastFirstDayOfWeek`, `getNextLastDayOfWeek` re-exported from
  `$soma/external/dates`.
- `calendar.*` and `range-calendar.*` translation blocks added to
  `$soma/core/langs.ts`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
semantuix
dev 6 months ago
parent ca768f068a
commit 69a8e8d4b5

@ -29,25 +29,26 @@ npm run format # Auto-format with Prettier
Aliases must be synced in BOTH `svelte.config.js` AND `vite.config.ts`:
| Alias | Target |
|---|---|
| `@/` | `src/` |
| `@/ling` | `src/lib/ling` (i18n) |
| `@/logr` | `src/lib/logr` (logging) |
| `@/glob` | `src/lib/glob` (globalization: dates, numbers, locale) |
| `@/actx` | `src/lib/actx` (app context) |
| `@/uiux` | `src/lib/uiux` |
| `$uix` | `src/uix` (UI layers) |
| `$terra` | `src/uix/terra` (headless primitives) |
| `$terra/utils` | `src/uix/terra/utils/sound.svelte.ts` |
| `$terra/system` | `src/uix/terra/system/exports.ts` |
| `$terra/external/dates` | `src/uix/terra/external/dates/sound.svelte.ts` |
| `$glob` | `src/lib/glob` |
| `$lib` | `src/lib` |
| Alias | Target |
| ----------------------- | ------------------------------------------------------ |
| `@/` | `src/` |
| `@/ling` | `src/lib/ling` (i18n) |
| `@/logr` | `src/lib/logr` (logging) |
| `@/glob` | `src/lib/glob` (legacy; do not use for new code) |
| `@/actx` | `src/lib/actx` (app context) |
| `@/uiux` | `src/lib/uiux` |
| `$uix` | `src/uix` (UI layers) |
| `$terra` | `src/uix/terra` (headless primitives) |
| `$terra/utils` | `src/uix/terra/utils/sound.svelte.ts` |
| `$terra/system` | `src/uix/terra/system/exports.ts` |
| `$terra/external/dates` | `src/uix/terra/external/dates/sound.svelte.ts` |
| `$lib` | `src/lib` |
| `$lib/util/dias` | `src/lib/util/dias` (date/time library — canonical) |
## Vitest Two-Project Structure
`vite.config.ts` defines two test projects:
- **client**: Browser tests via Playwright for `*.svelte.{test,spec}.{js,ts}`
- **server**: Node environment for `*.{test,spec}.{js,ts}` (excludes svelte tests)
@ -66,6 +67,7 @@ The dependency flows one way: `air` → `terra`. Terra never imports from air.
Headless primitive library. Each component follows `Element.SubElement` pattern (`Dialog.Root`, `Dialog.Trigger`, `Dialog.Content`).
**Key patterns:**
- Thin `.svelte` wrappers + logic in `*.svelte.ts` State classes
- State classes use `static create()` for context registration, `createComposed()` for internal composition without context
- Reactive system: `Active<T>`, `State<T>`, `readableActive()`, `writableActive()` — values travel as boxes with `.current`
@ -75,6 +77,7 @@ Headless primitive library. Each component follows `Element.SubElement` pattern
- `child` snippet replaces the rendered node; `children` fills the interior
**Structure:**
- `[component]/components/` — Svelte wrappers
- `[component]/types.ts` — public props
- `[component]/*.svelte.ts` — State classes
@ -92,21 +95,25 @@ Headless primitive library. Each component follows `Element.SubElement` pattern
Visual layer built on terra. Consumes terra's public anatomy and `data-*` attrs — never internal classes or incidental DOM structure.
**Three-level token system:**
1. **Primitive tokens** (`tokens/primitive.css`) — absolute scale values, never used directly by components
2. **Semantic tokens** (`tokens/semantic.css`) — theme-aware, switch via `[data-theme]`
3. **Component tokens** (`tokens/components/*.css`) — scoped per component (`--air-button-*`), the only level component CSS writes
**Token naming:**
- Public: `--air-{component}-*` (stable, overridable)
- Private: `--_air-{component}-*` (implementation detail, may change)
**Contracts** (`contracts/`):
- `contract.css` — minimum semantic tokens any theme must implement
- `contract-categorical.css` — optional categorical color families
- `contract-primitives.css` — optional advanced 12-step scales
- `contracts/components/*.css` — per-component public token API
**CSS rules:**
- Tokens and `data-*` selectors must be global CSS (imported `.css` files), not `<style scoped>`
- Svelte scoped styles only for properties that don't depend on `data-*` or terra internals
- Components respond to terra states purely via `data-*` selectors: `[data-disabled]`, `[data-state='open']`, etc.
@ -114,6 +121,7 @@ Visual layer built on terra. Consumes terra's public anatomy and `data-*` attrs
- Variant dimensions are orthogonal: `size` → dimensions, `variant` → appearance, `state` → transversal overrides
**Structure:**
- `components/` — styled wrappers over terra primitives
- `tokens/` — token implementation (primitive, semantic, per-component)
- `contracts/` — public token interfaces for theme authors
@ -124,29 +132,159 @@ Visual layer built on terra. Consumes terra's public anatomy and `data-*` attrs
- `ling` — i18n system (factory: `createLing`)
- `logr` — logging (factory: `createLogr`, depends on `ling`)
- `glob` — globalization: dates, numbers, currency, locale
- `ext/` — App-layer reactive services: `app`, `dates`, `lang`, `money`, `nums`, `presentation`, `units`
- `util/dias` — canonical date/time library. Single public door at `$lib/util/dias`. `_vendor/` holds the internal implementation (Adobe Apache 2.0 vendored code) and MUST NOT be imported by consumers
- `glob` — legacy (pre-App-layer); do not use for new code
- `actx` — app context
- `vice` — app-level utilities
**Important:** `DateValue` and all date utilities come from `@/glob/lib`, never from `@internationalized/date` directly. Within terra, use `$terra/external/dates`.
**Dates — canonical layout:**
- Value types (`CalendarDate`, `CalendarDateTime`, `Time`, `ZonedDateTime`), 9 calendars, queries, operations, parsing: all re-exported from `$lib/util/dias`
- All date-related `Intl` (`DateFormatter`, cache, `resolveDateOrder`, `resolveHourCycle`, placeholders, defaults) lives in `$lib/util/dias/format.ts`
- `ext/dates` is a thin reactive wrapper for App-level preferences (dateOrder, hourCycle) that delegates formatting to `dias`
- `HourCycle` is canonically numeric — `12 | 24` — matching `Intl.DateTimeFormat`'s `hour12` resolved option. Never `'12h' | '24h'`
- Soma consumes via `$soma/external/dates`; terra via `$terra/external/dates`. Both re-export from `dias`. Components never import `dias` directly
**Number formatting:** use `createNumr` from the App layer, NEVER raw `Intl.NumberFormat`.
### soma (`src/uix/soma/`)
Headless component library. Follows terra's architecture exactly — same patterns, same conventions. Uses `$soma` alias.
**Key patterns (must match terra):**
- Child providers reference parent as `provider`, NEVER `root`
- **The root export is always `Provider`, never `Root`**. Consumer writes `<Dialog.Provider>`, `<Calendar.Provider>`. `createAttrs` still uses the part name `'root'` internally — that is the DOM attribute part, not the export
- **Data-attr naming is canonical**: `data-{component}` (root) and `data-{component}-{part}` (sub-parts), emitted by `createAttrs`. **Never** `data-soma-*`. QuerySelectors, CSS selectors, README tables, and inline strings must all match exactly what `createAttrs` writes on the DOM — the contract validator does NOT check this (only enum values)
- `onChange` callbacks go in `writableActive` setter in the `.svelte` wrapper, NOT in provider Opts
- Translations: idlangref constants (`#?components.xxx.yyy|Fallback`) defined in the component's `langs.ts`. Root-level entries live in `src/uix/soma/core/langs.ts` under `components.{kebab-name}`. Providers call `soma?.langs.ts(IDLANGREF)` — no `resolveSomaTranslationPath` wrapper
- Props defined ONCE in `types.ts` (consumer API). Provider Opts only has reactive wrappers + internal fields
- `Soma.get()` returns the Soma config from context. `Soma.require()` throws when missing
- Test pages use shared layout at `src/routes/test/soma/+layout.svelte` with real `createLangs()` + `createPresentation()` + `App.create()`
**Structure:** mirrors terra exactly:
- `[component]/components/` — Svelte wrappers
- `[component]/types.ts` — public props with JSDoc
- `[component]/*-provider.svelte.ts` — Provider classes
- `[component]/exports.ts` + `index.ts` — barrel exports
- `core/` — Soma class, translator adapter, prop-resolvers, formatters
- `provider/` — base Provider class, context utility
- `layers/` — shared infra (floating, presence, focus-scope, dismissal, scroll-lock)
**Import rules within soma:** use `$soma/` alias paths, never relative paths across component boundaries.
### Test Pages
Component demos live in `src/routes/test/`. Each terra/air component should have a test page demonstrating its states and variants.
Component demos live in `src/routes/test/`. Each terra/air/soma component should have a test page demonstrating its states and variants.
Soma test pages share a layout at `src/routes/test/soma/+layout.svelte` that provides:
## TerraConfig Integration
- Real ling instance with soma translations via `extendSomaTranslationModule`
- `createSomaTranslator` adapter connecting to ling
- Locale/dir switcher in a sticky bar
- `<Soma>` wrapper for all children
Test pages should NOT create their own `<Soma>` or translator — use the layout's.
## TerraConfig / Soma Integration
```svelte
<!-- Terra -->
<TerraConfig {translator} {dateTimeFormatter}>
<Dialog.Root />
<Dialog.Root />
</TerraConfig>
<!-- Soma -->
<Soma {translator} {presentation}>
<Dialog.Provider />
</Soma>
```
Priority chain for resolved props: explicit component prop → TerraConfig value → internal fallback. TerraConfig supports nesting (child overrides parent partially).
Priority chain for resolved props: explicit component prop → config context value → internal fallback. Both support nesting (child overrides parent partially).
## Key Conventions
- When creating terra components: follow the checklist in `src/uix/terra/README.md` §21
- When reading existing modules: start with `exports.ts` → `types.ts` → root `.svelte` → `*.svelte.ts` → child wrappers → test page
- When creating soma components: follow the 27-item checklist in `src/uix/soma/COMPONENT_GUIDE.md`. Walk every item explicitly before reporting done — this is mandatory, not optional. Compare features against ark-ui, bits-ui, radix-ui, react-aria and document the gap table in the component's README
- When reading existing modules: start with `exports.ts` → `types.ts` → root `.svelte` → `*-provider.svelte.ts` → child wrappers → test page → README
- `$bindable()` without fallback when parent might pass `undefined`; apply defaults via coalescing (see `src/uix/terra/README.md` §13.1)
- Components that use Portal: consumer must manage `z-index` explicitly (layers don't impose z-indices)
- Every soma component ships with its own `README.md` following the format of `dialog/README.md` — anatomy, parts, props, ARIA, data-attrs, keyboard, comparison table, example
## Critical Rules
- **NEVER modify terra source code.** Terra is the reference architecture. Soma copies its patterns.
- **NEVER delete files without explicit instruction** ("delete", "remove", "borra", "elimina"). If ambiguous, ASK first. Reading costs nothing, deleting can be irreversible.
- **NEVER create fake translators** in test pages. Use real ling instances with `createSomaTranslator` + `extendSomaTranslationModule`.
- **READ before acting.** When told to read a file, read it. Don't interpret "léete" as "eléte".
- **Verify before reporting done.** Run `npm run check`, test the UI in the browser.
- Soma audit: `src/uix/soma/AUDIT.md` — 4 pending issues (F3, F12, F13, F14). Read it before making architectural changes.
Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed.
**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment.
## 1. Think Before Coding
**Don't assume. Don't hide confusion. Surface tradeoffs.**
Before implementing:
- State your assumptions explicitly. If uncertain, ask.
- If multiple interpretations exist, present them - don't pick silently.
- If a simpler approach exists, say so. Push back when warranted.
- If something is unclear, stop. Name what's confusing. Ask.
## 2. Simplicity First
**Minimum code that solves the problem. Nothing speculative.**
- No features beyond what was asked.
- No abstractions for single-use code.
- No "flexibility" or "configurability" that wasn't requested.
- No error handling for impossible scenarios.
- If you write 200 lines and it could be 50, rewrite it.
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
## 3. Surgical Changes
**Touch only what you must. Clean up only your own mess.**
When editing existing code:
- Don't "improve" adjacent code, comments, or formatting.
- Don't refactor things that aren't broken.
- Match existing style, even if you'd do it differently.
- If you notice unrelated dead code, mention it - don't delete it.
When your changes create orphans:
- Remove imports/variables/functions that YOUR changes made unused.
- Don't remove pre-existing dead code unless asked.
The test: Every changed line should trace directly to the user's request.
## 4. Goal-Driven Execution
**Define success criteria. Loop until verified.**
Transform tasks into verifiable goals:
- "Add validation" → "Write tests for invalid inputs, then make them pass"
- "Fix the bug" → "Write a test that reproduces it, then make it pass"
- "Refactor X" → "Ensure tests pass before and after"
For multi-step tasks, state a brief plan:
```
1. [Step] → verify: [check]
2. [Step] → verify: [check]
3. [Step] → verify: [check]
```
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
---
**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.

@ -271,9 +271,9 @@ describe('locale-defaults', () => {
it('resolveHourCycle maps locales', async () => {
const { resolveHourCycle } = await import('../../dates/locale-defaults');
expect(resolveHourCycle('es')).toBe('24h');
expect(resolveHourCycle('en')).toBe('12h');
expect(resolveHourCycle('en-GB')).toBe('24h');
expect(resolveHourCycle('es')).toBe(24);
expect(resolveHourCycle('en')).toBe(12);
expect(resolveHourCycle('en-GB')).toBe(24);
});
it('resolveUnitSystem maps locales', async () => {

@ -30,7 +30,7 @@ function mockLangs(initialLocale = 'es'): AppLangs & { _fire: () => void } {
onSchemaChange: () => () => {},
_fire: () => {
for (const fn of listeners) fn(locale);
},
}
};
}
@ -213,7 +213,7 @@ describe('createMoney', () => {
const langs = mockLangs('es');
const rates = createRates({
initial: { base: 'USD', rates: { EUR: 0.92, GBP: 0.79 } },
initial: { base: 'USD', rates: { EUR: 0.92, GBP: 0.79 } }
});
const money = createMoney(langs, { currency: 'EUR', rates });
@ -222,7 +222,7 @@ describe('createMoney', () => {
// EUR → USD: rate = 1/0.92 ≈ 1.087
const result = await money.convert(100, 'USD');
expect(result).toBeDefined();
expect(result!).toBeCloseTo(108.70, 0);
expect(result!).toBeCloseTo(108.7, 0);
});
it('convertAs converts between any two currencies', async () => {
@ -231,7 +231,7 @@ describe('createMoney', () => {
const langs = mockLangs('es');
const rates = createRates({
initial: { base: 'USD', rates: { EUR: 0.92, GBP: 0.79 } },
initial: { base: 'USD', rates: { EUR: 0.92, GBP: 0.79 } }
});
const money = createMoney(langs, { rates });
@ -246,7 +246,7 @@ describe('createMoney', () => {
const langs = mockLangs('es');
const rates = createRates({
initial: { base: 'USD', rates: { EUR: 0.92 } },
initial: { base: 'USD', rates: { EUR: 0.92 } }
});
const money = createMoney(langs, { currency: 'EUR', rates });
@ -259,7 +259,7 @@ describe('createMoney', () => {
const langs = mockLangs('es');
const rates = createRates({
initial: { base: 'USD', rates: { EUR: 0.92 } },
initial: { base: 'USD', rates: { EUR: 0.92 } }
});
const money = createMoney(langs, { currency: 'EUR', rates });
@ -274,7 +274,7 @@ describe('createMoney', () => {
fetchCount++;
if (from === 'USD' && to === 'EUR') return { rate: 0.92, expiresAt: Infinity };
return undefined;
},
}
});
// No initial rates — fetches on demand
@ -301,7 +301,7 @@ describe('createMoney', () => {
it('initial rates available immediately', async () => {
const { createRates } = await import('../../money/rates');
const provider = createRates({
initial: { base: 'USD', rates: { EUR: 0.92 } },
initial: { base: 'USD', rates: { EUR: 0.92 } }
});
expect(await provider.getRate('USD', 'EUR')).toBeCloseTo(0.92);
@ -327,7 +327,7 @@ describe('createDates', () => {
const langs = mockLangs('es');
const dates = createDates(langs);
expect(dates.getHourCycle()).toBe('24h');
expect(dates.getHourCycle()).toBe(24);
});
it('formats dates', async () => {
@ -372,8 +372,8 @@ describe('createDates', () => {
const langs = mockLangs('es');
const dates = createDates(langs);
dates.setHourCycle('12h');
expect(dates.getHourCycle()).toBe('12h');
dates.setHourCycle(12);
expect(dates.getHourCycle()).toBe(12);
});
it('notifies on preference change', async () => {
@ -658,7 +658,7 @@ describe('createPresentation', () => {
theme: 'midnight',
mode: 'dark',
density: 'compact',
reducedSound: true,
reducedSound: true
});
expect(pres.getDir()).toBe('rtl');

@ -1,8 +1,16 @@
// ── Direction ────────────────────────────────────────────────────────────────
export type Direction = 'ltr' | 'rtl';
export type DateOrder = 'DMY' | 'MDY' | 'YMD';
export type HourCycle = '12h' | '24h';
// ── Dates ────────────────────────────────────────────────────────────────────
//
// `DateOrder` and `HourCycle` are canonical across the framework. Defined in
// `$lib/util/dias`, re-exported here as convenience for App-layer consumers.
// `HourCycle` uses the numeric form `12 | 24` — same as `Intl.DateTimeFormat`'s
// resolved `hour12` option (12 → true, 24 → false).
import type { DateOrder, HourCycle } from '$lib/util/dias';
export type { DateOrder, HourCycle };
// ── AppLangs ─────────────────────────────────────────────────────────────────
@ -78,7 +86,6 @@ export interface AppMoney {
onCurrencyChange(fn: (currency: string) => void): () => void;
}
export interface MoneyFormatOptions {
/** How to display the currency: symbol, narrowSymbol, code, name */
currencyDisplay?: 'symbol' | 'narrowSymbol' | 'code' | 'name';

@ -1,22 +1,6 @@
import type { AppLangs, AppDates, DateOrder, HourCycle } from '../app/types';
import { resolveDateOrder, resolveHourCycle } from './locale-defaults';
// ── Intl cache ──────────────────────────────────────────────────────────────
const formatCache = new Map<string, Intl.DateTimeFormat>();
function getCachedDateFormat(
locale: string,
opts?: Intl.DateTimeFormatOptions,
): Intl.DateTimeFormat {
const key = `${locale}:${JSON.stringify(opts ?? {})}`;
let fmt = formatCache.get(key);
if (!fmt) {
fmt = new Intl.DateTimeFormat(locale || undefined, opts);
formatCache.set(key, fmt);
}
return fmt;
}
import { getCachedDateFormat } from '$lib/util/dias';
// ── Factory ─────────────────────────────────────────────────────────────────
@ -31,6 +15,9 @@ export interface DatesOptions {
* Create a reactive dates service.
* Subscribes to locale changes from langs.
* Owns dateOrder and hourCycle preferences.
*
* Formatting itself is delegated to `$lib/util/dias`: this module adds
* nothing but reactive preference state on top of it.
*/
export function createDates(langs: AppLangs, opts: DatesOptions = {}): AppDates {
let _dateOrder = $state<DateOrder | null>(opts.dateOrder ?? null);
@ -51,7 +38,7 @@ export function createDates(langs: AppLangs, opts: DatesOptions = {}): AppDates
return {
format(value: Date, options?: Intl.DateTimeFormatOptions): string {
const mergedOpts: Intl.DateTimeFormatOptions = {
...(options ?? { dateStyle: 'medium' }),
...(options ?? { dateStyle: 'medium' })
};
return getCachedDateFormat(locale(), mergedOpts).format(value);
},
@ -61,8 +48,8 @@ export function createDates(langs: AppLangs, opts: DatesOptions = {}): AppDates
const mergedOpts: Intl.DateTimeFormatOptions = {
hour: 'numeric',
minute: 'numeric',
hourCycle: cycle === '12h' ? 'h12' : 'h23',
...options,
hourCycle: cycle === 12 ? 'h12' : 'h23',
...options
};
return getCachedDateFormat(locale(), mergedOpts).format(value);
},
@ -82,6 +69,6 @@ export function createDates(langs: AppLangs, opts: DatesOptions = {}): AppDates
onPreferenceChange(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
},
}
};
}

@ -1,18 +1,9 @@
import type { DateOrder, HourCycle } from '../app/types';
/**
* Thin re-export of locale-default helpers from `$lib/util/dias`. Exists for
* historical reasons — `ext/dates` used to convert between App-layer string
* hour-cycle (`'12h'|'24h'`) and the canonical numeric form. Since both
* layers now use the same numeric form, this file is a passthrough and can
* be removed; consumers should import directly from `$lib/util/dias`.
*/
const MDY_LOCALES = new Set(['en-US', 'en-PH', 'en-BZ', 'en-MH', 'en-FM']);
const YMD_BASES = new Set(['zh', 'ja', 'ko', 'hu', 'lt', 'fa']);
const H12_BASES = new Set(['en', 'ko', 'hi', 'bn']);
export function resolveDateOrder(locale: string): DateOrder {
if (MDY_LOCALES.has(locale)) return 'MDY';
const base = locale.split('-')[0].toLowerCase();
if (YMD_BASES.has(base)) return 'YMD';
return 'DMY';
}
export function resolveHourCycle(locale: string): HourCycle {
if (locale === 'en-GB') return '24h';
const base = locale.split('-')[0].toLowerCase();
return H12_BASES.has(base) ? '12h' : '24h';
}
export { resolveDateOrder, resolveHourCycle } from '$lib/util/dias';

@ -1,26 +1,18 @@
import type { Direction, DateOrder, HourCycle } from '../app/types';
import type { Direction } from '../app/types';
const RTL_LOCALES = new Set(['ar', 'he', 'fa', 'ur', 'ps', 'ku', 'yi', 'dv']);
/**
* Best-effort direction inferred from a BCP 47 locale.
*/
export function resolveDir(locale: string): Direction {
const base = locale.split('-')[0].toLowerCase();
return RTL_LOCALES.has(base) ? 'rtl' : 'ltr';
}
const MDY_LOCALES = new Set(['en-US', 'en-PH', 'en-BZ', 'en-MH', 'en-FM']);
export function resolveDateOrder(locale: string): DateOrder {
if (MDY_LOCALES.has(locale)) return 'MDY';
const base = locale.split('-')[0].toLowerCase();
// Chinese, Japanese, Korean, Hungarian use YMD
if (['zh', 'ja', 'ko', 'hu', 'lt'].includes(base)) return 'YMD';
return 'DMY';
}
export function resolveHourCycle(locale: string): HourCycle {
const base = locale.split('-')[0].toLowerCase();
// US, Philippines, and a few others use 12h by default
const h12 = new Set(['en', 'ko', 'hi', 'bn']);
if (locale === 'en-GB') return '24h';
return h12.has(base) ? '12h' : '24h';
}
/**
* Legacy re-exports. The date-related defaults live in `$lib/util/dias`;
* they were duplicated here in an earlier iteration and are kept as
* passthroughs to preserve any lingering imports.
*/
export { resolveDateOrder, resolveHourCycle } from '$lib/util/dias';

@ -0,0 +1,38 @@
# _vendor — third-party attribution
The files in this directory are derived work from Adobe's
[`@internationalized/date`](https://github.com/adobe/react-spectrum/tree/main/packages/%40internationalized/date)
package, distributed under the Apache License, Version 2.0.
Original copyright: Copyright 2020 Adobe. All rights reserved.
License: Apache License, Version 2.0
https://www.apache.org/licenses/LICENSE-2.0
## Scope
Every file under `_vendor/` carries the original Apache 2.0 header. This
directory is the library's internal implementation of calendar math, parsing
and formatting. Consumers of the `dias` public API **must not** import from
`_vendor/` directly — use the facade at `src/lib/util/dias/index.ts` instead.
## Changes from upstream
- Folder layout has been reorganised around the public `dias` facade.
- Type imports use relative paths suited to this project's structure.
- Any future modifications to the vendored source will be noted here with
a short description and the date of the change.
## What is NOT vendored
The `dias` facade files (everything outside `_vendor/`) are original work
authored for this framework: `types.ts`, `values.ts`, `calendars.ts`,
`queries.ts`, `operations.ts`, `parse.ts`, `format.ts`, `defaults.ts`,
`index.ts`. Those files are distributed under the framework's own license
and consist of re-exports and thin compositions over the vendored code.
## Removing or replacing the vendor
If the vendored implementation is ever replaced by a clean-room rewrite or
a different upstream library, delete this directory and repoint the facade
files. Consumers of the public `dias` API are unaffected.

@ -0,0 +1,59 @@
/*
* Copyright 2020 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
// Portions of the code in this file are based on code from ICU.
// Original licensing can be found in the NOTICE file in the root directory of this source tree.
import type { AnyCalendarDate, CalendarIdentifier } from '../core/types';
import { CalendarDate } from '../core/CalendarDate';
import { fromExtendedYear, getExtendedYear, GregorianCalendar } from './GregorianCalendar';
const BUDDHIST_ERA_START = -543;
/**
* The Buddhist calendar is the same as the Gregorian calendar, but counts years
* starting from the birth of Buddha in 543 BC (Gregorian). It supports only one
* era, identified as 'BE'.
*/
export class BuddhistCalendar extends GregorianCalendar {
identifier: CalendarIdentifier = 'buddhist';
fromJulianDay(jd: number): CalendarDate {
let gregorianDate = super.fromJulianDay(jd);
let year = getExtendedYear(gregorianDate.era, gregorianDate.year);
return new CalendarDate(
this,
year - BUDDHIST_ERA_START,
gregorianDate.month,
gregorianDate.day
);
}
toJulianDay(date: AnyCalendarDate): number {
return super.toJulianDay(toGregorian(date));
}
getEras(): string[] {
return ['BE'];
}
getDaysInMonth(date: AnyCalendarDate): number {
return super.getDaysInMonth(toGregorian(date));
}
balanceDate(): void {}
}
function toGregorian(date: AnyCalendarDate) {
let [era, year] = fromExtendedYear(date.year + BUDDHIST_ERA_START);
return new CalendarDate(era, year, date.month, date.day);
}

@ -0,0 +1,205 @@
/*
* Copyright 2020 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
// Portions of the code in this file are based on code from ICU.
// Original licensing can be found in the NOTICE file in the root directory of this source tree.
import type { AnyCalendarDate, Calendar, CalendarIdentifier } from '../core/types';
import { CalendarDate } from '../core/CalendarDate';
import type { Mutable } from '$lib/util/objs';
const ETHIOPIC_EPOCH = 1723856;
const COPTIC_EPOCH = 1824665;
// The delta between Amete Alem 1 and Amete Mihret 1
// AA 5501 = AM 1
const AMETE_MIHRET_DELTA = 5500;
function ceToJulianDay(epoch: number, year: number, month: number, day: number): number {
return (
epoch + // difference from Julian epoch to 1,1,1
365 * year + // number of days from years
Math.floor(year / 4) + // extra day of leap year
30 * (month - 1) + // number of days from months (1 based)
day -
1 // number of days for present month (1 based)
);
}
function julianDayToCE(epoch: number, jd: number) {
let year = Math.floor((4 * (jd - epoch)) / 1461);
let month = 1 + Math.floor((jd - ceToJulianDay(epoch, year, 1, 1)) / 30);
let day = jd + 1 - ceToJulianDay(epoch, year, month, 1);
return [year, month, day];
}
function getLeapDay(year: number) {
return Math.floor((year % 4) / 3);
}
function getDaysInMonth(year: number, month: number) {
// The Ethiopian and Coptic calendars have 13 months, 12 of 30 days each and
// an intercalary month at the end of the year of 5 or 6 days, depending whether
// the year is a leap year or not. The Leap Year follows the same rules as the
// Julian Calendar so that the extra month always has six days in the year before
// a Julian Leap Year.
if (month % 13 !== 0) {
// not intercalary month
return 30;
} else {
// intercalary month 5 days + possible leap day
return getLeapDay(year) + 5;
}
}
/**
* The Ethiopic calendar system is the official calendar used in Ethiopia.
* It includes 12 months of 30 days each, plus 5 or 6 intercalary days depending
* on whether it is a leap year. Two eras are supported: 'AA' and 'AM'.
*/
export class EthiopicCalendar implements Calendar {
identifier: CalendarIdentifier = 'ethiopic';
fromJulianDay(jd: number): CalendarDate {
let [year, month, day] = julianDayToCE(ETHIOPIC_EPOCH, jd);
let era = 'AM';
if (year <= 0) {
era = 'AA';
year += AMETE_MIHRET_DELTA;
}
return new CalendarDate(this, era, year, month, day);
}
toJulianDay(date: AnyCalendarDate): number {
let year = date.year;
if (date.era === 'AA') {
year -= AMETE_MIHRET_DELTA;
}
return ceToJulianDay(ETHIOPIC_EPOCH, year, date.month, date.day);
}
getDaysInMonth(date: AnyCalendarDate): number {
return getDaysInMonth(date.year, date.month);
}
getMonthsInYear(): number {
return 13;
}
getDaysInYear(date: AnyCalendarDate): number {
return 365 + getLeapDay(date.year);
}
getMaximumMonthsInYear(): number {
return 13;
}
getMaximumDaysInMonth(): number {
return 30;
}
getYearsInEra(date: AnyCalendarDate): number {
// 9999-12-31 gregorian is 9992-20-02 ethiopic.
// Round down to 9991 for the last full year.
// AA 9999-01-01 ethiopic is 4506-09-30 gregorian.
return date.era === 'AA' ? 9999 : 9991;
}
getEras(): string[] {
return ['AA', 'AM'];
}
}
/**
* The Ethiopic (Amete Alem) calendar is the same as the modern Ethiopic calendar,
* except years were measured from a different epoch. Only one era is supported: 'AA'.
*/
export class EthiopicAmeteAlemCalendar extends EthiopicCalendar {
identifier: CalendarIdentifier = 'ethioaa'; // also known as 'ethiopic-amete-alem' in ICU
fromJulianDay(jd: number): CalendarDate {
let [year, month, day] = julianDayToCE(ETHIOPIC_EPOCH, jd);
year += AMETE_MIHRET_DELTA;
return new CalendarDate(this, 'AA', year, month, day);
}
getEras(): string[] {
return ['AA'];
}
getYearsInEra(): number {
// 9999-13-04 ethioaa is the maximum date, which is equivalent to 4506-09-29 gregorian.
return 9999;
}
}
/**
* The Coptic calendar is similar to the Ethiopic calendar.
* It includes 12 months of 30 days each, plus 5 or 6 intercalary days depending
* on whether it is a leap year. Two eras are supported: 'BCE' and 'CE'.
*/
export class CopticCalendar extends EthiopicCalendar {
identifier: CalendarIdentifier = 'coptic';
fromJulianDay(jd: number): CalendarDate {
let [year, month, day] = julianDayToCE(COPTIC_EPOCH, jd);
let era = 'CE';
if (year <= 0) {
era = 'BCE';
year = 1 - year;
}
return new CalendarDate(this, era, year, month, day);
}
toJulianDay(date: AnyCalendarDate): number {
let year = date.year;
if (date.era === 'BCE') {
year = 1 - year;
}
return ceToJulianDay(COPTIC_EPOCH, year, date.month, date.day);
}
getDaysInMonth(date: AnyCalendarDate): number {
let year = date.year;
if (date.era === 'BCE') {
year = 1 - year;
}
return getDaysInMonth(year, date.month);
}
isInverseEra(date: AnyCalendarDate): boolean {
return date.era === 'BCE';
}
balanceDate(date: Mutable<AnyCalendarDate>): void {
if (date.year <= 0) {
date.era = date.era === 'BCE' ? 'CE' : 'BCE';
date.year = 1 - date.year;
}
}
getEras(): string[] {
return ['BCE', 'CE'];
}
getYearsInEra(date: AnyCalendarDate): number {
// 9999-12-30 gregorian is 9716-02-20 coptic.
// Round down to 9715 for the last full year.
// BCE 9999-01-01 coptic is BC 9716-06-15 gregorian.
return date.era === 'BCE' ? 9999 : 9715;
}
}

@ -0,0 +1,150 @@
/*
* Copyright 2020 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
// Portions of the code in this file are based on code from ICU.
// Original licensing can be found in the NOTICE file in the root directory of this source tree.
import type { AnyCalendarDate, Calendar, CalendarIdentifier } from '../core/types';
import { CalendarDate } from '../core/CalendarDate';
import { mod } from '$lib/util/numbers';
import type { Mutable } from '$lib/util/objs';
const EPOCH = 1721426; // 001/01/03 Julian C.E.
export function gregorianToJulianDay(
era: string,
year: number,
month: number,
day: number
): number {
year = getExtendedYear(era, year);
let y1 = year - 1;
let monthOffset = -2;
if (month <= 2) {
monthOffset = 0;
} else if (isLeapYear(year)) {
monthOffset = -1;
}
return (
EPOCH -
1 +
365 * y1 +
Math.floor(y1 / 4) -
Math.floor(y1 / 100) +
Math.floor(y1 / 400) +
Math.floor((367 * month - 362) / 12 + monthOffset + day)
);
}
export function isLeapYear(year: number): boolean {
return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
}
export function getExtendedYear(era: string, year: number): number {
return era === 'BC' ? 1 - year : year;
}
export function fromExtendedYear(year: number): [string, number] {
let era = 'AD';
if (year <= 0) {
era = 'BC';
year = 1 - year;
}
return [era, year];
}
const daysInMonth = {
standard: [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31],
leapyear: [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
};
/**
* The Gregorian calendar is the most commonly used calendar system in the world. It supports two eras: BC, and AD.
* Years always contain 12 months, and 365 or 366 days depending on whether it is a leap year.
*/
export class GregorianCalendar implements Calendar {
identifier: CalendarIdentifier = 'gregory';
fromJulianDay(jd: number): CalendarDate {
let jd0 = jd;
let depoch = jd0 - EPOCH;
let quadricent = Math.floor(depoch / 146097);
let dqc = mod(depoch, 146097);
let cent = Math.floor(dqc / 36524);
let dcent = mod(dqc, 36524);
let quad = Math.floor(dcent / 1461);
let dquad = mod(dcent, 1461);
let yindex = Math.floor(dquad / 365);
let extendedYear =
quadricent * 400 + cent * 100 + quad * 4 + yindex + (cent !== 4 && yindex !== 4 ? 1 : 0);
let [era, year] = fromExtendedYear(extendedYear);
let yearDay = jd0 - gregorianToJulianDay(era, year, 1, 1);
let leapAdj = 2;
if (jd0 < gregorianToJulianDay(era, year, 3, 1)) {
leapAdj = 0;
} else if (isLeapYear(year)) {
leapAdj = 1;
}
let month = Math.floor(((yearDay + leapAdj) * 12 + 373) / 367);
let day = jd0 - gregorianToJulianDay(era, year, month, 1) + 1;
return new CalendarDate(era, year, month, day);
}
toJulianDay(date: AnyCalendarDate): number {
return gregorianToJulianDay(date.era, date.year, date.month, date.day);
}
getDaysInMonth(date: AnyCalendarDate): number {
return daysInMonth[isLeapYear(date.year) ? 'leapyear' : 'standard'][date.month - 1];
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
getMonthsInYear(date: AnyCalendarDate): number {
return 12;
}
getDaysInYear(date: AnyCalendarDate): number {
return isLeapYear(date.year) ? 366 : 365;
}
getMaximumMonthsInYear(): number {
return 12;
}
getMaximumDaysInMonth(): number {
return 31;
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
getYearsInEra(date: AnyCalendarDate): number {
return 9999;
}
getEras(): string[] {
return ['BC', 'AD'];
}
isInverseEra(date: AnyCalendarDate): boolean {
return date.era === 'BC';
}
balanceDate(date: Mutable<AnyCalendarDate>): void {
if (date.year <= 0) {
date.era = date.era === 'BC' ? 'AD' : 'BC';
date.year = 1 - date.year;
}
}
}

@ -0,0 +1,215 @@
/*
* Copyright 2020 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
// Portions of the code in this file are based on code from ICU.
// Original licensing can be found in the NOTICE file in the root directory of this source tree.
import type { AnyCalendarDate, Calendar, CalendarIdentifier } from '../core/types';
import { CalendarDate } from '../core/CalendarDate';
import { mod } from '$lib/util/numbers';
import type { Mutable } from '$lib/util/objs';
const HEBREW_EPOCH = 347997;
// Hebrew date calculations are performed in terms of days, hours, and
// "parts" (or halakim), which are 1/1080 of an hour, or 3 1/3 seconds.
const HOUR_PARTS = 1080;
const DAY_PARTS = 24 * HOUR_PARTS;
// An approximate value for the length of a lunar month.
// It is used to calculate the approximate year and month of a given
// absolute date.
const MONTH_DAYS = 29;
const MONTH_FRACT = 12 * HOUR_PARTS + 793;
const MONTH_PARTS = MONTH_DAYS * DAY_PARTS + MONTH_FRACT;
function isLeapYear(year: number) {
return mod(year * 7 + 1, 19) < 7;
}
// Test for delay of start of new year and to avoid
// Sunday, Wednesday, and Friday as start of the new year.
function hebrewDelay1(year: number) {
let months = Math.floor((235 * year - 234) / 19);
let parts = 12084 + 13753 * months;
let day = months * 29 + Math.floor(parts / 25920);
if (mod(3 * (day + 1), 7) < 3) {
day += 1;
}
return day;
}
// Check for delay in start of new year due to length of adjacent years
function hebrewDelay2(year: number) {
let last = hebrewDelay1(year - 1);
let present = hebrewDelay1(year);
let next = hebrewDelay1(year + 1);
if (next - present === 356) {
return 2;
}
if (present - last === 382) {
return 1;
}
return 0;
}
function startOfYear(year: number) {
return hebrewDelay1(year) + hebrewDelay2(year);
}
function getDaysInYear(year: number) {
return startOfYear(year + 1) - startOfYear(year);
}
function getYearType(year: number) {
let yearLength = getDaysInYear(year);
if (yearLength > 380) {
yearLength -= 30; // Subtract length of leap month.
}
switch (yearLength) {
case 353:
return 0; // deficient
case 354:
return 1; // normal
case 355:
return 2; // complete
}
}
function getDaysInMonth(year: number, month: number): number {
// Normalize month numbers from 1 - 13, even on non-leap years
if (month >= 6 && !isLeapYear(year)) {
month++;
}
// First of all, dispose of fixed-length 29 day months
if (month === 4 || month === 7 || month === 9 || month === 11 || month === 13) {
return 29;
}
let yearType = getYearType(year);
// If it's Heshvan, days depend on length of year
if (month === 2) {
return yearType === 2 ? 30 : 29;
}
// Similarly, Kislev varies with the length of year
if (month === 3) {
return yearType === 0 ? 29 : 30;
}
// Adar I only exists in leap years
if (month === 6) {
return isLeapYear(year) ? 30 : 0;
}
return 30;
}
/**
* The Hebrew calendar is used in Israel and around the world by the Jewish faith.
* Years include either 12 or 13 months depending on whether it is a leap year.
* In leap years, an extra month is inserted at month 6.
*/
export class HebrewCalendar implements Calendar {
identifier: CalendarIdentifier = 'hebrew';
fromJulianDay(jd: number): CalendarDate {
let d = jd - HEBREW_EPOCH;
let m = (d * DAY_PARTS) / MONTH_PARTS; // Months (approx)
let year = Math.floor((19 * m + 234) / 235) + 1; // Years (approx)
let ys = startOfYear(year); // 1st day of year
let dayOfYear = Math.floor(d - ys);
// Because of the postponement rules, it's possible to guess wrong. Fix it.
while (dayOfYear < 1) {
year--;
ys = startOfYear(year);
dayOfYear = Math.floor(d - ys);
}
// Now figure out which month we're in, and the date within that month
let month = 1;
let monthStart = 0;
while (monthStart < dayOfYear) {
monthStart += getDaysInMonth(year, month);
month++;
}
month--;
monthStart -= getDaysInMonth(year, month);
let day = dayOfYear - monthStart;
return new CalendarDate(this, year, month, day);
}
toJulianDay(date: AnyCalendarDate): number {
let jd = startOfYear(date.year);
for (let month = 1; month < date.month; month++) {
jd += getDaysInMonth(date.year, month);
}
return jd + date.day + HEBREW_EPOCH;
}
getDaysInMonth(date: AnyCalendarDate): number {
return getDaysInMonth(date.year, date.month);
}
getMonthsInYear(date: AnyCalendarDate): number {
return isLeapYear(date.year) ? 13 : 12;
}
getDaysInYear(date: AnyCalendarDate): number {
return getDaysInYear(date.year);
}
getMaximumMonthsInYear(): number {
return 13;
}
getMaximumDaysInMonth(): number {
return 30;
}
getYearsInEra(): number {
// 6239 gregorian
return 9999;
}
getEras(): string[] {
return ['AM'];
}
balanceYearMonth(date: Mutable<AnyCalendarDate>, previousDate: AnyCalendarDate): void {
// Keep date in the same month when switching between leap years and non leap years
if (previousDate.year !== date.year) {
if (isLeapYear(previousDate.year) && !isLeapYear(date.year) && previousDate.month > 6) {
date.month--;
} else if (
!isLeapYear(previousDate.year) &&
isLeapYear(date.year) &&
previousDate.month > 6
) {
date.month++;
}
}
}
}

@ -0,0 +1,134 @@
/*
* Copyright 2020 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
// Portions of the code in this file are based on code from ICU.
// Original licensing can be found in the NOTICE file in the root directory of this source tree.
import type { AnyCalendarDate, CalendarIdentifier } from '../core/types';
import { CalendarDate } from '../core/CalendarDate';
import {
fromExtendedYear,
GregorianCalendar,
gregorianToJulianDay,
isLeapYear
} from './GregorianCalendar';
// Starts in 78 AD,
const INDIAN_ERA_START = 78;
// The Indian year starts 80 days later than the Gregorian year.
const INDIAN_YEAR_START = 80;
/**
* The Indian National Calendar is similar to the Gregorian calendar, but with
* years numbered since the Saka era in 78 AD (Gregorian). There are 12 months
* in each year, with either 30 or 31 days. Only one era identifier is supported: 'saka'.
*/
export class IndianCalendar extends GregorianCalendar {
identifier: CalendarIdentifier = 'indian';
fromJulianDay(jd: number): CalendarDate {
// Gregorian date for Julian day
let date = super.fromJulianDay(jd);
// Year in Saka era
let indianYear = date.year - INDIAN_ERA_START;
// Day number in Gregorian year (starting from 0)
let yDay = jd - gregorianToJulianDay(date.era, date.year, 1, 1);
let leapMonth: number;
if (yDay < INDIAN_YEAR_START) {
// Day is at the end of the preceding Saka year
indianYear--;
// Days in leapMonth this year, previous Gregorian year
leapMonth = isLeapYear(date.year - 1) ? 31 : 30;
yDay += leapMonth + 31 * 5 + 30 * 3 + 10;
} else {
// Days in leapMonth this year
leapMonth = isLeapYear(date.year) ? 31 : 30;
yDay -= INDIAN_YEAR_START;
}
let indianMonth: number;
let indianDay: number;
if (yDay < leapMonth) {
indianMonth = 1;
indianDay = yDay + 1;
} else {
let mDay = yDay - leapMonth;
if (mDay < 31 * 5) {
indianMonth = Math.floor(mDay / 31) + 2;
indianDay = (mDay % 31) + 1;
} else {
mDay -= 31 * 5;
indianMonth = Math.floor(mDay / 30) + 7;
indianDay = (mDay % 30) + 1;
}
}
return new CalendarDate(this, indianYear, indianMonth, indianDay);
}
toJulianDay(date: AnyCalendarDate): number {
let extendedYear = date.year + INDIAN_ERA_START;
let [era, year] = fromExtendedYear(extendedYear);
let leapMonth: number;
let jd: number;
if (isLeapYear(year)) {
leapMonth = 31;
jd = gregorianToJulianDay(era, year, 3, 21);
} else {
leapMonth = 30;
jd = gregorianToJulianDay(era, year, 3, 22);
}
if (date.month === 1) {
return jd + date.day - 1;
}
jd += leapMonth + Math.min(date.month - 2, 5) * 31;
if (date.month >= 8) {
jd += (date.month - 7) * 30;
}
jd += date.day - 1;
return jd;
}
getDaysInMonth(date: AnyCalendarDate): number {
if (date.month === 1 && isLeapYear(date.year + INDIAN_ERA_START)) {
return 31;
}
if (date.month >= 2 && date.month <= 6) {
return 31;
}
return 30;
}
getYearsInEra(): number {
// 9999-12-31 gregorian is 9920-10-10 indian.
// Round down to 9919 for the last full year.
return 9919;
}
getEras(): string[] {
return ['saka'];
}
balanceDate(): void {}
}

@ -0,0 +1,245 @@
/*
* Copyright 2020 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
// Portions of the code in this file are based on code from ICU.
// Original licensing can be found in the NOTICE file in the root directory of this source tree.
import type { AnyCalendarDate, Calendar, CalendarIdentifier } from '../core/types';
import { CalendarDate } from '../core/CalendarDate';
const CIVIL_EPOC = 1948440; // CE 622 July 16 Friday (Julian calendar) / CE 622 July 19 (Gregorian calendar)
const ASTRONOMICAL_EPOC = 1948439; // CE 622 July 15 Thursday (Julian calendar)
const UMALQURA_YEAR_START = 1300;
const UMALQURA_YEAR_END = 1600;
const UMALQURA_START_DAYS = 460322;
function islamicToJulianDay(epoch: number, year: number, month: number, day: number): number {
return (
day +
Math.ceil(29.5 * (month - 1)) +
(year - 1) * 354 +
Math.floor((3 + 11 * year) / 30) +
epoch -
1
);
}
function julianDayToIslamic(calendar: Calendar, epoch: number, jd: number) {
let year = Math.floor((30 * (jd - epoch) + 10646) / 10631);
let month = Math.min(
12,
Math.ceil((jd - (29 + islamicToJulianDay(epoch, year, 1, 1))) / 29.5) + 1
);
let day = jd - islamicToJulianDay(epoch, year, month, 1) + 1;
return new CalendarDate(calendar, year, month, day);
}
function isLeapYear(year: number): boolean {
return (14 + 11 * year) % 30 < 11;
}
/**
* The Islamic calendar, also known as the "Hijri" calendar, is used throughout much of the Arab world.
* The civil variant uses simple arithmetic rules rather than astronomical calculations to approximate
* the traditional calendar, which is based on sighting of the crescent moon. It uses Friday, July 16 622 CE (Julian) as the epoch.
* Each year has 12 months, with either 354 or 355 days depending on whether it is a leap year.
* Learn more about the available Islamic calendars [here](https://cldr.unicode.org/development/development-process/design-proposals/islamic-calendar-types).
*/
export class IslamicCivilCalendar implements Calendar {
identifier: CalendarIdentifier = 'islamic-civil';
fromJulianDay(jd: number): CalendarDate {
return julianDayToIslamic(this, CIVIL_EPOC, jd);
}
toJulianDay(date: AnyCalendarDate): number {
return islamicToJulianDay(CIVIL_EPOC, date.year, date.month, date.day);
}
getDaysInMonth(date: AnyCalendarDate): number {
let length = 29 + (date.month % 2);
if (date.month === 12 && isLeapYear(date.year)) {
length++;
}
return length;
}
getMonthsInYear(): number {
return 12;
}
getDaysInYear(date: AnyCalendarDate): number {
return isLeapYear(date.year) ? 355 : 354;
}
getMaximumMonthsInYear(): number {
return 12;
}
getMaximumDaysInMonth(): number {
return 30;
}
getYearsInEra(): number {
// 9999 gregorian
return 9665;
}
getEras(): string[] {
return ['AH'];
}
}
/**
* The Islamic calendar, also known as the "Hijri" calendar, is used throughout much of the Arab world.
* The tabular variant uses simple arithmetic rules rather than astronomical calculations to approximate
* the traditional calendar, which is based on sighting of the crescent moon. It uses Thursday, July 15 622 CE (Julian) as the epoch.
* Each year has 12 months, with either 354 or 355 days depending on whether it is a leap year.
* Learn more about the available Islamic calendars [here](https://cldr.unicode.org/development/development-process/design-proposals/islamic-calendar-types).
*/
export class IslamicTabularCalendar extends IslamicCivilCalendar {
identifier: CalendarIdentifier = 'islamic-tbla';
fromJulianDay(jd: number): CalendarDate {
return julianDayToIslamic(this, ASTRONOMICAL_EPOC, jd);
}
toJulianDay(date: AnyCalendarDate): number {
return islamicToJulianDay(ASTRONOMICAL_EPOC, date.year, date.month, date.day);
}
}
// Generated by scripts/generate-umalqura.js
const UMALQURA_DATA =
'qgpUDckO1AbqBmwDrQpVBakGkgepC9QF2gpcBS0NlQZKB1QLagutBa4ETwoXBYsGpQbVCtYCWwmdBE0KJg2VDawFtgm6AlsKKwWVCsoG6Qr0AnYJtgJWCcoKpAvSC9kF3AJtCU0FpQpSC6ULtAW2CVcFlwJLBaMGUgdlC2oFqworBZUMSg2lDcoF1gpXCasESwmlClILagt1BXYCtwhbBFUFqQW0BdoJ3QRuAjYJqgpUDbIN1QXaAlsJqwRVCkkLZAtxC7QFtQpVCiUNkg7JDtQG6QprCasEkwpJDaQNsg25CroEWworBZUKKgtVC1wFvQQ9Ah0JlQpKC1oLbQW2AjsJmwRVBqkGVAdqC2wFrQpVBSkLkgupC9QF2gpaBasKlQVJB2QHqgu1BbYCVgpNDiULUgtqC60FrgIvCZcESwalBqwG1gpdBZ0ETQoWDZUNqgW1BdoCWwmtBJUFygbkBuoK9QS2AlYJqgpUC9IL2QXqAm0JrQSVCkoLpQuyBbUJ1gSXCkcFkwZJB1ULagVrCisFiwpGDaMNygXWCtsEawJLCaUKUgtpC3UFdgG3CFsCKwVlBbQF2gntBG0BtgimClINqQ3UBdoKWwmrBFMGKQdiB6kLsgW1ClUFJQuSDckO0gbpCmsFqwRVCikNVA2qDbUJugQ7CpsETQqqCtUK2gJdCV4ELgqaDFUNsga5BroEXQotBZUKUguoC7QLuQXaAloJSgukDdEO6AZqC20FNQWVBkoNqA3UDdoGWwWdAisGFQtKC5ULqgWuCi4JjwwnBZUGqgbWCl0FnQI=';
let UMALQURA_MONTHLENGTH: Uint16Array;
let UMALQURA_YEAR_START_TABLE: Uint32Array;
function umalquraYearStart(year: number): number {
return UMALQURA_START_DAYS + UMALQURA_YEAR_START_TABLE[year - UMALQURA_YEAR_START];
}
function umalquraMonthLength(year: number, month: number): number {
let idx = year - UMALQURA_YEAR_START;
let mask = 0x01 << (11 - (month - 1));
if ((UMALQURA_MONTHLENGTH[idx] & mask) === 0) {
return 29;
} else {
return 30;
}
}
function umalquraMonthStart(year: number, month: number): number {
let day = umalquraYearStart(year);
for (let i = 1; i < month; i++) {
day += umalquraMonthLength(year, i);
}
return day;
}
function umalquraYearLength(year: number): number {
return (
UMALQURA_YEAR_START_TABLE[year + 1 - UMALQURA_YEAR_START] -
UMALQURA_YEAR_START_TABLE[year - UMALQURA_YEAR_START]
);
}
/**
* The Islamic calendar, also known as the "Hijri" calendar, is used throughout much of the Arab world.
* The Umalqura variant is primarily used in Saudi Arabia. It is a lunar calendar, based on astronomical
* calculations that predict the sighting of a crescent moon. Month and year lengths vary between years
* depending on these calculations.
* Learn more about the available Islamic calendars [here](https://cldr.unicode.org/development/development-process/design-proposals/islamic-calendar-types).
*/
export class IslamicUmalquraCalendar extends IslamicCivilCalendar {
identifier: CalendarIdentifier = 'islamic-umalqura';
constructor() {
super();
if (!UMALQURA_MONTHLENGTH) {
UMALQURA_MONTHLENGTH = new Uint16Array(
Uint8Array.from(atob(UMALQURA_DATA), (c) => c.charCodeAt(0)).buffer
);
}
if (!UMALQURA_YEAR_START_TABLE) {
UMALQURA_YEAR_START_TABLE = new Uint32Array(UMALQURA_YEAR_END - UMALQURA_YEAR_START + 1);
let yearStart = 0;
for (let year = UMALQURA_YEAR_START; year <= UMALQURA_YEAR_END; year++) {
UMALQURA_YEAR_START_TABLE[year - UMALQURA_YEAR_START] = yearStart;
for (let i = 1; i <= 12; i++) {
yearStart += umalquraMonthLength(year, i);
}
}
}
}
fromJulianDay(jd: number): CalendarDate {
let days = jd - CIVIL_EPOC;
let startDays = umalquraYearStart(UMALQURA_YEAR_START);
let endDays = umalquraYearStart(UMALQURA_YEAR_END);
if (days < startDays || days > endDays) {
return super.fromJulianDay(jd);
} else {
let y = UMALQURA_YEAR_START - 1;
let m = 1;
let d = 1;
while (d > 0) {
y++;
d = days - umalquraYearStart(y) + 1;
let yearLength = umalquraYearLength(y);
if (d === yearLength) {
m = 12;
break;
} else if (d < yearLength) {
let monthLength = umalquraMonthLength(y, m);
m = 1;
while (d > monthLength) {
d -= monthLength;
m++;
monthLength = umalquraMonthLength(y, m);
}
break;
}
}
return new CalendarDate(this, y, m, days - umalquraMonthStart(y, m) + 1);
}
}
toJulianDay(date: AnyCalendarDate): number {
if (date.year < UMALQURA_YEAR_START || date.year > UMALQURA_YEAR_END) {
return super.toJulianDay(date);
}
return CIVIL_EPOC + umalquraMonthStart(date.year, date.month) + (date.day - 1);
}
getDaysInMonth(date: AnyCalendarDate): number {
if (date.year < UMALQURA_YEAR_START || date.year > UMALQURA_YEAR_END) {
return super.getDaysInMonth(date);
}
return umalquraMonthLength(date.year, date.month);
}
getDaysInYear(date: AnyCalendarDate): number {
if (date.year < UMALQURA_YEAR_START || date.year > UMALQURA_YEAR_END) {
return super.getDaysInYear(date);
}
return umalquraYearLength(date.year);
}
}

@ -0,0 +1,184 @@
/*
* Copyright 2020 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
// Portions of the code in this file are based on code from the TC39 Temporal proposal.
// Original licensing can be found in the NOTICE file in the root directory of this source tree.
import type { AnyCalendarDate, CalendarIdentifier } from '../core/types';
import { CalendarDate } from '../core/CalendarDate';
import { GregorianCalendar } from './GregorianCalendar';
import type { Mutable } from '$lib/util/objs';
const ERA_START_DATES = [
[1868, 9, 8],
[1912, 7, 30],
[1926, 12, 25],
[1989, 1, 8],
[2019, 5, 1]
];
const ERA_END_DATES = [
[1912, 7, 29],
[1926, 12, 24],
[1989, 1, 7],
[2019, 4, 30]
];
const ERA_ADDENDS = [1867, 1911, 1925, 1988, 2018];
const ERA_NAMES = ['meiji', 'taisho', 'showa', 'heisei', 'reiwa'];
function findEraFromGregorianDate(date: AnyCalendarDate) {
const idx = ERA_START_DATES.findIndex(([year, month, day]) => {
if (date.year < year) {
return true;
}
if (date.year === year && date.month < month) {
return true;
}
if (date.year === year && date.month === month && date.day < day) {
return true;
}
return false;
});
if (idx === -1) {
return ERA_START_DATES.length - 1;
}
if (idx === 0) {
return 0;
}
return idx - 1;
}
function toGregorian(date: AnyCalendarDate) {
let eraAddend = ERA_ADDENDS[ERA_NAMES.indexOf(date.era)];
if (!eraAddend) {
throw new Error('Unknown era: ' + date.era);
}
return new CalendarDate(date.year + eraAddend, date.month, date.day);
}
/**
* The Japanese calendar is based on the Gregorian calendar, but with eras for the reign of each Japanese emperor.
* Whenever a new emperor ascends to the throne, a new era begins and the year starts again from 1.
* Note that eras before 1868 (Gregorian) are not currently supported by this implementation.
*/
export class JapaneseCalendar extends GregorianCalendar {
identifier: CalendarIdentifier = 'japanese';
fromJulianDay(jd: number): CalendarDate {
let date = super.fromJulianDay(jd);
let era = findEraFromGregorianDate(date);
return new CalendarDate(
this,
ERA_NAMES[era],
date.year - ERA_ADDENDS[era],
date.month,
date.day
);
}
toJulianDay(date: AnyCalendarDate): number {
return super.toJulianDay(toGregorian(date));
}
balanceDate(date: Mutable<AnyCalendarDate>): void {
let gregorianDate = toGregorian(date);
let era = findEraFromGregorianDate(gregorianDate);
if (ERA_NAMES[era] !== date.era) {
date.era = ERA_NAMES[era];
date.year = gregorianDate.year - ERA_ADDENDS[era];
}
// Constrain in case we went before the first supported era.
this.constrainDate(date);
}
constrainDate(date: Mutable<AnyCalendarDate>): void {
let idx = ERA_NAMES.indexOf(date.era);
let end = ERA_END_DATES[idx];
if (end != null) {
let [endYear, endMonth, endDay] = end;
// Constrain the year to the maximum possible value in the era.
// Then constrain the month and day fields within that.
let maxYear = endYear - ERA_ADDENDS[idx];
date.year = Math.max(1, Math.min(maxYear, date.year));
if (date.year === maxYear) {
date.month = Math.min(endMonth, date.month);
if (date.month === endMonth) {
date.day = Math.min(endDay, date.day);
}
}
}
if (date.year === 1 && idx >= 0) {
let [, startMonth, startDay] = ERA_START_DATES[idx];
date.month = Math.max(startMonth, date.month);
if (date.month === startMonth) {
date.day = Math.max(startDay, date.day);
}
}
}
getEras(): string[] {
return ERA_NAMES;
}
getYearsInEra(date: AnyCalendarDate): number {
// Get the number of years in the era, taking into account the date's month and day fields.
let era = ERA_NAMES.indexOf(date.era);
let cur = ERA_START_DATES[era];
let next = ERA_START_DATES[era + 1];
if (next == null) {
// 9999 gregorian is the maximum year allowed.
return 9999 - cur[0] + 1;
}
let years = next[0] - cur[0];
if (date.month < next[1] || (date.month === next[1] && date.day < next[2])) {
years++;
}
return years;
}
getDaysInMonth(date: AnyCalendarDate): number {
return super.getDaysInMonth(toGregorian(date));
}
getMinimumMonthInYear(date: AnyCalendarDate): number {
let start = getMinimums(date);
return start ? start[1] : 1;
}
getMinimumDayInMonth(date: AnyCalendarDate): number {
let start = getMinimums(date);
return start && date.month === start[1] ? start[2] : 1;
}
}
function getMinimums(date: AnyCalendarDate) {
if (date.year === 1) {
let idx = ERA_NAMES.indexOf(date.era);
return ERA_START_DATES[idx];
}
}

@ -0,0 +1,98 @@
/*
* Copyright 2020 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
// Portions of the code in this file are based on code from ICU.
// Original licensing can be found in the NOTICE file in the root directory of this source tree.
import type { AnyCalendarDate, Calendar, CalendarIdentifier } from '../core/types';
import { CalendarDate } from '../core/CalendarDate';
import { mod } from '$lib/util/numbers';
const PERSIAN_EPOCH = 1948320;
// Number of days from the start of the year to the start of each month.
const MONTH_START = [
0, // Farvardin
31, // Ordibehesht
62, // Khordad
93, // Tir
124, // Mordad
155, // Shahrivar
186, // Mehr
216, // Aban
246, // Azar
276, // Dey
306, // Bahman
336 // Esfand
];
/**
* The Persian calendar is the main calendar used in Iran and Afghanistan. It has 12 months
* in each year, the first 6 of which have 31 days, and the next 5 have 30 days. The 12th month
* has either 29 or 30 days depending on whether it is a leap year. The Persian year starts
* around the March equinox.
*/
export class PersianCalendar implements Calendar {
identifier: CalendarIdentifier = 'persian';
fromJulianDay(jd: number): CalendarDate {
let daysSinceEpoch = jd - PERSIAN_EPOCH;
let year = 1 + Math.floor((33 * daysSinceEpoch + 3) / 12053);
let farvardin1 = 365 * (year - 1) + Math.floor((8 * year + 21) / 33);
let dayOfYear = daysSinceEpoch - farvardin1;
let month = dayOfYear < 216 ? Math.floor(dayOfYear / 31) : Math.floor((dayOfYear - 6) / 30);
let day = dayOfYear - MONTH_START[month] + 1;
return new CalendarDate(this, year, month + 1, day);
}
toJulianDay(date: AnyCalendarDate): number {
let jd = PERSIAN_EPOCH - 1 + 365 * (date.year - 1) + Math.floor((8 * date.year + 21) / 33);
jd += MONTH_START[date.month - 1];
jd += date.day;
return jd;
}
getMonthsInYear(): number {
return 12;
}
getDaysInMonth(date: AnyCalendarDate): number {
if (date.month <= 6) {
return 31;
}
if (date.month <= 11) {
return 30;
}
let isLeapYear = mod(25 * date.year + 11, 33) < 8;
return isLeapYear ? 30 : 29;
}
getMaximumMonthsInYear(): number {
return 12;
}
getMaximumDaysInMonth(): number {
return 31;
}
getEras(): string[] {
return ['AP'];
}
getYearsInEra(): number {
// 9378-10-10 persian is 9999-12-31 gregorian.
// Round down to 9377 to set the maximum full year.
return 9377;
}
}

@ -0,0 +1,81 @@
/*
* Copyright 2020 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
// Portions of the code in this file are based on code from ICU.
// Original licensing can be found in the NOTICE file in the root directory of this source tree.
import type { AnyCalendarDate, CalendarIdentifier } from '../core/types';
import { CalendarDate } from '../core/CalendarDate';
import { fromExtendedYear, getExtendedYear, GregorianCalendar } from './GregorianCalendar';
import type { Mutable } from '$lib/util/objs';
const TAIWAN_ERA_START = 1911;
function gregorianYear(date: AnyCalendarDate) {
return date.era === 'minguo' ? date.year + TAIWAN_ERA_START : 1 - date.year + TAIWAN_ERA_START;
}
function gregorianToTaiwan(year: number): [string, number] {
let y = year - TAIWAN_ERA_START;
if (y > 0) {
return ['minguo', y];
} else {
return ['before_minguo', 1 - y];
}
}
/**
* The Taiwanese calendar is the same as the Gregorian calendar, but years
* are numbered starting from 1912 (Gregorian). Two eras are supported:
* 'before_minguo' and 'minguo'.
*/
export class TaiwanCalendar extends GregorianCalendar {
identifier: CalendarIdentifier = 'roc'; // Republic of China
fromJulianDay(jd: number): CalendarDate {
let date = super.fromJulianDay(jd);
let extendedYear = getExtendedYear(date.era, date.year);
let [era, year] = gregorianToTaiwan(extendedYear);
return new CalendarDate(this, era, year, date.month, date.day);
}
toJulianDay(date: AnyCalendarDate): number {
return super.toJulianDay(toGregorian(date));
}
getEras(): string[] {
return ['before_minguo', 'minguo'];
}
balanceDate(date: Mutable<AnyCalendarDate>): void {
let [era, year] = gregorianToTaiwan(gregorianYear(date));
date.era = era;
date.year = year;
}
isInverseEra(date: AnyCalendarDate): boolean {
return date.era === 'before_minguo';
}
getDaysInMonth(date: AnyCalendarDate): number {
return super.getDaysInMonth(toGregorian(date));
}
getYearsInEra(date: AnyCalendarDate): number {
return date.era === 'before_minguo' ? 9999 : 9999 - TAIWAN_ERA_START;
}
}
function toGregorian(date: AnyCalendarDate) {
let [era, year] = fromExtendedYear(gregorianYear(date));
return new CalendarDate(era, year, date.month, date.day);
}

@ -0,0 +1,559 @@
/*
* Copyright 2020 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
import {
add,
addTime,
addZoned,
constrain,
constrainTime,
cycleDate,
cycleTime,
cycleZoned,
set,
setTime,
setZoned,
subtract,
subtractTime,
subtractZoned
} from '../operations/manipulation';
import type {
AnyCalendarDate,
AnyTime,
Calendar,
CycleOptions,
CycleTimeOptions,
DateDuration,
DateField,
DateFields,
DateTimeDuration,
Disambiguation,
TimeDuration,
TimeField,
TimeFields
} from './types';
import { compareDate, compareTime } from '../operations/queries';
import {
dateTimeToString,
dateToString,
timeToString,
zonedDateTimeToString
} from '../operations/string';
import { GregorianCalendar } from '../calendars/GregorianCalendar';
import { toCalendarDateTime, toDate, toZoned, zonedToDate } from '../operations/conversion';
function shiftArgs(args: any[]) {
let calendar: Calendar = typeof args[0] === 'object' ? args.shift() : new GregorianCalendar();
let era: string;
if (typeof args[0] === 'string') {
era = args.shift();
} else {
let eras = calendar.getEras();
era = eras[eras.length - 1];
}
let year = args.shift();
let month = args.shift();
let day = args.shift();
return [calendar, era, year, month, day];
}
/** A CalendarDate represents a date without any time component in a specific calendar system. */
export class CalendarDate {
// This prevents TypeScript from allowing other types with the same fields to match.
// i.e. a ZonedDateTime should not be be passable to a parameter that expects CalendarDate.
// If that behavior is desired, use the AnyCalendarDate interface instead.
// @ts-ignore
#type;
/** The calendar system associated with this date, e.g. Gregorian. */
public readonly calendar: Calendar;
/** The calendar era for this date, e.g. "BC" or "AD". */
public readonly era: string;
/** The year of this date within the era. */
public readonly year: number;
/**
* The month number within the year. Note that some calendar systems such as Hebrew
* may have a variable number of months per year. Therefore, month numbers may not
* always correspond to the same month names in different years.
*/
public readonly month: number;
/** The day number within the month. */
public readonly day: number;
constructor(year: number, month: number, day: number);
constructor(era: string, year: number, month: number, day: number);
constructor(calendar: Calendar, year: number, month: number, day: number);
constructor(calendar: Calendar, era: string, year: number, month: number, day: number);
constructor(...args: any[]) {
let [calendar, era, year, month, day] = shiftArgs(args);
this.calendar = calendar;
this.era = era;
this.year = year;
this.month = month;
this.day = day;
constrain(this);
}
/** Returns a copy of this date. */
copy(): CalendarDate {
if (this.era) {
return new CalendarDate(this.calendar, this.era, this.year, this.month, this.day);
} else {
return new CalendarDate(this.calendar, this.year, this.month, this.day);
}
}
/** Returns a new `CalendarDate` with the given duration added to it. */
add(duration: DateDuration): CalendarDate {
return add(this, duration);
}
/** Returns a new `CalendarDate` with the given duration subtracted from it. */
subtract(duration: DateDuration): CalendarDate {
return subtract(this, duration);
}
/** Returns a new `CalendarDate` with the given fields set to the provided values. Other fields will be constrained accordingly. */
set(fields: DateFields): CalendarDate {
return set(this, fields);
}
/**
* Returns a new `CalendarDate` with the given field adjusted by a specified amount.
* When the resulting value reaches the limits of the field, it wraps around.
*/
cycle(field: DateField, amount: number, options?: CycleOptions): CalendarDate {
return cycleDate(this, field, amount, options);
}
/** Converts the date to a native JavaScript Date object, with the time set to midnight in the given time zone. */
toDate(timeZone: string): Date {
return toDate(this, timeZone);
}
/** Converts the date to an ISO 8601 formatted string. */
toString(): string {
return dateToString(this);
}
/** Compares this date with another. A negative result indicates that this date is before the given one, and a positive date indicates that it is after. */
compare(b: AnyCalendarDate): number {
return compareDate(this, b);
}
}
/** A Time represents a clock time without any date component. */
export class Time {
// This prevents TypeScript from allowing other types with the same fields to match.
// @ts-ignore
#type;
/** The hour, numbered from 0 to 23. */
public readonly hour: number;
/** The minute in the hour. */
public readonly minute: number;
/** The second in the minute. */
public readonly second: number;
/** The millisecond in the second. */
public readonly millisecond: number;
constructor(hour: number = 0, minute: number = 0, second: number = 0, millisecond: number = 0) {
this.hour = hour;
this.minute = minute;
this.second = second;
this.millisecond = millisecond;
constrainTime(this);
}
/** Returns a copy of this time. */
copy(): Time {
return new Time(this.hour, this.minute, this.second, this.millisecond);
}
/** Returns a new `Time` with the given duration added to it. */
add(duration: TimeDuration): Time {
return addTime(this, duration);
}
/** Returns a new `Time` with the given duration subtracted from it. */
subtract(duration: TimeDuration): Time {
return subtractTime(this, duration);
}
/** Returns a new `Time` with the given fields set to the provided values. Other fields will be constrained accordingly. */
set(fields: TimeFields): Time {
return setTime(this, fields);
}
/**
* Returns a new `Time` with the given field adjusted by a specified amount.
* When the resulting value reaches the limits of the field, it wraps around.
*/
cycle(field: TimeField, amount: number, options?: CycleTimeOptions): Time {
return cycleTime(this, field, amount, options);
}
/** Converts the time to an ISO 8601 formatted string. */
toString(): string {
return timeToString(this);
}
/** Compares this time with another. A negative result indicates that this time is before the given one, and a positive time indicates that it is after. */
compare(b: AnyTime): number {
return compareTime(this, b);
}
}
/** A CalendarDateTime represents a date and time without a time zone, in a specific calendar system. */
export class CalendarDateTime {
// This prevents TypeScript from allowing other types with the same fields to match.
// @ts-ignore
#type;
/** The calendar system associated with this date, e.g. Gregorian. */
public readonly calendar: Calendar;
/** The calendar era for this date, e.g. "BC" or "AD". */
public readonly era: string;
/** The year of this date within the era. */
public readonly year: number;
/**
* The month number within the year. Note that some calendar systems such as Hebrew
* may have a variable number of months per year. Therefore, month numbers may not
* always correspond to the same month names in different years.
*/
public readonly month: number;
/** The day number within the month. */
public readonly day: number;
/** The hour in the day, numbered from 0 to 23. */
public readonly hour: number;
/** The minute in the hour. */
public readonly minute: number;
/** The second in the minute. */
public readonly second: number;
/** The millisecond in the second. */
public readonly millisecond: number;
constructor(
year: number,
month: number,
day: number,
hour?: number,
minute?: number,
second?: number,
millisecond?: number
);
constructor(
era: string,
year: number,
month: number,
day: number,
hour?: number,
minute?: number,
second?: number,
millisecond?: number
);
constructor(
calendar: Calendar,
year: number,
month: number,
day: number,
hour?: number,
minute?: number,
second?: number,
millisecond?: number
);
constructor(
calendar: Calendar,
era: string,
year: number,
month: number,
day: number,
hour?: number,
minute?: number,
second?: number,
millisecond?: number
);
constructor(...args: any[]) {
let [calendar, era, year, month, day] = shiftArgs(args);
this.calendar = calendar;
this.era = era;
this.year = year;
this.month = month;
this.day = day;
this.hour = args.shift() || 0;
this.minute = args.shift() || 0;
this.second = args.shift() || 0;
this.millisecond = args.shift() || 0;
constrain(this);
}
/** Returns a copy of this date. */
copy(): CalendarDateTime {
if (this.era) {
return new CalendarDateTime(
this.calendar,
this.era,
this.year,
this.month,
this.day,
this.hour,
this.minute,
this.second,
this.millisecond
);
} else {
return new CalendarDateTime(
this.calendar,
this.year,
this.month,
this.day,
this.hour,
this.minute,
this.second,
this.millisecond
);
}
}
/** Returns a new `CalendarDateTime` with the given duration added to it. */
add(duration: DateTimeDuration): CalendarDateTime {
return add(this, duration);
}
/** Returns a new `CalendarDateTime` with the given duration subtracted from it. */
subtract(duration: DateTimeDuration): CalendarDateTime {
return subtract(this, duration);
}
/** Returns a new `CalendarDateTime` with the given fields set to the provided values. Other fields will be constrained accordingly. */
set(fields: DateFields & TimeFields): CalendarDateTime {
return set(setTime(this, fields), fields);
}
/**
* Returns a new `CalendarDateTime` with the given field adjusted by a specified amount.
* When the resulting value reaches the limits of the field, it wraps around.
*/
cycle(
field: DateField | TimeField,
amount: number,
options?: CycleTimeOptions
): CalendarDateTime {
switch (field) {
case 'era':
case 'year':
case 'month':
case 'day':
return cycleDate(this, field, amount, options);
default:
return cycleTime(this, field, amount, options);
}
}
/** Converts the date to a native JavaScript Date object in the given time zone. */
toDate(timeZone: string, disambiguation?: Disambiguation): Date {
return toDate(this, timeZone, disambiguation);
}
/** Converts the date to an ISO 8601 formatted string. */
toString(): string {
return dateTimeToString(this);
}
/** Compares this date with another. A negative result indicates that this date is before the given one, and a positive date indicates that it is after. */
compare(b: CalendarDate | CalendarDateTime | ZonedDateTime): number {
let res = compareDate(this, b);
if (res === 0) {
return compareTime(this, toCalendarDateTime(b));
}
return res;
}
}
/** A ZonedDateTime represents a date and time in a specific time zone and calendar system. */
export class ZonedDateTime {
// This prevents TypeScript from allowing other types with the same fields to match.
// @ts-ignore
#type;
/** The calendar system associated with this date, e.g. Gregorian. */
public readonly calendar: Calendar;
/** The calendar era for this date, e.g. "BC" or "AD". */
public readonly era: string;
/** The year of this date within the era. */
public readonly year: number;
/**
* The month number within the year. Note that some calendar systems such as Hebrew
* may have a variable number of months per year. Therefore, month numbers may not
* always correspond to the same month names in different years.
*/
public readonly month: number;
/** The day number within the month. */
public readonly day: number;
/** The hour in the day, numbered from 0 to 23. */
public readonly hour: number;
/** The minute in the hour. */
public readonly minute: number;
/** The second in the minute. */
public readonly second: number;
/** The millisecond in the second. */
public readonly millisecond: number;
/** The IANA time zone identifier that this date and time is represented in. */
public readonly timeZone: string;
/** The UTC offset for this time, in milliseconds. */
public readonly offset: number;
constructor(
year: number,
month: number,
day: number,
timeZone: string,
offset: number,
hour?: number,
minute?: number,
second?: number,
millisecond?: number
);
constructor(
era: string,
year: number,
month: number,
day: number,
timeZone: string,
offset: number,
hour?: number,
minute?: number,
second?: number,
millisecond?: number
);
constructor(
calendar: Calendar,
year: number,
month: number,
day: number,
timeZone: string,
offset: number,
hour?: number,
minute?: number,
second?: number,
millisecond?: number
);
constructor(
calendar: Calendar,
era: string,
year: number,
month: number,
day: number,
timeZone: string,
offset: number,
hour?: number,
minute?: number,
second?: number,
millisecond?: number
);
constructor(...args: any[]) {
let [calendar, era, year, month, day] = shiftArgs(args);
let timeZone = args.shift();
let offset = args.shift();
this.calendar = calendar;
this.era = era;
this.year = year;
this.month = month;
this.day = day;
this.timeZone = timeZone;
this.offset = offset;
this.hour = args.shift() || 0;
this.minute = args.shift() || 0;
this.second = args.shift() || 0;
this.millisecond = args.shift() || 0;
constrain(this);
}
/** Returns a copy of this date. */
copy(): ZonedDateTime {
if (this.era) {
return new ZonedDateTime(
this.calendar,
this.era,
this.year,
this.month,
this.day,
this.timeZone,
this.offset,
this.hour,
this.minute,
this.second,
this.millisecond
);
} else {
return new ZonedDateTime(
this.calendar,
this.year,
this.month,
this.day,
this.timeZone,
this.offset,
this.hour,
this.minute,
this.second,
this.millisecond
);
}
}
/** Returns a new `ZonedDateTime` with the given duration added to it. */
add(duration: DateTimeDuration): ZonedDateTime {
return addZoned(this, duration);
}
/** Returns a new `ZonedDateTime` with the given duration subtracted from it. */
subtract(duration: DateTimeDuration): ZonedDateTime {
return subtractZoned(this, duration);
}
/** Returns a new `ZonedDateTime` with the given fields set to the provided values. Other fields will be constrained accordingly. */
set(fields: DateFields & TimeFields, disambiguation?: Disambiguation): ZonedDateTime {
return setZoned(this, fields, disambiguation);
}
/**
* Returns a new `ZonedDateTime` with the given field adjusted by a specified amount.
* When the resulting value reaches the limits of the field, it wraps around.
*/
cycle(field: DateField | TimeField, amount: number, options?: CycleTimeOptions): ZonedDateTime {
return cycleZoned(this, field, amount, options);
}
/** Converts the date to a native JavaScript Date object. */
toDate(): Date {
return zonedToDate(this);
}
/** Converts the date to an ISO 8601 formatted string, including the UTC offset and time zone identifier. */
toString(): string {
return zonedDateTimeToString(this);
}
/** Converts the date to an ISO 8601 formatted string in UTC. */
toAbsoluteString(): string {
return this.toDate().toISOString();
}
/** Compares this date with another. A negative result indicates that this date is before the given one, and a positive date indicates that it is after. */
compare(b: CalendarDate | CalendarDateTime | ZonedDateTime): number {
// TODO: Is this a bad idea??
return this.toDate().getTime() - toZoned(b, this.timeZone).toDate().getTime();
}
}

@ -0,0 +1,63 @@
/*
* Copyright 2020 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
import { BuddhistCalendar } from '../calendars/BuddhistCalendar';
import type { Calendar, CalendarIdentifier } from './types';
import {
CopticCalendar,
EthiopicAmeteAlemCalendar,
EthiopicCalendar
} from '../calendars/EthiopicCalendar';
import { GregorianCalendar } from '../calendars/GregorianCalendar';
import { HebrewCalendar } from '../calendars/HebrewCalendar';
import { IndianCalendar } from '../calendars/IndianCalendar';
import {
IslamicCivilCalendar,
IslamicTabularCalendar,
IslamicUmalquraCalendar
} from '../calendars/IslamicCalendar';
import { JapaneseCalendar } from '../calendars/JapaneseCalendar';
import { PersianCalendar } from '../calendars/PersianCalendar';
import { TaiwanCalendar } from '../calendars/TaiwanCalendar';
/** Creates a `Calendar` instance from a Unicode calendar identifier string. */
export function createCalendar(name: CalendarIdentifier): Calendar {
switch (name) {
case 'buddhist':
return new BuddhistCalendar();
case 'ethiopic':
return new EthiopicCalendar();
case 'ethioaa':
return new EthiopicAmeteAlemCalendar();
case 'coptic':
return new CopticCalendar();
case 'hebrew':
return new HebrewCalendar();
case 'indian':
return new IndianCalendar();
case 'islamic-civil':
return new IslamicCivilCalendar();
case 'islamic-tbla':
return new IslamicTabularCalendar();
case 'islamic-umalqura':
return new IslamicUmalquraCalendar();
case 'japanese':
return new JapaneseCalendar();
case 'persian':
return new PersianCalendar();
case 'roc':
return new TaiwanCalendar();
case 'gregory':
default:
return new GregorianCalendar();
}
}

@ -0,0 +1,212 @@
/*
* Copyright 2020 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
import { CalendarDate } from './CalendarDate';
/** An interface that is compatible with any object with date fields. */
export interface AnyCalendarDate {
readonly calendar: Calendar;
readonly era: string;
readonly year: number;
readonly month: number;
readonly day: number;
copy(): this;
}
/** An interface that is compatible with any object with time fields. */
export interface AnyTime {
readonly hour: number;
readonly minute: number;
readonly second: number;
readonly millisecond: number;
copy(): this;
}
/** An interface that is compatible with any object with both date and time fields. */
export interface AnyDateTime extends AnyCalendarDate, AnyTime {}
export type CalendarIdentifier =
| 'gregory'
| 'buddhist'
| 'chinese'
| 'coptic'
| 'dangi'
| 'ethioaa'
| 'ethiopic'
| 'hebrew'
| 'indian'
| 'islamic'
| 'islamic-umalqura'
| 'islamic-tbla'
| 'islamic-civil'
| 'islamic-rgsa'
| 'iso8601'
| 'japanese'
| 'persian'
| 'roc';
/**
* The Calendar interface represents a calendar system, including information
* about how days, months, years, and eras are organized, and methods to perform
* arithmetic on dates.
*/
export interface Calendar {
/**
* A string identifier for the calendar, as defined by Unicode CLDR.
* See [MDN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/supportedValuesOf#supported_calendar_types).
*/
identifier: CalendarIdentifier;
/** Creates a CalendarDate in this calendar from the given Julian day number. */
fromJulianDay(jd: number): CalendarDate;
/** Converts a date in this calendar to a Julian day number. */
toJulianDay(date: AnyCalendarDate): number;
/** Returns the number of days in the month of the given date. */
getDaysInMonth(date: AnyCalendarDate): number;
/** Returns the number of months in the year of the given date. */
getMonthsInYear(date: AnyCalendarDate): number;
/** Returns the number of years in the era of the given date. */
getYearsInEra(date: AnyCalendarDate): number;
/** Returns a list of era identifiers for the calendar. */
getEras(): string[];
/**
* Returns the minimum month number of the given date's year.
* Normally, this is 1, but in some calendars such as the Japanese,
* eras may begin in the middle of a year.
*/
getMinimumMonthInYear?(date: AnyCalendarDate): number;
/**
* Returns the minimum day number of the given date's month.
* Normally, this is 1, but in some calendars such as the Japanese,
* eras may begin in the middle of a month.
*/
getMinimumDayInMonth?(date: AnyCalendarDate): number;
/** Returns the maximum months across all years. */
getMaximumMonthsInYear(): number;
/** Returns the maximum days across all months. */
getMaximumDaysInMonth(): number;
/**
* Returns a date that is the first day of the month for the given date.
* This is used to determine the month that the given date falls in, if
* the calendar has months that do not align with the standard calendar months
* (e.g. fiscal calendars).
*/
getFormattableMonth?(date: AnyCalendarDate): CalendarDate;
/** Returns whether the given calendar is the same as this calendar. */
isEqual?(calendar: Calendar): boolean;
/** @private */
balanceDate?(date: AnyCalendarDate): void;
/** @private */
balanceYearMonth?(date: AnyCalendarDate, previousDate: AnyCalendarDate): void;
/** @private */
constrainDate?(date: AnyCalendarDate): void;
/** @private */
isInverseEra?(date: AnyCalendarDate): boolean;
}
/** Represents an amount of time in calendar-specific units, for use when performing arithmetic. */
export interface DateDuration {
/** The number of years to add or subtract. */
years?: number;
/** The number of months to add or subtract. */
months?: number;
/** The number of weeks to add or subtract. */
weeks?: number;
/** The number of days to add or subtract. */
days?: number;
}
/** Represents an amount of time, for use whe performing arithmetic. */
export interface TimeDuration {
/** The number of hours to add or subtract. */
hours?: number;
/** The number of minutes to add or subtract. */
minutes?: number;
/** The number of seconds to add or subtract. */
seconds?: number;
/** The number of milliseconds to add or subtract. */
milliseconds?: number;
}
/** Represents an amount of time with both date and time component, for use when performing arithmetic. */
export interface DateTimeDuration extends DateDuration, TimeDuration {}
export interface DateFields {
era?: string;
year?: number;
month?: number;
day?: number;
}
export interface TimeFields {
hour?: number;
minute?: number;
second?: number;
millisecond?: number;
}
export type DateField = keyof DateFields;
export type TimeField = keyof TimeFields;
export type Disambiguation = 'compatible' | 'earlier' | 'later' | 'reject';
export interface CycleOptions {
/** Whether to round the field value to the nearest interval of the amount. */
round?: boolean;
}
/**
* Hour cycle for time display.
* - `12` — 12-hour clock (AM/PM)
* - `24` — 24-hour clock
*/
export type HourCycle = 12 | 24;
export interface CycleTimeOptions extends CycleOptions {
/**
* Whether to use 12 or 24 hour time. If 12 hour time is chosen, the resulting value
* will remain in the same day period as the original value (e.g. if the value is AM,
* the resulting value also be AM).
* @default 24
*/
hourCycle?: HourCycle;
}
/**
* Nivel de detalle en el formateo de fecha u hora.
* Mapea directamente a los valores que acepta `Intl.DateTimeFormat`.
*/
export type DateStyleType = 'short' | 'medium' | 'long' | 'full';
/** Orden preferido de los componentes de fecha (informativo para la UI). */
export type DateOrder = 'DMY' | 'MDY' | 'YMD';
// ─── Domain types ─────────────────────────────────────────────────────────────
/**
* Granularity of a date/time value. Determines which fields are included
* when rendering or editing a date field.
*/
export type Granularity = 'day' | 'hour' | 'minute' | 'second';
/** Time-only granularity (subset of Granularity). */
export type TimeGranularity = 'hour' | 'minute' | 'second';
/**
* Day of the week that the calendar week starts on.
* 0 = Sunday, 1 = Monday, …, 6 = Saturday.
*/
export type WeekStartsOn = 0 | 1 | 2 | 3 | 4 | 5 | 6;

@ -0,0 +1,222 @@
/*
* 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 { HourCycle } from '../core/types';
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,92 @@
import { CalendarDate, CalendarDateTime, Time } from '../core/CalendarDate';
import type { DateValue, TimeValue } from '../operations/queries';
import { getLocalTimeZone } from '../operations/queries';
import type { Granularity, TimeGranularity, HourCycle, DateOrder } from '../core/types';
/** Valores por defecto compartidos para componentes desacoplados de contexto. */
export const DEFAULT_TIME_FORMAT: HourCycle = 24;
export const DEFAULT_DATE_ORDER: DateOrder = 'DMY';
// ─── Date defaults ────────────────────────────────────────────────────────────
type GetDefaultDateOpts = {
defaultValue?: DateValue | DateValue[] | undefined;
minValue?: DateValue;
maxValue?: DateValue;
granularity?: Granularity;
};
/**
* Returns a sensible default `DateValue` for use when no value is set.
* Respects `minValue`/`maxValue` clamps and `granularity` to decide whether
* to return a `CalendarDate` or a `CalendarDateTime`.
*/
export function getDefaultDate(opts: GetDefaultDateOpts): DateValue {
const { defaultValue, granularity = 'day', minValue, maxValue } = opts;
if (Array.isArray(defaultValue) && defaultValue.length) {
return defaultValue[defaultValue.length - 1]!;
}
if (defaultValue && !Array.isArray(defaultValue)) {
return defaultValue;
}
let date = new Date();
const tz = getLocalTimeZone();
if (minValue && date < minValue.toDate(tz)) date = minValue.toDate(tz);
else if (maxValue && date > maxValue.toDate(tz)) date = maxValue.toDate(tz);
const year = date.getFullYear();
const month = date.getMonth() + 1;
const day = date.getDate();
if (granularity === 'hour' || granularity === 'minute' || granularity === 'second') {
return new CalendarDateTime(year, month, day, 0, 0, 0);
}
return new CalendarDate(year, month, day);
}
// ─── Time defaults ────────────────────────────────────────────────────────────
type GetDefaultTimeOpts = {
defaultValue?: TimeValue | undefined;
granularity?: TimeGranularity;
};
/**
* Returns a sensible default `TimeValue`. Falls back to midnight (`00:00:00`).
*/
export function getDefaultTime(opts: GetDefaultTimeOpts): TimeValue {
return opts.defaultValue ?? new Time(0, 0, 0);
}
// ─── Granularity inference ────────────────────────────────────────────────────
/**
* Infers the appropriate `Granularity` from a `DateValue`.
* If an explicit `granularity` is provided, it is returned as-is.
*/
export function inferGranularity(value: DateValue, granularity?: Granularity): Granularity {
if (granularity) return granularity;
if (value instanceof CalendarDate) return 'day';
return 'minute';
}
/**
* Infers the appropriate `TimeGranularity`.
* Defaults to `'minute'` if none is provided.
*/
export function inferTimeGranularity(granularity?: TimeGranularity): TimeGranularity {
return granularity ?? 'minute';
}
// ─── HourCycle ────────────────────────────────────────────────────────────────
/**
* Returns the default hour cycle for the given locale based on
* `Intl.DateTimeFormat` resolved options.
*/
export function getDefaultHourCycle(locale: string): HourCycle {
const opts = new Intl.DateTimeFormat(locale, { hour: 'numeric' }).resolvedOptions();
return opts.hour12 ? 12 : 24;
}

@ -0,0 +1,143 @@
// prettier-ignore
const supportedLocales = [
'ach','af','am','an','ar','ast','az','be','bg','bn','br','bs',
'ca','cak','ckb','cs','cy','da','de','dsb','el','en','eo','es',
'et','eu','fa','ff','fi','fr','fy','ga','gd','gl','he','hr',
'hsb','hu','ia','id','it','ja','ka','kk','kn','ko','lb','lo',
'lt','lv','meh','ml','ms','nl','nn','no','oc','pl','pt','rm',
'ro','ru','sc','scn','sk','sl','sr','sv','szl','tg','th','tr',
'uk','zh-CN','zh-TW',
] as const;
const placeholderFields = ['year', 'month', 'day'] as const;
type SupportedLocale = (typeof supportedLocales)[number];
type PlaceholderField = (typeof placeholderFields)[number];
export type PlaceholderMap = Record<SupportedLocale, Record<PlaceholderField, string>>;
const placeholders: PlaceholderMap = {
ach: { year: 'mwaka', month: 'dwe', day: 'nino' },
af: { year: 'jjjj', month: 'mm', day: 'dd' },
am: { year: 'ዓዓዓዓ', month: 'ሚሜ', day: 'ቀቀ' },
an: { year: 'aaaa', month: 'mm', day: 'dd' },
ar: { year: 'سنة', month: 'شهر', day: 'يوم' },
ast: { year: 'aaaa', month: 'mm', day: 'dd' },
az: { year: 'iiii', month: 'aa', day: 'gg' },
be: { year: 'гггг', month: 'мм', day: 'дд' },
bg: { year: 'гггг', month: 'мм', day: 'дд' },
bn: { year: 'yyyy', month: 'মিমি', day: 'dd' },
br: { year: 'bbbb', month: 'mm', day: 'dd' },
bs: { year: 'gggg', month: 'mm', day: 'dd' },
ca: { year: 'aaaa', month: 'mm', day: 'dd' },
cak: { year: 'jjjj', month: 'ii', day: "q'q'" },
ckb: { year: 'ساڵ', month: 'مانگ', day: 'ڕۆژ' },
cs: { year: 'rrrr', month: 'mm', day: 'dd' },
cy: { year: 'bbbb', month: 'mm', day: 'dd' },
da: { year: 'åååå', month: 'mm', day: 'dd' },
de: { year: 'jjjj', month: 'mm', day: 'tt' },
dsb: { year: 'llll', month: 'mm', day: 'źź' },
el: { year: 'εεεε', month: 'μμ', day: 'ηη' },
en: { year: 'yyyy', month: 'mm', day: 'dd' },
eo: { year: 'jjjj', month: 'mm', day: 'tt' },
es: { year: 'aaaa', month: 'mm', day: 'dd' },
et: { year: 'aaaa', month: 'kk', day: 'pp' },
eu: { year: 'uuuu', month: 'hh', day: 'ee' },
fa: { year: 'سال', month: 'ماه', day: 'روز' },
ff: { year: 'hhhh', month: 'll', day: 'ññ' },
fi: { year: 'vvvv', month: 'kk', day: 'pp' },
fr: { year: 'aaaa', month: 'mm', day: 'jj' },
fy: { year: 'jjjj', month: 'mm', day: 'dd' },
ga: { year: 'bbbb', month: 'mm', day: 'll' },
gd: { year: 'bbbb', month: 'mm', day: 'll' },
gl: { year: 'aaaa', month: 'mm', day: 'dd' },
he: { year: 'שנה', month: 'חודש', day: 'יום' },
hr: { year: 'gggg', month: 'mm', day: 'dd' },
hsb: { year: 'llll', month: 'mm', day: 'dd' },
hu: { year: 'éééé', month: 'hh', day: 'nn' },
ia: { year: 'aaaa', month: 'mm', day: 'dd' },
id: { year: 'tttt', month: 'bb', day: 'hh' },
it: { year: 'aaaa', month: 'mm', day: 'gg' },
ja: { year: ' 年 ', month: '月', day: '日' },
ka: { year: 'წწწწ', month: 'თთ', day: 'რრ' },
kk: { year: 'жжжж', month: 'аа', day: 'кк' },
kn: { year: 'ವವವವ', month: 'ಮಿಮೀ', day: 'ದಿದಿ' },
ko: { year: '연도', month: '월', day: '일' },
lb: { year: 'jjjj', month: 'mm', day: 'dd' },
lo: { year: 'ປປປປ', month: 'ດດ', day: 'ວວ' },
lt: { year: 'mmmm', month: 'mm', day: 'dd' },
lv: { year: 'gggg', month: 'mm', day: 'dd' },
meh: { year: 'aaaa', month: 'mm', day: 'dd' },
ml: { year: 'വർഷം', month: 'മാസം', day: 'തീയതി' },
ms: { year: 'tttt', month: 'mm', day: 'hh' },
nl: { year: 'jjjj', month: 'mm', day: 'dd' },
nn: { year: 'åååå', month: 'mm', day: 'dd' },
no: { year: 'åååå', month: 'mm', day: 'dd' },
oc: { year: 'aaaa', month: 'mm', day: 'jj' },
pl: { year: 'rrrr', month: 'mm', day: 'dd' },
pt: { year: 'aaaa', month: 'mm', day: 'dd' },
rm: { year: 'oooo', month: 'mm', day: 'dd' },
ro: { year: 'aaaa', month: 'll', day: 'zz' },
ru: { year: 'гггг', month: 'мм', day: 'дд' },
sc: { year: 'aaaa', month: 'mm', day: 'dd' },
scn: { year: 'aaaa', month: 'mm', day: 'jj' },
sk: { year: 'rrrr', month: 'mm', day: 'dd' },
sl: { year: 'llll', month: 'mm', day: 'dd' },
sr: { year: 'гггг', month: 'мм', day: 'дд' },
sv: { year: 'åååå', month: 'mm', day: 'dd' },
szl: { year: 'rrrr', month: 'mm', day: 'dd' },
tg: { year: 'сссс', month: 'мм', day: 'рр' },
th: { year: 'ปปปป', month: 'ดด', day: 'วว' },
tr: { year: 'yyyy', month: 'aa', day: 'gg' },
uk: { year: 'рррр', month: 'мм', day: 'дд' },
'zh-CN': { year: '年', month: '月', day: '日' },
'zh-TW': { year: '年', month: '月', day: '日' }
};
function getPlaceholderObj(locale: SupportedLocale | (string & {})) {
if (!isSupportedLocale(locale)) {
const localeLanguage = getLocaleLanguage(locale);
if (!isSupportedLocale(localeLanguage)) {
return placeholders.en;
} else {
return placeholders[localeLanguage];
}
} else {
return placeholders[locale];
}
}
type Field = 'era' | 'year' | 'month' | 'day' | 'hour' | 'minute' | 'second' | 'dayPeriod';
export function getPlaceholder(
field: Field,
value: string,
locale: SupportedLocale | (string & {})
) {
if (isPlaceholderField(field)) return getPlaceholderObj(locale)[field];
if (isDefaultField(field)) return value;
if (isTimeField(field)) return '––';
return '';
}
function isSupportedLocale(locale: string): locale is SupportedLocale {
return supportedLocales.includes(locale as SupportedLocale);
}
function isPlaceholderField(field: unknown): field is PlaceholderField {
return placeholderFields.includes(field as PlaceholderField);
}
function isTimeField(field: unknown): field is 'hour' | 'minute' | 'second' {
return field === 'hour' || field === 'minute' || field === 'second';
}
function isDefaultField(field: unknown): field is 'era' | 'dayPeriod' {
return field === 'era' || field === 'dayPeriod';
}
function getLocaleLanguage(locale: string) {
if (Intl.Locale) {
return new Intl.Locale(locale).language;
}
return locale.split('-')[0]!;
}

@ -0,0 +1,10 @@
import type { HourCycle } from '../core/types';
export type Mutable<T> = {
-readonly [P in keyof T]: T[P];
};
/** Convierte la preferencia de usuario `DatiTimeFormat` al `HourCycle` numérico de `Intl`. */
export function timeFormatToHourCycle(fmt: HourCycle): HourCycle {
return fmt === 12 ? 12 : 24;
}

@ -0,0 +1,111 @@
/*
* Copyright 2020 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
// Data from https://github.com/unicode-cldr/cldr-core/blob/master/supplemental/weekData.json
// Locales starting on Sunday have been removed for compression.
export const weekStartData = {
'001': 1,
AD: 1,
AE: 6,
AF: 6,
AI: 1,
AL: 1,
AM: 1,
AN: 1,
AR: 1,
AT: 1,
AU: 1,
AX: 1,
AZ: 1,
BA: 1,
BE: 1,
BG: 1,
BH: 6,
BM: 1,
BN: 1,
BY: 1,
CH: 1,
CL: 1,
CM: 1,
CN: 1,
CR: 1,
CY: 1,
CZ: 1,
DE: 1,
DJ: 6,
DK: 1,
DZ: 6,
EC: 1,
EE: 1,
EG: 6,
ES: 1,
FI: 1,
FJ: 1,
FO: 1,
FR: 1,
GB: 1,
GE: 1,
GF: 1,
GP: 1,
GR: 1,
HR: 1,
HU: 1,
IE: 1,
IQ: 6,
IR: 6,
IS: 1,
IT: 1,
JO: 6,
KG: 1,
KW: 6,
KZ: 1,
LB: 1,
LI: 1,
LK: 1,
LT: 1,
LU: 1,
LV: 1,
LY: 6,
MC: 1,
MD: 1,
ME: 1,
MK: 1,
MN: 1,
MQ: 1,
MV: 5,
MY: 1,
NL: 1,
NO: 1,
NZ: 1,
OM: 6,
PL: 1,
QA: 6,
RE: 1,
RO: 1,
RS: 1,
RU: 1,
SD: 6,
SE: 1,
SI: 1,
SK: 1,
SM: 1,
SY: 6,
TJ: 1,
TM: 1,
TR: 1,
UA: 1,
UY: 1,
UZ: 1,
VA: 1,
VN: 1,
XK: 1
};

@ -0,0 +1,425 @@
/*
* Copyright 2020 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
// Portions of the code in this file are based on code from the TC39 Temporal proposal.
// Original licensing can be found in the NOTICE file in the root directory of this source tree.
import type {
AnyCalendarDate,
AnyDateTime,
AnyTime,
Calendar,
DateFields,
Disambiguation,
TimeFields
} from '../core/types';
import { CalendarDate, CalendarDateTime, Time, ZonedDateTime } from '../core/CalendarDate';
import { constrain } from './manipulation';
import { getExtendedYear, GregorianCalendar } from '../calendars/GregorianCalendar';
import { getLocalTimeZone, isEqualCalendar, isLocalTimeZoneOverridden } from './queries';
import type { Mutable } from '../internal/utils';
export function epochFromDate(date: AnyDateTime): number {
date = toCalendar(date, new GregorianCalendar());
let year = getExtendedYear(date.era, date.year);
return epochFromParts(
year,
date.month,
date.day,
date.hour,
date.minute,
date.second,
date.millisecond
);
}
function epochFromParts(
year: number,
month: number,
day: number,
hour: number,
minute: number,
second: number,
millisecond: number
): number {
// Note: Date.UTC() interprets one and two-digit years as being in the
// 20th century, so don't use it
let date = new Date();
date.setUTCHours(hour, minute, second, millisecond);
date.setUTCFullYear(year, month - 1, day);
return date.getTime();
}
export function getTimeZoneOffset(ms: number, timeZone: string): number {
// Fast path for UTC.
if (timeZone === 'UTC') {
return 0;
}
// Fast path: for local timezone after 1970, use native Date.
// Skip this fast path if the local timezone was explicitly overridden via setLocalTimeZone,
// since native Date always uses the browser's timezone, not the overridden one.
if (ms > 0 && timeZone === getLocalTimeZone() && !isLocalTimeZoneOverridden()) {
return new Date(ms).getTimezoneOffset() * -60 * 1000;
}
let { year, month, day, hour, minute, second } = getTimeZoneParts(ms, timeZone);
let utc = epochFromParts(year, month, day, hour, minute, second, 0);
return utc - Math.floor(ms / 1000) * 1000;
}
const formattersByTimeZone = new Map<string, Intl.DateTimeFormat>();
function getTimeZoneParts(ms: number, timeZone: string) {
let formatter = formattersByTimeZone.get(timeZone);
if (!formatter) {
formatter = new Intl.DateTimeFormat('en-US', {
timeZone,
hour12: false,
era: 'short',
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric'
});
formattersByTimeZone.set(timeZone, formatter);
}
let parts = formatter.formatToParts(new Date(ms));
let namedParts: { [name: string]: string } = {};
for (let part of parts) {
if (part.type !== 'literal') {
namedParts[part.type] = part.value;
}
}
return {
// Firefox returns B instead of BC... https://bugzilla.mozilla.org/show_bug.cgi?id=1752253
year:
namedParts.era === 'BC' || namedParts.era === 'B' ? -namedParts.year + 1 : +namedParts.year,
month: +namedParts.month,
day: +namedParts.day,
hour: namedParts.hour === '24' ? 0 : +namedParts.hour, // bugs.chromium.org/p/chromium/issues/detail?id=1045791
minute: +namedParts.minute,
second: +namedParts.second
};
}
const DAYMILLIS = 86400000;
export function possibleAbsolutes(date: CalendarDateTime, timeZone: string): number[] {
let ms = epochFromDate(date);
let earlier = ms - getTimeZoneOffset(ms - DAYMILLIS, timeZone);
let later = ms - getTimeZoneOffset(ms + DAYMILLIS, timeZone);
return getValidWallTimes(date, timeZone, earlier, later);
}
function getValidWallTimes(
date: CalendarDateTime,
timeZone: string,
earlier: number,
later: number
): number[] {
let found = earlier === later ? [earlier] : [earlier, later];
return found.filter((absolute) => isValidWallTime(date, timeZone, absolute));
}
function isValidWallTime(date: CalendarDateTime, timeZone: string, absolute: number) {
let parts = getTimeZoneParts(absolute, timeZone);
return (
date.year === parts.year &&
date.month === parts.month &&
date.day === parts.day &&
date.hour === parts.hour &&
date.minute === parts.minute &&
date.second === parts.second
);
}
export function toAbsolute(
date: CalendarDate | CalendarDateTime,
timeZone: string,
disambiguation: Disambiguation = 'compatible'
): number {
let dateTime = toCalendarDateTime(date);
// Fast path: if the time zone is UTC, use native Date.
if (timeZone === 'UTC') {
return epochFromDate(dateTime);
}
// Fast path: if the time zone is the local timezone and disambiguation is compatible, use native Date.
// Skip this fast path if the local timezone was explicitly overridden via setLocalTimeZone,
// since native Date always uses the browser's timezone, not the overridden one.
if (
timeZone === getLocalTimeZone() &&
disambiguation === 'compatible' &&
!isLocalTimeZoneOverridden()
) {
dateTime = toCalendar(dateTime, new GregorianCalendar());
// Don't use Date constructor here because two-digit years are interpreted in the 20th century.
let date = new Date();
let year = getExtendedYear(dateTime.era, dateTime.year);
date.setFullYear(year, dateTime.month - 1, dateTime.day);
date.setHours(dateTime.hour, dateTime.minute, dateTime.second, dateTime.millisecond);
return date.getTime();
}
let ms = epochFromDate(dateTime);
let offsetBefore = getTimeZoneOffset(ms - DAYMILLIS, timeZone);
let offsetAfter = getTimeZoneOffset(ms + DAYMILLIS, timeZone);
let valid = getValidWallTimes(dateTime, timeZone, ms - offsetBefore, ms - offsetAfter);
if (valid.length === 1) {
return valid[0];
}
if (valid.length > 1) {
switch (disambiguation) {
// 'compatible' means 'earlier' for "fall back" transitions
case 'compatible':
case 'earlier':
return valid[0];
case 'later':
return valid[valid.length - 1];
case 'reject':
throw new RangeError('Multiple possible absolute times found');
}
}
switch (disambiguation) {
case 'earlier':
return Math.min(ms - offsetBefore, ms - offsetAfter);
// 'compatible' means 'later' for "spring forward" transitions
case 'compatible':
case 'later':
return Math.max(ms - offsetBefore, ms - offsetAfter);
case 'reject':
throw new RangeError('No such absolute time found');
}
}
export function toDate(
dateTime: CalendarDate | CalendarDateTime,
timeZone: string,
disambiguation: Disambiguation = 'compatible'
): Date {
return new Date(toAbsolute(dateTime, timeZone, disambiguation));
}
/**
* Takes a Unix epoch (milliseconds since 1970) and converts it to the provided time zone.
*/
export function fromAbsolute(ms: number, timeZone: string): ZonedDateTime {
let offset = getTimeZoneOffset(ms, timeZone);
let date = new Date(ms + offset);
let year = date.getUTCFullYear();
let month = date.getUTCMonth() + 1;
let day = date.getUTCDate();
let hour = date.getUTCHours();
let minute = date.getUTCMinutes();
let second = date.getUTCSeconds();
let millisecond = date.getUTCMilliseconds();
return new ZonedDateTime(
year < 1 ? 'BC' : 'AD',
year < 1 ? -year + 1 : year,
month,
day,
timeZone,
offset,
hour,
minute,
second,
millisecond
);
}
/**
* Takes a `Date` object and converts it to the provided time zone.
*/
export function fromDate(date: Date, timeZone: string): ZonedDateTime {
return fromAbsolute(date.getTime(), timeZone);
}
/**
* Takes a `Date` object and converts it to the time zone identifier for the current user.
*/
export function fromDateToLocal(date: Date): ZonedDateTime {
return fromDate(date, getLocalTimeZone());
}
/** Converts a value with date component such as a `CalendarDateTime` or `ZonedDateTime` into a `CalendarDate`. */
export function toCalendarDate(dateTime: AnyCalendarDate): CalendarDate {
return new CalendarDate(
dateTime.calendar,
dateTime.era,
dateTime.year,
dateTime.month,
dateTime.day
);
}
export function toDateFields(date: AnyCalendarDate): DateFields {
return {
era: date.era,
year: date.year,
month: date.month,
day: date.day
};
}
export function toTimeFields(date: AnyTime): TimeFields {
return {
hour: date.hour,
minute: date.minute,
second: date.second,
millisecond: date.millisecond
};
}
/**
* Converts a date value to a `CalendarDateTime`. An optional `Time` value can be passed to set the time
* of the resulting value, otherwise it will default to midnight.
*/
export function toCalendarDateTime(
date: CalendarDate | CalendarDateTime | ZonedDateTime,
time?: AnyTime
): CalendarDateTime {
let hour = 0,
minute = 0,
second = 0,
millisecond = 0;
if ('timeZone' in date) {
({ hour, minute, second, millisecond } = date);
} else if ('hour' in date && !time) {
return date;
}
if (time) {
({ hour, minute, second, millisecond } = time);
}
return new CalendarDateTime(
date.calendar,
date.era,
date.year,
date.month,
date.day,
hour,
minute,
second,
millisecond
);
}
/** Extracts the time component from a value containing a date and time. */
export function toTime(dateTime: CalendarDateTime | ZonedDateTime): Time {
return new Time(dateTime.hour, dateTime.minute, dateTime.second, dateTime.millisecond);
}
/** Converts a date from one calendar system to another. */
export function toCalendar<T extends AnyCalendarDate>(date: T, calendar: Calendar): T {
if (isEqualCalendar(date.calendar, calendar)) {
return date;
}
let calendarDate = calendar.fromJulianDay(date.calendar.toJulianDay(date));
let copy: Mutable<T> = date.copy();
copy.calendar = calendar;
copy.era = calendarDate.era;
copy.year = calendarDate.year;
copy.month = calendarDate.month;
copy.day = calendarDate.day;
constrain(copy);
return copy;
}
/**
* Converts a date value to a `ZonedDateTime` in the provided time zone. The `disambiguation` option can be set
* to control how values that fall on daylight saving time changes are interpreted.
*/
export function toZoned(
date: CalendarDate | CalendarDateTime | ZonedDateTime,
timeZone: string,
disambiguation?: Disambiguation
): ZonedDateTime {
if (date instanceof ZonedDateTime) {
if (date.timeZone === timeZone) {
return date;
}
return toTimeZone(date, timeZone);
}
let ms = toAbsolute(date, timeZone, disambiguation);
return fromAbsolute(ms, timeZone);
}
export function zonedToDate(date: ZonedDateTime): Date {
let ms = epochFromDate(date) - date.offset;
return new Date(ms);
}
/** Converts a `ZonedDateTime` from one time zone to another. */
export function toTimeZone(date: ZonedDateTime, timeZone: string): ZonedDateTime {
let ms = epochFromDate(date) - date.offset;
return toCalendar(fromAbsolute(ms, timeZone), date.calendar);
}
/** Converts the given `ZonedDateTime` into the user's local time zone. */
export function toLocalTimeZone(date: ZonedDateTime): ZonedDateTime {
return toTimeZone(date, getLocalTimeZone());
}
// ─── DateValue / TimeValue bridge ─────────────────────────────────────────────
import type { TimeValue } from './queries';
/**
* Converts a `DateValue` to a native `Date`.
* Uses the local time zone unless a specific `tz` is provided.
*/
export function dateValueToDate(
dateValue: CalendarDate | CalendarDateTime | ZonedDateTime,
tz: string = getLocalTimeZone()
): Date {
if (dateValue instanceof ZonedDateTime) return dateValue.toDate();
return dateValue.toDate(tz);
}
/**
* Wraps a `TimeValue` (which may be a bare `Time`) in a `CalendarDateTime`
* so it can be used wherever a `DateValue` is required.
* The date portion is set to 2020-01-01 as a neutral anchor.
*/
export function convertTimeValueToDateValue(time: TimeValue): CalendarDateTime | ZonedDateTime {
if (time instanceof Time) {
return new CalendarDateTime(2020, 1, 1, time.hour, time.minute, time.second, time.millisecond);
}
return time;
}
/** Extracts a `Time` from any `TimeValue`. */
export function convertTimeValueToTime(time: TimeValue): Time {
if (time instanceof Time) return time;
return new Time(time.hour, time.minute, time.second, time.millisecond);
}
/** Returns an ISO 8601 time string for any `TimeValue`. */
export function getISOTimeValue(time: TimeValue): string {
return convertTimeValueToTime(time).toString();
}

@ -0,0 +1,580 @@
/*
* Copyright 2020 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
import type {
AnyCalendarDate,
AnyDateTime,
AnyTime,
CycleOptions,
CycleTimeOptions,
DateDuration,
DateField,
DateFields,
DateTimeDuration,
Disambiguation,
TimeDuration,
TimeField,
TimeFields
} from '../core/types';
import { CalendarDate, CalendarDateTime, Time, ZonedDateTime } from '../core/CalendarDate';
import {
epochFromDate,
fromAbsolute,
toAbsolute,
toCalendar,
toCalendarDateTime
} from './conversion';
import { GregorianCalendar } from '../calendars/GregorianCalendar';
import type { Mutable } from '../internal/utils';
const ONE_HOUR = 3600000;
export function add(date: CalendarDateTime, duration: DateTimeDuration): CalendarDateTime;
export function add(date: CalendarDate, duration: DateDuration): CalendarDate;
export function add(
date: CalendarDate | CalendarDateTime,
duration: DateTimeDuration
): CalendarDate | CalendarDateTime;
export function add(
date: CalendarDate | CalendarDateTime,
duration: DateTimeDuration
): Mutable<AnyCalendarDate | AnyDateTime> {
let mutableDate: Mutable<AnyCalendarDate | AnyDateTime> = date.copy();
let days = 'hour' in mutableDate ? addTimeFields(mutableDate, duration) : 0;
addYears(mutableDate, duration.years || 0);
if (mutableDate.calendar.balanceYearMonth) {
mutableDate.calendar.balanceYearMonth(mutableDate, date);
}
mutableDate.month += duration.months || 0;
balanceYearMonth(mutableDate);
constrainMonthDay(mutableDate);
mutableDate.day += (duration.weeks || 0) * 7;
mutableDate.day += duration.days || 0;
mutableDate.day += days;
balanceDay(mutableDate);
if (mutableDate.calendar.balanceDate) {
mutableDate.calendar.balanceDate(mutableDate);
}
// Constrain in case adding ended up with a date outside the valid range for the calendar system.
// The behavior here is slightly different than when constraining in the `set` function in that
// we adjust smaller fields to their minimum/maximum values rather than constraining each field
// individually. This matches the general behavior of `add` vs `set` regarding how fields are balanced.
if (mutableDate.year < 1) {
mutableDate.year = 1;
mutableDate.month = 1;
mutableDate.day = 1;
}
let maxYear = mutableDate.calendar.getYearsInEra(mutableDate);
if (mutableDate.year > maxYear) {
let isInverseEra = mutableDate.calendar.isInverseEra?.(mutableDate);
mutableDate.year = maxYear;
mutableDate.month = isInverseEra ? 1 : mutableDate.calendar.getMonthsInYear(mutableDate);
mutableDate.day = isInverseEra ? 1 : mutableDate.calendar.getDaysInMonth(mutableDate);
}
if (mutableDate.month < 1) {
mutableDate.month = 1;
mutableDate.day = 1;
}
let maxMonth = mutableDate.calendar.getMonthsInYear(mutableDate);
if (mutableDate.month > maxMonth) {
mutableDate.month = maxMonth;
mutableDate.day = mutableDate.calendar.getDaysInMonth(mutableDate);
}
mutableDate.day = Math.max(
1,
Math.min(mutableDate.calendar.getDaysInMonth(mutableDate), mutableDate.day)
);
return mutableDate;
}
function addYears(date: Mutable<AnyCalendarDate>, years: number) {
if (date.calendar.isInverseEra?.(date)) {
years = -years;
}
date.year += years;
}
function balanceYearMonth(date: Mutable<AnyCalendarDate>) {
while (date.month < 1) {
addYears(date, -1);
date.month += date.calendar.getMonthsInYear(date);
}
let monthsInYear = 0;
while (date.month > (monthsInYear = date.calendar.getMonthsInYear(date))) {
date.month -= monthsInYear;
addYears(date, 1);
}
}
function balanceDay(date: Mutable<AnyCalendarDate>) {
while (date.day < 1) {
date.month--;
balanceYearMonth(date);
date.day += date.calendar.getDaysInMonth(date);
}
while (date.day > date.calendar.getDaysInMonth(date)) {
date.day -= date.calendar.getDaysInMonth(date);
date.month++;
balanceYearMonth(date);
}
}
function constrainMonthDay(date: Mutable<AnyCalendarDate>) {
date.month = Math.max(1, Math.min(date.calendar.getMonthsInYear(date), date.month));
date.day = Math.max(1, Math.min(date.calendar.getDaysInMonth(date), date.day));
}
export function constrain(date: Mutable<AnyCalendarDate>): void {
if (date.calendar.constrainDate) {
date.calendar.constrainDate(date);
}
date.year = Math.max(1, Math.min(date.calendar.getYearsInEra(date), date.year));
constrainMonthDay(date);
}
export function invertDuration(duration: DateTimeDuration): DateTimeDuration {
let inverseDuration: Record<string, number> = {};
for (let key in duration) {
const k = key as keyof DateTimeDuration;
if (typeof duration[k] === 'number') {
inverseDuration[key] = -(duration[k] as number);
}
}
return inverseDuration as DateTimeDuration;
}
export function subtract(date: CalendarDateTime, duration: DateTimeDuration): CalendarDateTime;
export function subtract(date: CalendarDate, duration: DateDuration): CalendarDate;
export function subtract(
date: CalendarDate | CalendarDateTime,
duration: DateTimeDuration
): CalendarDate | CalendarDateTime {
return add(date, invertDuration(duration));
}
export function set(date: CalendarDateTime, fields: DateFields): CalendarDateTime;
export function set(date: CalendarDate, fields: DateFields): CalendarDate;
export function set(
date: CalendarDate | CalendarDateTime,
fields: DateFields
): Mutable<AnyCalendarDate> {
let mutableDate: Mutable<AnyCalendarDate> = date.copy();
if (fields.era != null) {
mutableDate.era = fields.era;
}
if (fields.year != null) {
mutableDate.year = fields.year;
}
if (fields.month != null) {
mutableDate.month = fields.month;
}
if (fields.day != null) {
mutableDate.day = fields.day;
}
constrain(mutableDate);
return mutableDate;
}
export function setTime(value: CalendarDateTime, fields: TimeFields): CalendarDateTime;
export function setTime(value: Time, fields: TimeFields): Time;
export function setTime(
value: Time | CalendarDateTime,
fields: TimeFields
): Mutable<Time | CalendarDateTime> {
let mutableValue: Mutable<Time | CalendarDateTime> = value.copy();
if (fields.hour != null) {
mutableValue.hour = fields.hour;
}
if (fields.minute != null) {
mutableValue.minute = fields.minute;
}
if (fields.second != null) {
mutableValue.second = fields.second;
}
if (fields.millisecond != null) {
mutableValue.millisecond = fields.millisecond;
}
constrainTime(mutableValue);
return mutableValue;
}
function balanceTime(time: Mutable<AnyTime>): number {
time.second += Math.floor(time.millisecond / 1000);
time.millisecond = nonNegativeMod(time.millisecond, 1000);
time.minute += Math.floor(time.second / 60);
time.second = nonNegativeMod(time.second, 60);
time.hour += Math.floor(time.minute / 60);
time.minute = nonNegativeMod(time.minute, 60);
let days = Math.floor(time.hour / 24);
time.hour = nonNegativeMod(time.hour, 24);
return days;
}
export function constrainTime(time: Mutable<AnyTime>): void {
time.millisecond = Math.max(0, Math.min(time.millisecond, 1000));
time.second = Math.max(0, Math.min(time.second, 59));
time.minute = Math.max(0, Math.min(time.minute, 59));
time.hour = Math.max(0, Math.min(time.hour, 23));
}
function nonNegativeMod(a: number, b: number) {
let result = a % b;
if (result < 0) {
result += b;
}
return result;
}
function addTimeFields(time: Mutable<AnyTime>, duration: TimeDuration): number {
time.hour += duration.hours || 0;
time.minute += duration.minutes || 0;
time.second += duration.seconds || 0;
time.millisecond += duration.milliseconds || 0;
return balanceTime(time);
}
export function addTime(time: Time, duration: TimeDuration): Time {
let res = time.copy();
addTimeFields(res, duration);
return res;
}
export function subtractTime(time: Time, duration: TimeDuration): Time {
return addTime(time, invertDuration(duration));
}
export function cycleDate(
value: CalendarDateTime,
field: DateField,
amount: number,
options?: CycleOptions
): CalendarDateTime;
export function cycleDate(
value: CalendarDate,
field: DateField,
amount: number,
options?: CycleOptions
): CalendarDate;
export function cycleDate(
value: CalendarDate | CalendarDateTime,
field: DateField,
amount: number,
options?: CycleOptions
): Mutable<CalendarDate | CalendarDateTime> {
let mutable: Mutable<CalendarDate | CalendarDateTime> = value.copy();
switch (field) {
case 'era': {
let eras = value.calendar.getEras();
let eraIndex = eras.indexOf(value.era);
if (eraIndex < 0) {
throw new Error('Invalid era: ' + value.era);
}
eraIndex = cycleValue(eraIndex, amount, 0, eras.length - 1, options?.round);
mutable.era = eras[eraIndex];
// Constrain the year and other fields within the era, so the era doesn't change when we balance below.
constrain(mutable);
break;
}
case 'year': {
if (mutable.calendar.isInverseEra?.(mutable)) {
amount = -amount;
}
// The year field should not cycle within the era as that can cause weird behavior affecting other fields.
// We need to also allow values < 1 so that decrementing goes to the previous era. If we get -Infinity back
// we know we wrapped around after reaching 9999 (the maximum), so set the year back to 1.
mutable.year = cycleValue(value.year, amount, -Infinity, 9999, options?.round);
if (mutable.year === -Infinity) {
mutable.year = 1;
}
if (mutable.calendar.balanceYearMonth) {
mutable.calendar.balanceYearMonth(mutable, value);
}
break;
}
case 'month':
mutable.month = cycleValue(
value.month,
amount,
1,
value.calendar.getMonthsInYear(value),
options?.round
);
break;
case 'day':
mutable.day = cycleValue(
value.day,
amount,
1,
value.calendar.getDaysInMonth(value),
options?.round
);
break;
default:
throw new Error('Unsupported field ' + field);
}
if (value.calendar.balanceDate) {
value.calendar.balanceDate(mutable);
}
constrain(mutable);
return mutable;
}
export function cycleTime(
value: CalendarDateTime,
field: TimeField,
amount: number,
options?: CycleTimeOptions
): CalendarDateTime;
export function cycleTime(
value: Time,
field: TimeField,
amount: number,
options?: CycleTimeOptions
): Time;
export function cycleTime(
value: Time | CalendarDateTime,
field: TimeField,
amount: number,
options?: CycleTimeOptions
): Mutable<Time | CalendarDateTime> {
let mutable: Mutable<Time | CalendarDateTime> = value.copy();
switch (field) {
case 'hour': {
let hours = value.hour;
let min = 0;
let max = 23;
if (options?.hourCycle === 12) {
let isPM = hours >= 12;
min = isPM ? 12 : 0;
max = isPM ? 23 : 11;
}
mutable.hour = cycleValue(hours, amount, min, max, options?.round);
break;
}
case 'minute':
mutable.minute = cycleValue(value.minute, amount, 0, 59, options?.round);
break;
case 'second':
mutable.second = cycleValue(value.second, amount, 0, 59, options?.round);
break;
case 'millisecond':
mutable.millisecond = cycleValue(value.millisecond, amount, 0, 999, options?.round);
break;
default:
throw new Error('Unsupported field ' + field);
}
return mutable;
}
function cycleValue(value: number, amount: number, min: number, max: number, round = false) {
if (round) {
value += Math.sign(amount);
if (value < min) {
value = max;
}
let div = Math.abs(amount);
if (amount > 0) {
value = Math.ceil(value / div) * div;
} else {
value = Math.floor(value / div) * div;
}
if (value > max) {
value = min;
}
} else {
value += amount;
if (value < min) {
value = max - (min - value - 1);
} else if (value > max) {
value = min + (value - max - 1);
}
}
return value;
}
export function addZoned(dateTime: ZonedDateTime, duration: DateTimeDuration): ZonedDateTime {
let ms: number;
if (
(duration.years != null && duration.years !== 0) ||
(duration.months != null && duration.months !== 0) ||
(duration.weeks != null && duration.weeks !== 0) ||
(duration.days != null && duration.days !== 0)
) {
let res = add(toCalendarDateTime(dateTime), {
years: duration.years,
months: duration.months,
weeks: duration.weeks,
days: duration.days
});
// Changing the date may change the timezone offset, so we need to recompute
// using the 'compatible' disambiguation.
ms = toAbsolute(res, dateTime.timeZone);
} else {
// Otherwise, preserve the offset of the original date.
ms = epochFromDate(dateTime) - dateTime.offset;
}
// Perform time manipulation in milliseconds rather than on the original time fields to account for DST.
// For example, adding one hour during a DST transition may result in the hour field staying the same or
// skipping an hour. This results in the offset field changing value instead of the specified field.
ms += duration.milliseconds || 0;
ms += (duration.seconds || 0) * 1000;
ms += (duration.minutes || 0) * 60 * 1000;
ms += (duration.hours || 0) * 60 * 60 * 1000;
let res = fromAbsolute(ms, dateTime.timeZone);
return toCalendar(res, dateTime.calendar);
}
export function subtractZoned(dateTime: ZonedDateTime, duration: DateTimeDuration): ZonedDateTime {
return addZoned(dateTime, invertDuration(duration));
}
export function cycleZoned(
dateTime: ZonedDateTime,
field: DateField | TimeField,
amount: number,
options?: CycleTimeOptions
): ZonedDateTime {
// For date fields, we want the time to remain consistent and the UTC offset to potentially change to account for DST changes.
// For time fields, we want the time to change by the amount given. This may result in the hour field staying the same, but the UTC
// offset changing in the case of a backward DST transition, or skipping an hour in the case of a forward DST transition.
switch (field) {
case 'hour': {
let min = 0;
let max = 23;
if (options?.hourCycle === 12) {
let isPM = dateTime.hour >= 12;
min = isPM ? 12 : 0;
max = isPM ? 23 : 11;
}
// The minimum and maximum hour may be affected by daylight saving time.
// For example, it might jump forward at midnight, and skip 1am.
// Or it might end at midnight and repeat the 11pm hour. To handle this, we get
// the possible absolute times for the min and max, and find the maximum range
// that is within the current day.
let plainDateTime = toCalendarDateTime(dateTime);
let minDate = toCalendar(setTime(plainDateTime, { hour: min }), new GregorianCalendar());
let minAbsolute = [
toAbsolute(minDate, dateTime.timeZone, 'earlier'),
toAbsolute(minDate, dateTime.timeZone, 'later')
].filter((ms) => fromAbsolute(ms, dateTime.timeZone).day === minDate.day)[0];
let maxDate = toCalendar(setTime(plainDateTime, { hour: max }), new GregorianCalendar());
let maxAbsolute = [
toAbsolute(maxDate, dateTime.timeZone, 'earlier'),
toAbsolute(maxDate, dateTime.timeZone, 'later')
]
.filter((ms) => fromAbsolute(ms, dateTime.timeZone).day === maxDate.day)
.pop()!;
// Since hours may repeat, we need to operate on the absolute time in milliseconds.
// This is done in hours from the Unix epoch so that cycleValue works correctly,
// and then converted back to milliseconds.
let ms = epochFromDate(dateTime) - dateTime.offset;
let hours = Math.floor(ms / ONE_HOUR);
let remainder = ms % ONE_HOUR;
ms =
cycleValue(
hours,
amount,
Math.floor(minAbsolute / ONE_HOUR),
Math.floor(maxAbsolute / ONE_HOUR),
options?.round
) *
ONE_HOUR +
remainder;
// Now compute the new timezone offset, and convert the absolute time back to local time.
return toCalendar(fromAbsolute(ms, dateTime.timeZone), dateTime.calendar);
}
case 'minute':
case 'second':
case 'millisecond':
// @ts-ignore
return cycleTime(dateTime, field, amount, options);
case 'era':
case 'year':
case 'month':
case 'day': {
let res = cycleDate(toCalendarDateTime(dateTime), field, amount, options);
let ms = toAbsolute(res, dateTime.timeZone);
return toCalendar(fromAbsolute(ms, dateTime.timeZone), dateTime.calendar);
}
default:
throw new Error('Unsupported field ' + field);
}
}
export function setZoned(
dateTime: ZonedDateTime,
fields: DateFields & TimeFields,
disambiguation?: Disambiguation
): ZonedDateTime {
// Set the date/time fields, and recompute the UTC offset to account for DST changes.
// We also need to validate by converting back to a local time in case hours are skipped during forward DST transitions.
let plainDateTime = toCalendarDateTime(dateTime);
let res = setTime(set(plainDateTime, fields), fields);
// If the resulting plain date time values are equal, return the original time.
// We don't want to change the offset when setting the time to the same value.
if (res.compare(plainDateTime) === 0) {
return dateTime;
}
let ms = toAbsolute(res, dateTime.timeZone, disambiguation);
return toCalendar(fromAbsolute(ms, dateTime.timeZone), dateTime.calendar);
}

@ -0,0 +1,581 @@
/*
* Copyright 2020 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
import type { AnyCalendarDate, AnyTime, Calendar } from '../core/types';
import { CalendarDate, CalendarDateTime, Time, ZonedDateTime } from '../core/CalendarDate';
import { fromAbsolute, toAbsolute, toCalendar, toCalendarDate } from './conversion';
import { weekStartData } from '../internal/weekStartData';
export type DateValue = CalendarDate | CalendarDateTime | ZonedDateTime;
/** Returns whether the given dates occur on the same day, regardless of the time or calendar system. */
export function isSameDay(a: DateValue, b: DateValue): boolean {
b = toCalendar(b, a.calendar);
return a.era === b.era && a.year === b.year && a.month === b.month && a.day === b.day;
}
/** Returns whether the given dates occur in the same month, using the calendar system of the first date. */
export function isSameMonth(a: DateValue, b: DateValue): boolean {
b = toCalendar(b, a.calendar);
// In the Japanese calendar, months can span multiple eras/years, so only compare the first of the month.
a = startOfMonth(a);
b = startOfMonth(b);
return a.era === b.era && a.year === b.year && a.month === b.month;
}
/** Returns whether the given dates occur in the same year, using the calendar system of the first date. */
export function isSameYear(a: DateValue, b: DateValue): boolean {
b = toCalendar(b, a.calendar);
a = startOfYear(a);
b = startOfYear(b);
return a.era === b.era && a.year === b.year;
}
/** Returns whether the given dates occur on the same day, and are of the same calendar system. */
export function isEqualDay(a: DateValue, b: DateValue): boolean {
return isEqualCalendar(a.calendar, b.calendar) && isSameDay(a, b);
}
/** Returns whether the given dates occur in the same month, and are of the same calendar system. */
export function isEqualMonth(a: DateValue, b: DateValue): boolean {
return isEqualCalendar(a.calendar, b.calendar) && isSameMonth(a, b);
}
/** Returns whether the given dates occur in the same year, and are of the same calendar system. */
export function isEqualYear(a: DateValue, b: DateValue): boolean {
return isEqualCalendar(a.calendar, b.calendar) && isSameYear(a, b);
}
/** Returns whether two calendars are the same. */
export function isEqualCalendar(a: Calendar, b: Calendar): boolean {
return a.isEqual?.(b) ?? b.isEqual?.(a) ?? a.identifier === b.identifier;
}
/** Returns whether the date is today in the given time zone. */
export function isToday(date: DateValue, timeZone: string): boolean {
return isSameDay(date, today(timeZone));
}
const DAY_MAP = {
sun: 0,
mon: 1,
tue: 2,
wed: 3,
thu: 4,
fri: 5,
sat: 6
};
type DayOfWeek = 'sun' | 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat';
/**
* Returns the day of week for the given date and locale. Days are numbered from zero to six,
* where zero is the first day of the week in the given locale. For example, in the United States,
* the first day of the week is Sunday, but in France it is Monday.
*/
export function getDayOfWeek(date: DateValue, locale: string, firstDayOfWeek?: DayOfWeek): number {
let julian = date.calendar.toJulianDay(date);
// If julian is negative, then julian % 7 will be negative, so we adjust
// accordingly. Julian day 0 is Monday.
let weekStart = firstDayOfWeek ? DAY_MAP[firstDayOfWeek] : getWeekStart(locale);
let dayOfWeek = Math.ceil(julian + 1 - weekStart) % 7;
if (dayOfWeek < 0) {
dayOfWeek += 7;
}
return dayOfWeek;
}
/** Returns the current time in the given time zone. */
export function now(timeZone: string): ZonedDateTime {
return fromAbsolute(Date.now(), timeZone);
}
/** Returns today's date in the given time zone. */
export function today(timeZone: string): CalendarDate {
return toCalendarDate(now(timeZone));
}
export function compareDate(a: AnyCalendarDate, b: AnyCalendarDate): number {
return a.calendar.toJulianDay(a) - b.calendar.toJulianDay(b);
}
export function compareTime(a: AnyTime, b: AnyTime): number {
return timeToMs(a) - timeToMs(b);
}
function timeToMs(a: AnyTime): number {
return a.hour * 60 * 60 * 1000 + a.minute * 60 * 1000 + a.second * 1000 + a.millisecond;
}
/**
* Returns the number of hours in the given date and time zone.
* Usually this is 24, but it could be 23 or 25 if the date is on a daylight saving transition.
*/
export function getHoursInDay(a: CalendarDate, timeZone: string): number {
let ms = toAbsolute(a, timeZone);
let tomorrow = a.add({ days: 1 });
let tomorrowMs = toAbsolute(tomorrow, timeZone);
return (tomorrowMs - ms) / 3600000;
}
let localTimeZone: string | null = null;
let localTimeZoneOverride = false;
/** Returns the time zone identifier for the current user. */
export function getLocalTimeZone(): string {
if (localTimeZone == null) {
localTimeZone = new Intl.DateTimeFormat().resolvedOptions().timeZone;
}
return localTimeZone!;
}
/** Sets the time zone identifier for the current user. */
export function setLocalTimeZone(timeZone: string): void {
localTimeZoneOverride = true;
localTimeZone = timeZone;
}
/** Resets the time zone identifier for the current user. */
export function resetLocalTimeZone(): void {
localTimeZoneOverride = false;
localTimeZone = null;
}
/** Returns whether the local time zone has been explicitly overridden via `setLocalTimeZone`. */
export function isLocalTimeZoneOverridden(): boolean {
return localTimeZoneOverride;
}
/** Returns the first date of the month for the given date. */
export function startOfMonth(date: ZonedDateTime): ZonedDateTime;
export function startOfMonth(date: CalendarDateTime): CalendarDateTime;
export function startOfMonth(date: CalendarDate): CalendarDate;
export function startOfMonth(date: DateValue): DateValue;
export function startOfMonth(date: DateValue): DateValue {
// Use `subtract` instead of `set` so we don't get constrained in an era.
return date.subtract({ days: date.day - 1 });
}
/** Returns the last date of the month for the given date. */
export function endOfMonth(date: ZonedDateTime): ZonedDateTime;
export function endOfMonth(date: CalendarDateTime): CalendarDateTime;
export function endOfMonth(date: CalendarDate): CalendarDate;
export function endOfMonth(date: DateValue): DateValue;
export function endOfMonth(date: DateValue): DateValue {
return date.add({ days: date.calendar.getDaysInMonth(date) - date.day });
}
/** Returns the first day of the year for the given date. */
export function startOfYear(date: ZonedDateTime): ZonedDateTime;
export function startOfYear(date: CalendarDateTime): CalendarDateTime;
export function startOfYear(date: CalendarDate): CalendarDate;
export function startOfYear(date: DateValue): DateValue;
export function startOfYear(date: DateValue): DateValue {
return startOfMonth(date.subtract({ months: date.month - 1 }));
}
/** Returns the last day of the year for the given date. */
export function endOfYear(date: ZonedDateTime): ZonedDateTime;
export function endOfYear(date: CalendarDateTime): CalendarDateTime;
export function endOfYear(date: CalendarDate): CalendarDate;
export function endOfYear(date: DateValue): DateValue;
export function endOfYear(date: DateValue): DateValue {
return endOfMonth(date.add({ months: date.calendar.getMonthsInYear(date) - date.month }));
}
export function getMinimumMonthInYear(date: AnyCalendarDate): number {
if (date.calendar.getMinimumMonthInYear) {
return date.calendar.getMinimumMonthInYear(date);
}
return 1;
}
export function getMinimumDayInMonth(date: AnyCalendarDate): number {
if (date.calendar.getMinimumDayInMonth) {
return date.calendar.getMinimumDayInMonth(date);
}
return 1;
}
/** Returns the first date of the week for the given date and locale. */
export function startOfWeek(
date: ZonedDateTime,
locale: string,
firstDayOfWeek?: DayOfWeek
): ZonedDateTime;
export function startOfWeek(
date: CalendarDateTime,
locale: string,
firstDayOfWeek?: DayOfWeek
): CalendarDateTime;
export function startOfWeek(
date: CalendarDate,
locale: string,
firstDayOfWeek?: DayOfWeek
): CalendarDate;
export function startOfWeek(date: DateValue, locale: string, firstDayOfWeek?: DayOfWeek): DateValue;
export function startOfWeek(
date: DateValue,
locale: string,
firstDayOfWeek?: DayOfWeek
): DateValue {
let dayOfWeek = getDayOfWeek(date, locale, firstDayOfWeek);
return date.subtract({ days: dayOfWeek });
}
/** Returns the last date of the week for the given date and locale. */
export function endOfWeek(
date: ZonedDateTime,
locale: string,
firstDayOfWeek?: DayOfWeek
): ZonedDateTime;
export function endOfWeek(
date: CalendarDateTime,
locale: string,
firstDayOfWeek?: DayOfWeek
): CalendarDateTime;
export function endOfWeek(
date: CalendarDate,
locale: string,
firstDayOfWeek?: DayOfWeek
): CalendarDate;
export function endOfWeek(date: DateValue, locale: string, firstDayOfWeek?: DayOfWeek): DateValue;
export function endOfWeek(date: DateValue, locale: string, firstDayOfWeek?: DayOfWeek): DateValue {
return startOfWeek(date, locale, firstDayOfWeek).add({ days: 6 });
}
const cachedRegions = new Map<string, string>();
const cachedWeekInfo = new Map<string, { firstDay: number }>();
function getRegion(locale: string): string | undefined {
// If the Intl.Locale API is available, use it to get the region for the locale.
// @ts-ignore
if (Intl.Locale) {
// Constructing an Intl.Locale is expensive, so cache the result.
let region = cachedRegions.get(locale);
if (!region) {
// @ts-ignore
region = new Intl.Locale(locale).maximize().region;
if (region) {
cachedRegions.set(locale, region);
}
}
return region;
}
// If not, just try splitting the string.
// If the second part of the locale string is 'u',
// then this is a unicode extension, so ignore it.
// Otherwise, it should be the region.
let part = locale.split('-')[1];
return part === 'u' ? undefined : part;
}
function getWeekStart(locale: string): number {
// TODO: use Intl.Locale for this once browsers support the weekInfo property
// https://github.com/tc39/proposal-intl-locale-info
let weekInfo = cachedWeekInfo.get(locale);
if (!weekInfo) {
if (Intl.Locale) {
// @ts-ignore
let localeInst = new Intl.Locale(locale);
if ('getWeekInfo' in localeInst) {
// @ts-expect-error
weekInfo = localeInst.getWeekInfo();
if (weekInfo) {
cachedWeekInfo.set(locale, weekInfo);
return weekInfo.firstDay;
}
}
}
let region = getRegion(locale);
if (locale.includes('-fw-')) {
// pull the value for the attribute fw from strings such as en-US-u-ca-iso8601-fw-tue or en-US-u-ca-iso8601-fw-mon-nu-thai
// where the fw attribute could be followed by another unicode locale extension or not
let day = locale.split('-fw-')[1].split('-')[0];
if (day === 'mon') {
weekInfo = { firstDay: 1 };
} else if (day === 'tue') {
weekInfo = { firstDay: 2 };
} else if (day === 'wed') {
weekInfo = { firstDay: 3 };
} else if (day === 'thu') {
weekInfo = { firstDay: 4 };
} else if (day === 'fri') {
weekInfo = { firstDay: 5 };
} else if (day === 'sat') {
weekInfo = { firstDay: 6 };
} else {
weekInfo = { firstDay: 0 };
}
} else if (locale.includes('-ca-iso8601')) {
weekInfo = { firstDay: 1 };
} else {
weekInfo = {
firstDay: region ? weekStartData[region as keyof typeof weekStartData] || 0 : 0
};
}
cachedWeekInfo.set(locale, weekInfo);
}
return weekInfo.firstDay;
}
/** Returns the number of weeks in the given month and locale. */
export function getWeeksInMonth(
date: DateValue,
locale: string,
firstDayOfWeek?: DayOfWeek
): number {
let days = date.calendar.getDaysInMonth(date);
return Math.ceil((getDayOfWeek(startOfMonth(date), locale, firstDayOfWeek) + days) / 7);
}
/** Returns the lesser of the two provider dates. */
export function minDate<A extends DateValue, B extends DateValue>(
a?: A | null,
b?: B | null
): A | B | null | undefined {
if (a && b) {
return a.compare(b) <= 0 ? a : b;
}
return a || b;
}
/** Returns the greater of the two provider dates. */
export function maxDate<A extends DateValue, B extends DateValue>(
a?: A | null,
b?: B | null
): A | B | null | undefined {
if (a && b) {
return a.compare(b) >= 0 ? a : b;
}
return a || b;
}
const WEEKEND_DATA = {
AF: [4, 5],
AE: [5, 6],
BH: [5, 6],
DZ: [5, 6],
EG: [5, 6],
IL: [5, 6],
IQ: [5, 6],
IR: [5, 5],
JO: [5, 6],
KW: [5, 6],
LY: [5, 6],
OM: [5, 6],
QA: [5, 6],
SA: [5, 6],
SD: [5, 6],
SY: [5, 6],
YE: [5, 6]
};
/** Returns whether the given date is on a weekend in the given locale. */
export function isWeekend(date: DateValue, locale: string): boolean {
let julian = date.calendar.toJulianDay(date);
// If julian is negative, then julian % 7 will be negative, so we adjust
// accordingly. Julian day 0 is Monday.
let dayOfWeek = Math.ceil(julian + 1) % 7;
if (dayOfWeek < 0) {
dayOfWeek += 7;
}
let region = getRegion(locale);
// Use Intl.Locale for this once weekInfo is supported.
// https://github.com/tc39/proposal-intl-locale-info
let [start, end] = (region ? WEEKEND_DATA[region as keyof typeof WEEKEND_DATA] : undefined) || [
6, 0
];
return dayOfWeek === start || dayOfWeek === end;
}
/** Returns whether the given date is on a weekday in the given locale. */
export function isWeekday(date: DateValue, locale: string): boolean {
return !isWeekend(date, locale);
}
// ─── Domain types ─────────────────────────────────────────────────────────────
/** Union of all date+time value types. */
export type TimeValue = Time | CalendarDateTime | ZonedDateTime;
/** A date range with optional start and end. */
export type DateRange = {
start: DateValue | undefined;
end: DateValue | undefined;
};
/** A time range with optional start and end. */
export type TimeRange<T extends TimeValue = Time> = {
start: T | undefined;
end: T | undefined;
};
/** A predicate that returns true if the given date matches some criteria. */
export type DateMatcher = (date: DateValue) => boolean;
/**
* A function that returns validation errors for a date, or nothing if valid.
*/
export type DateValidator = (date: DateValue) => string[] | string | void;
/**
* A function that returns validation errors for a date range, or nothing if valid.
*/
export type DateRangeValidator = (range: {
start: DateValue;
end: DateValue;
}) => string[] | string | void;
/** A function that returns validation errors for a time value, or nothing if valid. */
export type TimeValidator<T extends TimeValue> = (time: T) => string[] | string | void;
/** A function that returns validation errors for a time range, or nothing if valid. */
export type TimeRangeValidator<T extends TimeValue = Time> = (range: {
start: T;
end: T;
}) => string[] | string | void;
/** Callback fired when a date value is determined to be invalid. */
export type DateOnInvalid = (reason: 'min' | 'max' | 'custom', msg?: string | string[]) => void;
/** Callback fired when a time value is determined to be invalid. */
export type TimeOnInvalid = (reason: 'min' | 'max' | 'custom', msg?: string | string[]) => void;
/**
* Represents a single month in a calendar grid.
* - `value` — the DateValue that identifies this month.
* - `weeks` — rows of dates (T[][]) for rendering a table grid.
* - `dates` — flat array of all dates shown in the grid.
*/
export type Month<T> = {
value: DateValue;
weeks: T[][];
dates: T[];
};
// ─── Comparison helpers ───────────────────────────────────────────────────────
function isBeforeOrSame(dateToCompare: DateValue, referenceDate: DateValue): boolean {
return dateToCompare.compare(referenceDate) <= 0;
}
function isAfterOrSame(dateToCompare: DateValue, referenceDate: DateValue): boolean {
return dateToCompare.compare(referenceDate) >= 0;
}
/** Returns whether `dateToCompare` is strictly before `referenceDate`. */
export function isBefore(dateToCompare: DateValue, referenceDate: DateValue): boolean {
return dateToCompare.compare(referenceDate) < 0;
}
/** Returns whether `dateToCompare` is strictly after `referenceDate`. */
export function isAfter(dateToCompare: DateValue, referenceDate: DateValue): boolean {
return dateToCompare.compare(referenceDate) > 0;
}
/** Returns whether `date` is inclusively between `start` and `end`. */
export function isBetweenInclusive(date: DateValue, start: DateValue, end: DateValue): boolean {
return isAfterOrSame(date, start) && isBeforeOrSame(date, end);
}
/** Type guard — returns true if `dateValue` is a `ZonedDateTime`. */
export function isZonedDateTime(dateValue: DateValue | TimeValue): dateValue is ZonedDateTime {
return dateValue instanceof ZonedDateTime;
}
/** Returns true if `dateValue` contains time component. */
export function hasTime(dateValue: DateValue): dateValue is CalendarDateTime | ZonedDateTime {
return dateValue instanceof CalendarDateTime || dateValue instanceof ZonedDateTime;
}
/**
* Returns the number of days in the month of the given date.
* Accepts a native `Date` or a `DateValue`.
*/
export function getDaysInMonth(date: Date | DateValue): number {
if (date instanceof Date) {
const year = date.getFullYear();
const month = date.getMonth() + 1;
return new Date(year, month, 0).getDate();
}
return date.set({ day: 100 }).day;
}
/** Returns the first day of the week that contains `date`. */
export function getLastFirstDayOfWeek<T extends DateValue = DateValue>(
date: T,
firstDayOfWeek: number,
locale: string
): T {
const day = getDayOfWeek(date, locale);
if (firstDayOfWeek > day) return date.subtract({ days: day + 7 - firstDayOfWeek }) as T;
if (firstDayOfWeek === day) return date as T;
return date.subtract({ days: day - firstDayOfWeek }) as T;
}
/** Returns the last day of the week that contains `date`. */
export function getNextLastDayOfWeek<T extends DateValue = DateValue>(
date: T,
firstDayOfWeek: number,
locale: string
): T {
const day = getDayOfWeek(date, locale);
const lastDayOfWeek = firstDayOfWeek === 0 ? 6 : firstDayOfWeek - 1;
if (day === lastDayOfWeek) return date as T;
if (day > lastDayOfWeek) return date.add({ days: 7 - day + lastDayOfWeek }) as T;
return date.add({ days: lastDayOfWeek - day }) as T;
}
/**
* Returns true if all days between `start` and `end` pass the provided
* `isUnavailable` and `isDisabled` matchers (or if no matchers are given).
*/
export function areAllDaysBetweenValid(
start: DateValue,
end: DateValue,
isUnavailable: DateMatcher | undefined,
isDisabled: DateMatcher | undefined
): boolean {
if (isUnavailable === undefined && isDisabled === undefined) return true;
let dCurrent = start.add({ days: 1 });
if (isDisabled?.(dCurrent) || isUnavailable?.(dCurrent)) return false;
const dEnd = end;
while (dCurrent.compare(dEnd) < 0) {
dCurrent = dCurrent.add({ days: 1 });
if (isDisabled?.(dCurrent) || isUnavailable?.(dCurrent)) return false;
}
return true;
}
// ─── Time comparison helpers ──────────────────────────────────────────────────
/** Returns whether `timeToCompare` is strictly before `referenceTime`. */
export function isTimeBefore(timeToCompare: Time, referenceTime: Time): boolean {
return timeToCompare.compare(referenceTime) < 0;
}
/** Returns whether `timeToCompare` is strictly after `referenceTime`. */
export function isTimeAfter(timeToCompare: Time, referenceTime: Time): boolean {
return timeToCompare.compare(referenceTime) > 0;
}

@ -0,0 +1,372 @@
/*
* Copyright 2020 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
import type { AnyDateTime, DateTimeDuration, Disambiguation } from '../core/types';
import { CalendarDate, CalendarDateTime, Time, ZonedDateTime } from '../core/CalendarDate';
import {
epochFromDate,
fromAbsolute,
possibleAbsolutes,
toAbsolute,
toCalendar,
toCalendarDateTime,
toTimeZone
} from './conversion';
import { getLocalTimeZone } from './queries';
import { GregorianCalendar } from '../calendars/GregorianCalendar';
import type { Mutable } from '../internal/utils';
const TIME_RE = /^(\d{2})(?::(\d{2}))?(?::(\d{2}))?(\.\d+)?$/;
const DATE_RE = /^([+-]\d{6}|\d{4})-(\d{2})-(\d{2})$/;
const DATE_TIME_RE =
/^([+-]\d{6}|\d{4})-(\d{2})-(\d{2})(?:T(\d{2}))?(?::(\d{2}))?(?::(\d{2}))?(\.\d+)?$/;
const ZONED_DATE_TIME_RE =
/^([+-]\d{6}|\d{4})-(\d{2})-(\d{2})(?:T(\d{2}))?(?::(\d{2}))?(?::(\d{2}))?(\.\d+)?(?:([+-]\d{2})(?::?(\d{2}))?(?::?(\d{2}))?)?\[(.*?)\]$/;
const ABSOLUTE_RE =
/^([+-]\d{6}|\d{4})-(\d{2})-(\d{2})(?:T(\d{2}))?(?::(\d{2}))?(?::(\d{2}))?(\.\d+)?(?:(?:([+-]\d{2})(?::?(\d{2}))?)|Z)$/;
const DATE_TIME_DURATION_RE =
/^((?<negative>-)|\+)?P((?<years>\d*)Y)?((?<months>\d*)M)?((?<weeks>\d*)W)?((?<days>\d*)D)?((?<time>T)((?<hours>\d*[.,]?\d{1,9})H)?((?<minutes>\d*[.,]?\d{1,9})M)?((?<seconds>\d*[.,]?\d{1,9})S)?)?$/;
const requiredDurationTimeGroups = ['hours', 'minutes', 'seconds'];
const requiredDurationGroups = ['years', 'months', 'weeks', 'days', ...requiredDurationTimeGroups];
/** Parses an ISO 8601 time string. */
export function parseTime(value: string): Time {
let m = value.match(TIME_RE);
if (!m) {
throw new Error('Invalid ISO 8601 time string: ' + value);
}
return new Time(
parseNumber(m[1], 0, 23),
m[2] ? parseNumber(m[2], 0, 59) : 0,
m[3] ? parseNumber(m[3], 0, 59) : 0,
m[4] ? parseNumber(m[4], 0, Infinity) * 1000 : 0
);
}
/** Parses an ISO 8601 date string, with no time component. */
export function parseDate(value: string): CalendarDate {
let m = value.match(DATE_RE);
if (!m) {
if (ABSOLUTE_RE.test(value)) {
throw new Error(`Invalid ISO 8601 date string: ${value}. Use parseAbsolute() instead.`);
}
throw new Error('Invalid ISO 8601 date string: ' + value);
}
let date: Mutable<CalendarDate> = new CalendarDate(
parseNumber(m[1], 0, 9999),
parseNumber(m[2], 1, 12),
1
);
date.day = parseNumber(m[3], 1, date.calendar.getDaysInMonth(date));
return date as CalendarDate;
}
/** Parses an ISO 8601 date and time string, with no time zone. */
export function parseDateTime(value: string): CalendarDateTime {
let m = value.match(DATE_TIME_RE);
if (!m) {
if (ABSOLUTE_RE.test(value)) {
throw new Error(`Invalid ISO 8601 date time string: ${value}. Use parseAbsolute() instead.`);
}
throw new Error('Invalid ISO 8601 date time string: ' + value);
}
let year = parseNumber(m[1], -9999, 9999);
let era = year < 1 ? 'BC' : 'AD';
let date: Mutable<CalendarDateTime> = new CalendarDateTime(
era,
year < 1 ? -year + 1 : year,
parseNumber(m[2], 1, 12),
1,
m[4] ? parseNumber(m[4], 0, 23) : 0,
m[5] ? parseNumber(m[5], 0, 59) : 0,
m[6] ? parseNumber(m[6], 0, 59) : 0,
m[7] ? parseNumber(m[7], 0, Infinity) * 1000 : 0
);
date.day = parseNumber(m[3], 0, date.calendar.getDaysInMonth(date));
return date as CalendarDateTime;
}
/**
* Parses an ISO 8601 date and time string with a time zone extension and optional UTC offset
* (e.g. "2021-11-07T00:45[America/Los_Angeles]" or "2021-11-07T00:45-07:00[America/Los_Angeles]").
* Ambiguous times due to daylight saving time transitions are resolved according to the `disambiguation`
* parameter.
*/
export function parseZonedDateTime(value: string, disambiguation?: Disambiguation): ZonedDateTime {
let m = value.match(ZONED_DATE_TIME_RE);
if (!m) {
throw new Error('Invalid ISO 8601 date time string: ' + value);
}
let year = parseNumber(m[1], -9999, 9999);
let era = year < 1 ? 'BC' : 'AD';
let date: Mutable<ZonedDateTime> = new ZonedDateTime(
era,
year < 1 ? -year + 1 : year,
parseNumber(m[2], 1, 12),
1,
m[11],
0,
m[4] ? parseNumber(m[4], 0, 23) : 0,
m[5] ? parseNumber(m[5], 0, 59) : 0,
m[6] ? parseNumber(m[6], 0, 59) : 0,
m[7] ? parseNumber(m[7], 0, Infinity) * 1000 : 0
);
date.day = parseNumber(m[3], 0, date.calendar.getDaysInMonth(date));
let plainDateTime = toCalendarDateTime(date as ZonedDateTime);
let ms: number;
if (m[8]) {
let hourOffset = parseNumber(m[8], -23, 23);
date.offset =
Math.sign(hourOffset) *
(Math.abs(hourOffset) * 60 * 60 * 1000 +
parseNumber(m[9] ?? '0', 0, 59) * 60 * 1000 +
parseNumber(m[10] ?? '0', 0, 59) * 1000);
ms = epochFromDate(date as ZonedDateTime) - date.offset;
// Validate offset against parsed date.
let absolutes = possibleAbsolutes(plainDateTime, date.timeZone);
if (!absolutes.includes(ms)) {
throw new Error(
`Offset ${offsetToString(date.offset)} is invalid for ${dateTimeToString(date)} in ${date.timeZone}`
);
}
} else {
// Convert to absolute and back to fix invalid times due to DST.
ms = toAbsolute(toCalendarDateTime(plainDateTime), date.timeZone, disambiguation);
}
return fromAbsolute(ms, date.timeZone);
}
/**
* Parses an ISO 8601 date and time string with a UTC offset (e.g. "2021-11-07T07:45:00Z"
* or "2021-11-07T07:45:00-07:00"). The result is converted to the provided time zone.
*/
export function parseAbsolute(value: string, timeZone: string): ZonedDateTime {
let m = value.match(ABSOLUTE_RE);
if (!m) {
throw new Error('Invalid ISO 8601 date time string: ' + value);
}
let year = parseNumber(m[1], -9999, 9999);
let era = year < 1 ? 'BC' : 'AD';
let date: Mutable<ZonedDateTime> = new ZonedDateTime(
era,
year < 1 ? -year + 1 : year,
parseNumber(m[2], 1, 12),
1,
timeZone,
0,
m[4] ? parseNumber(m[4], 0, 23) : 0,
m[5] ? parseNumber(m[5], 0, 59) : 0,
m[6] ? parseNumber(m[6], 0, 59) : 0,
m[7] ? parseNumber(m[7], 0, Infinity) * 1000 : 0
);
date.day = parseNumber(m[3], 0, date.calendar.getDaysInMonth(date));
if (m[8]) {
date.offset =
parseNumber(m[8], -23, 23) * 60 * 60 * 1000 + parseNumber(m[9] ?? '0', 0, 59) * 60 * 1000;
}
return toTimeZone(date as ZonedDateTime, timeZone);
}
/**
* Parses an ISO 8601 date and time string with a UTC offset (e.g. "2021-11-07T07:45:00Z"
* or "2021-11-07T07:45:00-07:00"). The result is converted to the user's local time zone.
*/
export function parseAbsoluteToLocal(value: string): ZonedDateTime {
return parseAbsolute(value, getLocalTimeZone());
}
function parseNumber(value: string, min: number, max: number) {
let val = Number(value);
if (val < min || val > max) {
throw new RangeError(`Value out of range: ${min} <= ${val} <= ${max}`);
}
return val;
}
export function timeToString(time: Time): string {
return `${String(time.hour).padStart(2, '0')}:${String(time.minute).padStart(2, '0')}:${String(time.second).padStart(2, '0')}${time.millisecond ? String(time.millisecond / 1000).slice(1) : ''}`;
}
export function dateToString(date: CalendarDate): string {
let gregorianDate = toCalendar(date, new GregorianCalendar());
let year: string;
if (gregorianDate.era === 'BC') {
year =
gregorianDate.year === 1
? '0000'
: '-' + String(Math.abs(1 - gregorianDate.year)).padStart(6, '00');
} else {
year = String(gregorianDate.year).padStart(4, '0');
}
return `${year}-${String(gregorianDate.month).padStart(2, '0')}-${String(gregorianDate.day).padStart(2, '0')}`;
}
export function dateTimeToString(date: AnyDateTime): string {
// @ts-ignore
return `${dateToString(date)}T${timeToString(date)}`;
}
function offsetToString(offset: number) {
let sign = Math.sign(offset) < 0 ? '-' : '+';
offset = Math.abs(offset);
let offsetHours = Math.floor(offset / (60 * 60 * 1000));
let offsetMinutes = Math.floor((offset % (60 * 60 * 1000)) / (60 * 1000));
let offsetSeconds = Math.floor(((offset % (60 * 60 * 1000)) % (60 * 1000)) / 1000);
let stringOffset = `${sign}${String(offsetHours).padStart(2, '0')}:${String(offsetMinutes).padStart(2, '0')}`;
if (offsetSeconds !== 0) {
stringOffset += `:${String(offsetSeconds).padStart(2, '0')}`;
}
return stringOffset;
}
export function zonedDateTimeToString(date: ZonedDateTime): string {
return `${dateTimeToString(date)}${offsetToString(date.offset)}[${date.timeZone}]`;
}
/**
* Parses an ISO 8601 duration string (e.g. "P3Y6M6W4DT12H30M5S").
* @param value An ISO 8601 duration string.
* @returns A DateTimeDuration object.
*/
export function parseDuration(value: string): Required<DateTimeDuration> {
const match = value.match(DATE_TIME_DURATION_RE);
if (!match) {
throw new Error(`Invalid ISO 8601 Duration string: ${value}`);
}
const parseDurationGroup = (group: string | undefined, isNegative: boolean): number => {
if (!group) {
return 0;
}
try {
const sign = isNegative ? -1 : 1;
return sign * Number(group.replace(',', '.'));
} catch {
throw new Error(`Invalid ISO 8601 Duration string: ${value}`);
}
};
const isNegative = !!match.groups?.negative;
const hasRequiredGroups = requiredDurationGroups.some((group) => match.groups?.[group]);
if (!hasRequiredGroups) {
throw new Error(`Invalid ISO 8601 Duration string: ${value}`);
}
const durationStringIncludesTime = match.groups?.time;
if (durationStringIncludesTime) {
const hasRequiredDurationTimeGroups = requiredDurationTimeGroups.some(
(group) => match.groups?.[group]
);
if (!hasRequiredDurationTimeGroups) {
throw new Error(`Invalid ISO 8601 Duration string: ${value}`);
}
}
const duration: Mutable<DateTimeDuration> = {
years: parseDurationGroup(match.groups?.years, isNegative),
months: parseDurationGroup(match.groups?.months, isNegative),
weeks: parseDurationGroup(match.groups?.weeks, isNegative),
days: parseDurationGroup(match.groups?.days, isNegative),
hours: parseDurationGroup(match.groups?.hours, isNegative),
minutes: parseDurationGroup(match.groups?.minutes, isNegative),
seconds: parseDurationGroup(match.groups?.seconds, isNegative)
};
if (
duration.hours !== undefined &&
duration.hours % 1 !== 0 &&
(duration.minutes || duration.seconds)
) {
throw new Error(
`Invalid ISO 8601 Duration string: ${value} - only the smallest unit can be fractional`
);
}
if (duration.minutes !== undefined && duration.minutes % 1 !== 0 && duration.seconds) {
throw new Error(
`Invalid ISO 8601 Duration string: ${value} - only the smallest unit can be fractional`
);
}
return duration as Required<DateTimeDuration>;
}
// ─── DateValue string utilities ───────────────────────────────────────────────
import type { DateValue } from './queries';
/** Returns a string identifier for the concrete type of a `DateValue`. */
export function getDateValueType(date: DateValue): 'date' | 'datetime' | 'zoneddatetime' {
if (date instanceof CalendarDate) return 'date';
if (date instanceof CalendarDateTime) return 'datetime';
if (date instanceof ZonedDateTime) return 'zoneddatetime';
throw new Error('Unknown DateValue type');
}
/**
* Parses an ISO 8601 string to the same concrete type as `referenceVal`,
* and converts the result to the same calendar system.
*/
export function parseStringToDateValue(dateStr: string, referenceVal: DateValue): DateValue {
let dateValue: DateValue;
if (referenceVal instanceof ZonedDateTime) dateValue = parseZonedDateTime(dateStr);
else if (referenceVal instanceof CalendarDateTime) dateValue = parseDateTime(dateStr);
else dateValue = parseDate(dateStr);
return dateValue.calendar !== referenceVal.calendar
? toCalendar(dateValue, referenceVal.calendar)
: dateValue;
}
/**
* Parses an ISO 8601 string to the `DateValue` type identified by `type`
* (as returned by `getDateValueType`).
*/
export function parseAnyDateValue(
value: string,
type: 'date' | 'datetime' | 'zoneddatetime'
): DateValue {
switch (type) {
case 'date':
return parseDate(value);
case 'datetime':
return parseDateTime(value);
case 'zoneddatetime':
return parseZonedDateTime(value);
default:
throw new Error(`Unknown DateValue type: ${type}`);
}
}

@ -0,0 +1,25 @@
/**
* Calendar systems. Consumers import a specific calendar when they need to
* construct dates outside the default Gregorian system, or use
* `createCalendar(identifier)` for dynamic resolution.
*/
export { GregorianCalendar } from './_vendor/calendars/GregorianCalendar';
export { JapaneseCalendar } from './_vendor/calendars/JapaneseCalendar';
export { BuddhistCalendar } from './_vendor/calendars/BuddhistCalendar';
export { TaiwanCalendar } from './_vendor/calendars/TaiwanCalendar';
export { PersianCalendar } from './_vendor/calendars/PersianCalendar';
export { IndianCalendar } from './_vendor/calendars/IndianCalendar';
export {
IslamicCivilCalendar,
IslamicTabularCalendar,
IslamicUmalquraCalendar
} from './_vendor/calendars/IslamicCalendar';
export { HebrewCalendar } from './_vendor/calendars/HebrewCalendar';
export {
EthiopicCalendar,
EthiopicAmeteAlemCalendar,
CopticCalendar
} from './_vendor/calendars/EthiopicCalendar';
export { createCalendar } from './_vendor/core/createCalendar';

@ -0,0 +1,87 @@
/**
* All date-related `Intl` lives here. Consumers that format, resolve a
* locale-default preference or read a placeholder import from this module.
* The `DateFormatter` class wraps `Intl.DateTimeFormat` with a cache so
* recurrent calls with the same options are cheap.
*
* `ext/dates` (the reactive App service) is a thin layer on top of this
* module: it owns reactive preferences and locale subscriptions, but
* delegates every actual formatting call here.
*/
import type { DateOrder, HourCycle } from './_vendor/core/types';
export { DateFormatter } from './_vendor/format/DateFormatter';
export { getPlaceholder } from './_vendor/format/placeholders';
export {
getDefaultDate,
getDefaultTime,
inferGranularity,
inferTimeGranularity,
getDefaultHourCycle,
DEFAULT_TIME_FORMAT,
DEFAULT_DATE_ORDER
} from './_vendor/format/defaults';
export { timeFormatToHourCycle } from './_vendor/internal/utils';
// ── Locale-derived defaults ─────────────────────────────────────────────────
//
// Previously scattered under `ext/dates/locale-defaults.ts`. Moved here so
// every date-related `Intl` hint lives in one place. These are pure
// functions: no state, no reactivity. The App service (`ext/dates`) is
// what makes them reactive by re-evaluating when the locale changes.
const MDY_LOCALES = new Set(['en-US', 'en-PH', 'en-BZ', 'en-MH', 'en-FM']);
const YMD_BASES = new Set(['zh', 'ja', 'ko', 'hu', 'lt', 'fa']);
const H12_BASES = new Set(['en', 'ko', 'hi', 'bn']);
/**
* Best-effort mapping of a BCP 47 locale to the dominant date-field order
* used in that locale. Returns `'DMY'` by default because it is the most
* common worldwide. Override via user preference when needed.
*/
export function resolveDateOrder(locale: string): DateOrder {
if (MDY_LOCALES.has(locale)) return 'MDY';
const base = locale.split('-')[0].toLowerCase();
if (YMD_BASES.has(base)) return 'YMD';
return 'DMY';
}
/**
* Best-effort mapping of a BCP 47 locale to the dominant clock convention.
* Returns `24` by default; override via user preference when needed.
*
* Uses the canonical numeric `HourCycle = 12 | 24` that matches
* `Intl.DateTimeFormat`'s `hour12` resolved option.
*/
export function resolveHourCycle(locale: string): HourCycle {
if (locale === 'en-GB') return 24;
const base = locale.split('-')[0].toLowerCase();
return H12_BASES.has(base) ? 12 : 24;
}
// ── Intl.DateTimeFormat cache ───────────────────────────────────────────────
//
// Shared across the framework. `new Intl.DateTimeFormat(...)` is slow enough
// that memoising by locale+options is worthwhile when the same combination is
// used repeatedly (e.g. rendering a calendar grid month after month).
const formatCache = new Map<string, Intl.DateTimeFormat>();
/**
* Returns a cached `Intl.DateTimeFormat` for the given locale and options.
* Cache key is the locale plus the JSON-serialised options. Callers that
* pass identical options on every call will hit the cache.
*/
export function getCachedDateFormat(
locale: string,
options?: Intl.DateTimeFormatOptions
): Intl.DateTimeFormat {
const key = `${locale}:${JSON.stringify(options ?? {})}`;
let fmt = formatCache.get(key);
if (!fmt) {
fmt = new Intl.DateTimeFormat(locale || undefined, options);
formatCache.set(key, fmt);
}
return fmt;
}

@ -0,0 +1,21 @@
/**
* # dias — date & time library
*
* Single public entry point. Consumers should import from `$lib/util/dias`
* (or `@/util/dias`) and nothing else. The `_vendor/` folder is internal:
* it holds the vendored implementation under its original Apache 2.0
* licence and must not be referenced by user code.
*
* The surface is split into small categorised modules (`types`, `values`,
* `calendars`, `queries`, `operations`, `parse`, `format`) that are all
* re-exported here. Import the categorised module when you only need one
* slice; import from the index when you want everything.
*/
export * from './types';
export * from './values';
export * from './calendars';
export * from './queries';
export * from './operations';
export * from './parse';
export * from './format';

@ -0,0 +1,24 @@
/**
* Conversions between value types (calendar ↔ calendar, with/without time,
* with/without zone) and construction from JS `Date`.
*
* Arithmetic (`add`, `subtract`, `cycle`, `set`) lives on the value classes
* themselves — see `values.ts`.
*/
export {
toCalendarDate,
toCalendarDateTime,
toTime,
toCalendar,
toZoned,
toTimeZone,
toLocalTimeZone,
fromDate,
fromDateToLocal,
fromAbsolute,
dateValueToDate,
convertTimeValueToDateValue,
convertTimeValueToTime,
getISOTimeValue
} from './_vendor/operations/conversion';

@ -0,0 +1,22 @@
/**
* String → value parsers. Input format is ISO 8601 for `parseDate`,
* `parseDateTime`, `parseTime`, `parseAbsolute`, `parseZonedDateTime`,
* and a subset of ISO 8601 duration notation for `parseDuration`.
*
* The generic helpers (`parseStringToDateValue`, `parseAnyDateValue`,
* `getDateValueType`) accept multiple shapes and are useful when the caller
* does not know the granularity in advance.
*/
export {
parseDate,
parseDateTime,
parseTime,
parseAbsolute,
parseAbsoluteToLocal,
parseZonedDateTime,
parseDuration,
parseStringToDateValue,
parseAnyDateValue,
getDateValueType
} from './_vendor/operations/string';

@ -0,0 +1,46 @@
/**
* Non-mutating queries over date/time values: equality, week math, bounds
* checks, "is weekend", "today", etc. Every function is pure.
*/
export {
isSameDay,
isSameMonth,
isSameYear,
isEqualDay,
isEqualMonth,
isEqualYear,
isEqualCalendar,
isToday,
isWeekend,
isWeekday,
isBefore,
isAfter,
isBetweenInclusive,
isTimeBefore,
isTimeAfter,
isZonedDateTime,
hasTime,
getDayOfWeek,
getHoursInDay,
getDaysInMonth,
getWeeksInMonth,
getMinimumMonthInYear,
getMinimumDayInMonth,
getLastFirstDayOfWeek,
getNextLastDayOfWeek,
startOfMonth,
startOfWeek,
startOfYear,
endOfMonth,
endOfWeek,
endOfYear,
minDate,
maxDate,
areAllDaysBetweenValid,
now,
today,
getLocalTimeZone,
setLocalTimeZone,
resetLocalTimeZone
} from './_vendor/operations/queries';

@ -0,0 +1,46 @@
/**
* Public type surface of `dias`.
*
* Every type consumers need lives here. Nothing in this file touches the
* vendored implementation beyond re-exporting declarations. If a type needs
* to be added or reshaped for the public API, do it here — never edit
* `_vendor/` for API reasons.
*/
export type {
AnyCalendarDate,
AnyTime,
AnyDateTime,
Calendar,
CalendarIdentifier,
DateDuration,
TimeDuration,
DateTimeDuration,
DateFields,
TimeFields,
DateField,
TimeField,
Disambiguation,
CycleOptions,
CycleTimeOptions,
Granularity,
TimeGranularity,
HourCycle,
WeekStartsOn,
DateOrder
} from './_vendor/core/types';
export type {
DateValue,
TimeValue,
DateRange,
TimeRange,
DateMatcher,
DateValidator,
DateRangeValidator,
TimeValidator,
TimeRangeValidator,
DateOnInvalid,
TimeOnInvalid,
Month
} from './_vendor/operations/queries';

@ -0,0 +1,8 @@
/**
* Immutable value classes: `CalendarDate`, `CalendarDateTime`, `Time`,
* `ZonedDateTime`. Instances are the canonical currency across `dias` —
* queries take them as input, operations return new ones, formatters
* consume them.
*/
export { CalendarDate, CalendarDateTime, Time, ZonedDateTime } from './_vendor/core/CalendarDate';

@ -4,8 +4,8 @@
import type { LingString } from '$lib/ling/lib/types';
import type { AirDemoContext } from '../demo-context';
import { AIR_DEMO_CONTEXT_KEY } from '../demo-context';
import type { DateOrder, DateValue } from '$lib/util/dates';
import { CalendarDate, getLocalTimeZone, today } from '$lib/util/dates';
import type { DateOrder, DateValue } from '$lib/util/dias';
import { CalendarDate, getLocalTimeZone, today } from '$lib/util/dias';
const demoContext = getContext<AirDemoContext>(AIR_DEMO_CONTEXT_KEY);
const tv = (value: LingString) => demoContext?.tv(value) ?? String(value ?? '');
@ -72,16 +72,20 @@
<header class="hero">
<div class="hero-copy">
<p class="eyebrow">Air / Calendar</p>
<h1>{tv({
es: 'Calendario headless de terra con una shell visual de air lista para componer.',
en: 'Terra headless calendar with an air visual shell ready to compose.',
ar: 'تقويم headless من terra مع غلاف بصري من air جاهز للتركيب.'
})}</h1>
<p class="lead">{tv({
es: 'Calendar sí merece quedarse en terra: resuelve navegación, grid semántica, locale, date order y selección reusable. Air aporta jerarquía visual, densidad y anatomía estable para el dominio de fechas.',
en: 'Calendar should stay in terra: it solves navigation, semantic grid, locale, date order and reusable selection. Air adds visual hierarchy, density and a stable anatomy for the date domain.',
ar: 'يستحق Calendar البقاء في terra لأنه يحل التنقل والشبكة الدلالية واللغة وترتيب التاريخ والاختيار القابل لإعادة الاستخدام. وتضيف air التسلسل البصري والكثافة وبنية ثابتة لمجال التواريخ.'
})}</p>
<h1>
{tv({
es: 'Calendario headless de terra con una shell visual de air lista para componer.',
en: 'Terra headless calendar with an air visual shell ready to compose.',
ar: 'تقويم headless من terra مع غلاف بصري من air جاهز للتركيب.'
})}
</h1>
<p class="lead">
{tv({
es: 'Calendar sí merece quedarse en terra: resuelve navegación, grid semántica, locale, date order y selección reusable. Air aporta jerarquía visual, densidad y anatomía estable para el dominio de fechas.',
en: 'Calendar should stay in terra: it solves navigation, semantic grid, locale, date order and reusable selection. Air adds visual hierarchy, density and a stable anatomy for the date domain.',
ar: 'يستحق Calendar البقاء في terra لأنه يحل التنقل والشبكة الدلالية واللغة وترتيب التاريخ والاختيار القابل لإعادة الاستخدام. وتضيف air التسلسل البصري والكثافة وبنية ثابتة لمجال التواريخ.'
})}
</p>
</div>
<div class="control-strip">
@ -123,11 +127,13 @@
<section class="card">
<header class="section-header">
<h2>{tv({ es: 'Single', en: 'Single', ar: 'مفرد' })}</h2>
<p>{tv({
es: 'La composición mínima: navegación lateral, heading y grilla mensual con estado de hoy/selected.',
en: 'The minimal composition: side navigation, heading and monthly grid with today/selected state.',
ar: 'أبسط تركيب: تنقل جانبي وعنوان وشبكة شهرية مع حالتي اليوم والاختيار.'
})}</p>
<p>
{tv({
es: 'La composición mínima: navegación lateral, heading y grilla mensual con estado de hoy/selected.',
en: 'The minimal composition: side navigation, heading and monthly grid with today/selected state.',
ar: 'أبسط تركيب: تنقل جانبي وعنوان وشبكة شهرية مع حالتي اليوم والاختيار.'
})}
</p>
</header>
<Calendar.Provider type="single" bind:value={singleValue} {locale} {dateOrder}>
@ -171,12 +177,20 @@
<section class="card">
<header class="section-header">
<h2>{tv({ es: 'Múltiple + navegación compuesta', en: 'Multiple + composed nav', ar: 'متعدد + تنقل مركب' })}</h2>
<p>{tv({
es: 'Header con MonthSelect y YearSelect. La piel queda lista para DatePicker y DateRangePicker más adelante.',
en: 'Header with MonthSelect and YearSelect. This skin is ready for DatePicker and DateRangePicker later on.',
ar: 'رأس مع MonthSelect و YearSelect. هذا الغلاف جاهز لـ DatePicker و DateRangePicker لاحقاً.'
})}</p>
<h2>
{tv({
es: 'Múltiple + navegación compuesta',
en: 'Multiple + composed nav',
ar: 'متعدد + تنقل مركب'
})}
</h2>
<p>
{tv({
es: 'Header con MonthSelect y YearSelect. La piel queda lista para DatePicker y DateRangePicker más adelante.',
en: 'Header with MonthSelect and YearSelect. This skin is ready for DatePicker and DateRangePicker later on.',
ar: 'رأس مع MonthSelect و YearSelect. هذا الغلاف جاهز لـ DatePicker و DateRangePicker لاحقاً.'
})}
</p>
</header>
<Calendar.Provider
@ -240,11 +254,13 @@
<section class="card">
<header class="section-header">
<h2>{tv({ es: 'Restricciones', en: 'Constraints', ar: 'القيود' })}</h2>
<p>{tv({
es: 'Min/max, fines de semana deshabilitados y días no disponibles, ya con el lenguaje visual de air.',
en: 'Min/max, disabled weekends and unavailable dates, already with the air visual language.',
ar: 'الحد الأدنى والأقصى وعطلات نهاية الأسبوع المعطلة والتواريخ غير المتاحة، وكل ذلك بلغة air البصرية.'
})}</p>
<p>
{tv({
es: 'Min/max, fines de semana deshabilitados y días no disponibles, ya con el lenguaje visual de air.',
en: 'Min/max, disabled weekends and unavailable dates, already with the air visual language.',
ar: 'الحد الأدنى والأقصى وعطلات نهاية الأسبوع المعطلة والتواريخ غير المتاحة، وكل ذلك بلغة air البصرية.'
})}
</p>
</header>
<Calendar.Provider

@ -2,17 +2,17 @@
import { TestLayout, StateItem, PropsTable, DemoSection } from '../_components';
import { Terra, Calendar } from '$uix/terra';
import { createTerraTranslator, createTerraDateTimeFormatter } from '$uix/terra/system';
import type { DateOrder } from '$lib/util/dates';
import type { DateValue } from '$lib/util/dates';
import { today, getLocalTimeZone, CalendarDate, isSameDay } from '$lib/util/dates';
import type { DateOrder } from '$lib/util/dias';
import type { DateValue } from '$lib/util/dias';
import { today, getLocalTimeZone, CalendarDate, isSameDay } from '$lib/util/dias';
// Estado
let singleValue = $state<DateValue | undefined>(undefined);
let multipleValues = $state<DateValue[]>([]);
let disabledValue = $state<DateValue | undefined>(undefined);
let rangeValue = $state<DateValue | undefined>(undefined);
let twoMonthValue = $state<DateValue | undefined>(undefined);
let configValue = $state<DateValue | undefined>(undefined);
let singleValue = $state<DateValue | undefined>(undefined);
let multipleValues = $state<DateValue[]>([]);
let disabledValue = $state<DateValue | undefined>(undefined);
let rangeValue = $state<DateValue | undefined>(undefined);
let twoMonthValue = $state<DateValue | undefined>(undefined);
let configValue = $state<DateValue | undefined>(undefined);
const todayDate = today(getLocalTimeZone());
@ -23,16 +23,16 @@
{ id: 'en-GB', label: 'en-GB' },
{ id: 'de-DE', label: 'de-DE' },
{ id: 'fr-FR', label: 'fr-FR' },
{ id: 'ja-JP', label: 'ja-JP' },
{ id: 'ja-JP', label: 'ja-JP' }
];
let locale = $state('es-ES');
const translator = createTerraTranslator({
locale: () => locale,
locale: () => locale
});
const DATE_ORDERS: DateOrder[] = ['DMY', 'MDY', 'YMD'];
let dateOrder = $state<DateOrder>('DMY');
const dateTimeFormatter = createTerraDateTimeFormatter({
dateOrder: () => dateOrder,
dateOrder: () => dateOrder
});
// Disabled functions
@ -43,21 +43,26 @@
}
function isUnavailable(date: DateValue): boolean {
return date.month === todayDate.month &&
return (
date.month === todayDate.month &&
date.year === todayDate.year &&
date.day >= 10 && date.day <= 15;
date.day >= 10 &&
date.day <= 15
);
}
// Range helpers
const minDate = todayDate.subtract({ months: 1 });
const maxDate = todayDate.add({ months: 3 });
let rangeStart = $state<DateValue | undefined>(undefined);
let rangeEnd = $state<DateValue | undefined>(undefined);
let rangeStart = $state<DateValue | undefined>(undefined);
let rangeEnd = $state<DateValue | undefined>(undefined);
let rangeStartPlaceholder = $state<DateValue>(todayDate);
let rangeEndPlaceholder = $state<DateValue>(todayDate);
let rangeEndPlaceholder = $state<DateValue>(todayDate);
function monthIdx(d: DateValue) { return d.year * 12 + d.month; }
function monthIdx(d: DateValue) {
return d.year * 12 + d.month;
}
function onRangeStartPlaceholderChange(p: DateValue) {
rangeStartPlaceholder = p;
@ -75,13 +80,13 @@
function rangeAttrs(date: DateValue) {
const isStart = !!rangeStart && isSameDay(date, rangeStart);
const isEnd = !!rangeEnd && isSameDay(date, rangeEnd);
const inside = !!rangeStart && !!rangeEnd
&& date.compare(rangeStart) > 0 && date.compare(rangeEnd) < 0;
const isEnd = !!rangeEnd && isSameDay(date, rangeEnd);
const inside =
!!rangeStart && !!rangeEnd && date.compare(rangeStart) > 0 && date.compare(rangeEnd) < 0;
return {
"data-range-start" : isStart ? "" : undefined,
"data-range-end" : isEnd ? "" : undefined,
"data-in-range" : inside ? "" : undefined,
'data-range-start': isStart ? '' : undefined,
'data-range-end': isEnd ? '' : undefined,
'data-in-range': inside ? '' : undefined
};
}
@ -96,35 +101,72 @@
const preSelected: DateValue[] = [
new CalendarDate(todayDate.year, todayDate.month, 3),
new CalendarDate(todayDate.year, todayDate.month, 7),
new CalendarDate(todayDate.year, todayDate.month, 14),
new CalendarDate(todayDate.year, todayDate.month, 14)
];
let multiPre = $state<DateValue[]>(preSelected);
// Format helpers
function fmt(v: DateValue | undefined) {
if (!v) return "—";
return `${v.year}-${String(v.month).padStart(2,'0')}-${String(v.day).padStart(2,'0')}`;
if (!v) return '—';
return `${v.year}-${String(v.month).padStart(2, '0')}-${String(v.day).padStart(2, '0')}`;
}
function fmtArr(arr: DateValue[]) {
if (!arr.length) return "—";
return arr.map(fmt).join(", ");
if (!arr.length) return '—';
return arr.map(fmt).join(', ');
}
const calendarRootProps = [
{ name: 'type', type: '"single" | "multiple"', default: '"single"', description: 'Tipo de selección' },
{ name: 'value', type: 'DateValue | DateValue[]', default: 'undefined', description: 'Valor seleccionado (bind:value)' },
{ name: 'locale', type: 'string', default: 'undefined', description: 'Locale para formato de fecha' },
{ name: 'dateOrder', type: 'DateOrder', default: "'DMY'", description: 'Orden de componentes de fecha' },
{ name: 'numberOfMonths', type: 'number', default: '1', description: 'Número de meses visibles' },
{ name: 'minValue', type: 'DateValue', default: 'undefined', description: 'Fecha mínima seleccionable' },
{ name: 'maxValue', type: 'DateValue', default: 'undefined', description: 'Fecha máxima seleccionable' },
{ name: 'isDateDisabled', type: '(date: DateValue) => boolean', default: 'undefined', description: 'Función para deshabilitar fechas' },
{
name: 'type',
type: '"single" | "multiple"',
default: '"single"',
description: 'Tipo de selección'
},
{
name: 'value',
type: 'DateValue | DateValue[]',
default: 'undefined',
description: 'Valor seleccionado (bind:value)'
},
{
name: 'locale',
type: 'string',
default: 'undefined',
description: 'Locale para formato de fecha'
},
{
name: 'dateOrder',
type: 'DateOrder',
default: "'DMY'",
description: 'Orden de componentes de fecha'
},
{
name: 'numberOfMonths',
type: 'number',
default: '1',
description: 'Número de meses visibles'
},
{
name: 'minValue',
type: 'DateValue',
default: 'undefined',
description: 'Fecha mínima seleccionable'
},
{
name: 'maxValue',
type: 'DateValue',
default: 'undefined',
description: 'Fecha máxima seleccionable'
},
{
name: 'isDateDisabled',
type: '(date: DateValue) => boolean',
default: 'undefined',
description: 'Función para deshabilitar fechas'
}
];
const breadcrumbs = [
{ label: 'Test', href: '/test' },
{ label: 'Calendar' }
];
const breadcrumbs = [{ label: 'Test', href: '/test' }, { label: 'Calendar' }];
</script>
<svelte:head>
@ -140,8 +182,8 @@
<button
class="control-btn"
class:control-btn--active={locale === loc.id}
onclick={() => (locale = loc.id)}
>{loc.label}</button>
onclick={() => (locale = loc.id)}>{loc.label}</button
>
{/each}
</div>
<div class="control-bar">
@ -150,15 +192,14 @@
<button
class="control-btn"
class:control-btn--active={dateOrder === order}
onclick={() => (dateOrder = order)}
>{order}</button>
onclick={() => (dateOrder = order)}>{order}</button
>
{/each}
</div>
</div>
<!-- Grid -->
<main class="calendar-grid">
<!-- Single -->
<section class="calendar-card">
<header class="card-header">
@ -208,10 +249,19 @@
<section class="calendar-card">
<header class="card-header">
<h2>Multiple — preseleccionado</h2>
<p>Selección de múltiples fechas. Máximo 5 días. Pre-cargado con 3 fechas del mes actual.</p>
<p>
Selección de múltiples fechas. Máximo 5 días. Pre-cargado con 3 fechas del mes actual.
</p>
</header>
<Calendar.Provider type="multiple" bind:value={multiPre} maxDays={5} {locale} {dateOrder} class="cal">
<Calendar.Provider
type="multiple"
bind:value={multiPre}
maxDays={5}
{locale}
{dateOrder}
class="cal"
>
{#snippet children({ months, weekdays })}
<Calendar.Header class="cal-hd">
<Calendar.PrevButton class="cal-btn">‹</Calendar.PrevButton>
@ -253,7 +303,10 @@
<section class="calendar-card">
<header class="card-header">
<h2>minValue / maxValue + isDateDisabled</h2>
<p>Rango permitido: ±1 mes. Fines de semana deshabilitados. Días 10–15 marcados como no disponibles.</p>
<p>
Rango permitido: ±1 mes. Fines de semana deshabilitados. Días 10–15 marcados como no
disponibles.
</p>
</header>
<Calendar.Provider
@ -397,7 +450,12 @@
{#each month.weeks as week}
<Calendar.GridRow class="cal-tr">
{#each week as date}
<Calendar.Cell {date} month={month.value} class="cal-td" {...rangeAttrs(date)}>
<Calendar.Cell
{date}
month={month.value}
class="cal-td"
{...rangeAttrs(date)}
>
<Calendar.Day class="cal-day" />
</Calendar.Cell>
{/each}
@ -449,7 +507,12 @@
{#each month.weeks as week}
<Calendar.GridRow class="cal-tr">
{#each week as date}
<Calendar.Cell {date} month={month.value} class="cal-td" {...rangeAttrs(date)}>
<Calendar.Cell
{date}
month={month.value}
class="cal-td"
{...rangeAttrs(date)}
>
<Calendar.Day class="cal-day" />
</Calendar.Cell>
{/each}
@ -523,7 +586,6 @@
<span class="val-val">{fmt(configValue)}</span>
</div>
</section>
</main>
<!-- State Panel -->
@ -725,7 +787,7 @@
transition: all var(--transition-fast);
}
:global(.cal-day:hover:not([aria-disabled="true"]):not([data-outside-visible-months])) {
:global(.cal-day:hover:not([aria-disabled='true']):not([data-outside-visible-months])) {
background: var(--color-surface-elevated);
}
@ -746,7 +808,7 @@
}
:global(.cal-day[data-disabled]),
:global(.cal-day[aria-disabled="true"]) {
:global(.cal-day[aria-disabled='true']) {
color: var(--color-text-dim);
cursor: not-allowed;
pointer-events: none;

@ -2,7 +2,13 @@
import { TestLayout, StateItem, PropsTable, DemoSection } from '../_components';
import { Terra, DatePicker } from '$uix/terra';
import { createTerraTranslator, createTerraDateTimeFormatter } from '$uix/terra/system';
import { CalendarDate, type DateValue, getLocalTimeZone, today, type DateOrder } from '$lib/util/dates';
import {
CalendarDate,
type DateValue,
getLocalTimeZone,
today,
type DateOrder
} from '$lib/util/dias';
const tz = getLocalTimeZone();
const now = today(tz);
@ -10,20 +16,22 @@
const LOCALES = [
{ id: 'es-ES', label: 'es-ES' },
{ id: 'en-US', label: 'en-US' },
{ id: 'de-DE', label: 'de-DE' },
{ id: 'de-DE', label: 'de-DE' }
];
const ORDERS: DateOrder[] = ['DMY', 'MDY', 'YMD'];
let locale = $state('es-ES');
const translator = createTerraTranslator({
locale: () => locale,
locale: () => locale
});
let dateOrder = $state<DateOrder>('DMY');
const dateTimeFormatter = createTerraDateTimeFormatter({
dateOrder: () => dateOrder,
dateOrder: () => dateOrder
});
let value1 = $state<DateValue | undefined>(new CalendarDate(now.year, now.month, now.day));
let value2 = $state<DateValue | undefined>(new CalendarDate(now.year, now.month, Math.min(now.day + 3, 28)));
let value2 = $state<DateValue | undefined>(
new CalendarDate(now.year, now.month, Math.min(now.day + 3, 28))
);
let value3 = $state<DateValue | undefined>(undefined);
let open3 = $state(false);
@ -33,17 +41,39 @@
}
const datePickerProps = [
{ name: 'locale', type: 'string', default: '"es-ES"', description: 'Locale para formato de fechas' },
{ name: 'dateOrder', type: 'DateOrder', default: '"DMY"', description: 'Orden de fecha: DMY, MDY, YMD' },
{ name: 'numberOfMonths', type: 'number', default: '1', description: 'Número de meses visibles en el popup' },
{ name: 'open', type: 'boolean', default: 'undefined', description: 'Control del estado abierto/cerrado (bind:open)' },
{ name: 'defaultOpen', type: 'boolean', default: 'false', description: 'Estado inicial abierto' },
{
name: 'locale',
type: 'string',
default: '"es-ES"',
description: 'Locale para formato de fechas'
},
{
name: 'dateOrder',
type: 'DateOrder',
default: '"DMY"',
description: 'Orden de fecha: DMY, MDY, YMD'
},
{
name: 'numberOfMonths',
type: 'number',
default: '1',
description: 'Número de meses visibles en el popup'
},
{
name: 'open',
type: 'boolean',
default: 'undefined',
description: 'Control del estado abierto/cerrado (bind:open)'
},
{
name: 'defaultOpen',
type: 'boolean',
default: 'false',
description: 'Estado inicial abierto'
}
];
const breadcrumbs = [
{ label: 'Test', href: '/test' },
{ label: 'DatePicker' }
];
const breadcrumbs = [{ label: 'Test', href: '/test' }, { label: 'DatePicker' }];
</script>
<TestLayout
@ -57,7 +87,10 @@
<div class="locale-bar">
<span class="locale-label">Locale:</span>
{#each LOCALES as option}
<button class="locale-btn {locale === option.id ? 'locale-btn--active' : ''}" onclick={() => (locale = option.id)}>
<button
class="locale-btn {locale === option.id ? 'locale-btn--active' : ''}"
onclick={() => (locale = option.id)}
>
{option.label}
</button>
{/each}
@ -66,7 +99,10 @@
<div class="locale-bar">
<span class="locale-label">Date order:</span>
{#each ORDERS as order}
<button class="locale-btn {dateOrder === order ? 'locale-btn--active' : ''}" onclick={() => (dateOrder = order)}>
<button
class="locale-btn {dateOrder === order ? 'locale-btn--active' : ''}"
onclick={() => (dateOrder = order)}
>
{order}
</button>
{/each}
@ -353,7 +389,7 @@
cursor: pointer;
}
:global(.dp-seg[data-type="literal"]) {
:global(.dp-seg[data-type='literal']) {
min-width: auto;
padding: 0;
color: var(--color-text-dim);

@ -2,11 +2,11 @@
import { TestLayout, StateItem, PropsTable, DemoSection } from '../_components';
import { Terra, DateRangeField } from '$uix/terra';
import { createTerraTranslator, createTerraDateTimeFormatter } from '$uix/terra/system';
import type { DateOrder, HourCycle } from '$lib/util/dates';
import type { DateValue } from '$lib/util/dates';
import { today, getLocalTimeZone, CalendarDate } from '$lib/util/dates';
import type { DateOrder, HourCycle } from '$lib/util/dias';
import type { DateValue } from '$lib/util/dias';
import { today, getLocalTimeZone, CalendarDate } from '$lib/util/dias';
const tz = getLocalTimeZone();
const tz = getLocalTimeZone();
const now = today(tz);
// Config
@ -16,63 +16,101 @@
{ id: 'en-GB', label: 'en-GB', hint: 'DD/MM/YYYY' },
{ id: 'de-DE', label: 'de-DE', hint: 'TT.MM.JJJJ' },
{ id: 'fr-FR', label: 'fr-FR', hint: 'JJ/MM/AAAA' },
{ id: 'ja-JP', label: 'ja-JP', hint: 'AAAA/MM/DD' },
{ id: 'ja-JP', label: 'ja-JP', hint: 'AAAA/MM/DD' }
];
let locale = $state('es-ES');
const translator = createTerraTranslator({
locale: () => locale,
locale: () => locale
});
const DATE_ORDERS: DateOrder[] = ['DMY', 'MDY', 'YMD'];
const TIME_FORMATS: HourCycle[] = [24, 12];
let dateOrder = $state<DateOrder>('DMY');
const dateTimeFormatter = createTerraDateTimeFormatter({
dateOrder: () => dateOrder,
dateOrder: () => dateOrder
});
let timeFormat = $state<HourCycle>(24);
// States
let value1 = $state<{ start: DateValue | undefined; end: DateValue | undefined } | undefined>(undefined);
let value1 = $state<{ start: DateValue | undefined; end: DateValue | undefined } | undefined>(
undefined
);
let value2 = $state<{ start: DateValue | undefined; end: DateValue | undefined }>({
start: new CalendarDate(now.year, now.month, 5),
end: new CalendarDate(now.year, now.month, 20),
end: new CalendarDate(now.year, now.month, 20)
});
let value3 = $state<{ start: DateValue | undefined; end: DateValue | undefined } | undefined>(undefined);
let value3 = $state<{ start: DateValue | undefined; end: DateValue | undefined } | undefined>(
undefined
);
function validateRange(range: { start: DateValue; end: DateValue }) {
const days = Math.round((range.end.toDate(tz).getTime() - range.start.toDate(tz).getTime()) / 86_400_000);
if (days < 2) return 'El rango debe ser de al menos 2 días';
const days = Math.round(
(range.end.toDate(tz).getTime() - range.start.toDate(tz).getTime()) / 86_400_000
);
if (days < 2) return 'El rango debe ser de al menos 2 días';
if (days > 30) return 'El rango no puede superar 30 días';
}
let value4 = $state<{ start: DateValue | undefined; end: DateValue | undefined } | undefined>(undefined);
let value4 = $state<{ start: DateValue | undefined; end: DateValue | undefined } | undefined>(
undefined
);
const fixedVal = {
start: new CalendarDate(now.year, now.month, 10),
end: new CalendarDate(now.year, now.month, 25),
end: new CalendarDate(now.year, now.month, 25)
};
let value6 = $state<{ start: DateValue | undefined; end: DateValue | undefined } | undefined>(undefined);
let value6 = $state<{ start: DateValue | undefined; end: DateValue | undefined } | undefined>(
undefined
);
function fmt(v: { start?: DateValue; end?: DateValue } | undefined) {
if (!v) return '—';
const d = (x: DateValue | undefined) => x ? `${x.year}-${String(x.month).padStart(2, '0')}-${String(x.day).padStart(2, '0')}` : '?';
const d = (x: DateValue | undefined) =>
x ? `${x.year}-${String(x.month).padStart(2, '0')}-${String(x.day).padStart(2, '0')}` : '?';
return `${d(v.start)} → ${d(v.end)}`;
}
const dateRangeFieldProps = [
{ name: 'locale', type: 'string', default: '"es-ES"', description: 'Locale para formato de fechas' },
{ name: 'dateOrder', type: 'DateOrder', default: '"DMY"', description: 'Orden de fecha: DMY, MDY, YMD' },
{ name: 'timeFormat', type: 'HourCycle', default: '24', description: 'Formato de hora: 12 o 24' },
{ name: 'granularity', type: '"day" | "hour" | "minute" | "second"', default: '"day"', description: 'Nivel de precisión del campo' },
{ name: 'validate', type: '(range) => string | undefined', default: 'undefined', description: 'Función de validación custom' },
{ name: 'readonlySegments', type: 'Segment[]', default: '[]', description: 'Segmentos en solo lectura' },
{
name: 'locale',
type: 'string',
default: '"es-ES"',
description: 'Locale para formato de fechas'
},
{
name: 'dateOrder',
type: 'DateOrder',
default: '"DMY"',
description: 'Orden de fecha: DMY, MDY, YMD'
},
{
name: 'timeFormat',
type: 'HourCycle',
default: '24',
description: 'Formato de hora: 12 o 24'
},
{
name: 'granularity',
type: '"day" | "hour" | "minute" | "second"',
default: '"day"',
description: 'Nivel de precisión del campo'
},
{
name: 'validate',
type: '(range) => string | undefined',
default: 'undefined',
description: 'Función de validación custom'
},
{
name: 'readonlySegments',
type: 'Segment[]',
default: '[]',
description: 'Segmentos en solo lectura'
}
];
const breadcrumbs = [
{ label: 'Test', href: '/test' },
{ label: 'DateRangeField' }
];
const breadcrumbs = [{ label: 'Test', href: '/test' }, { label: 'DateRangeField' }];
</script>
<TestLayout
@ -86,24 +124,34 @@
<div class="locale-bar">
<span class="locale-label">Locale:</span>
{#each LOCALES as loc}
<button class="locale-btn {locale === loc.id ? 'locale-btn--active' : ''}" onclick={() => (locale = loc.id)} title={loc.hint}>
<button
class="locale-btn {locale === loc.id ? 'locale-btn--active' : ''}"
onclick={() => (locale = loc.id)}
title={loc.hint}
>
{loc.label}
</button>
{/each}
<span class="locale-badge">{LOCALES.find(l => l.id === locale)?.hint ?? ''}</span>
<span class="locale-badge">{LOCALES.find((l) => l.id === locale)?.hint ?? ''}</span>
</div>
<div class="locale-bar">
<span class="locale-label">Date order:</span>
{#each DATE_ORDERS as order}
<button class="locale-btn {dateOrder === order ? 'locale-btn--active' : ''}" onclick={() => (dateOrder = order)}>
<button
class="locale-btn {dateOrder === order ? 'locale-btn--active' : ''}"
onclick={() => (dateOrder = order)}
>
{order}
</button>
{/each}
<span class="locale-label locale-label--spaced">Time format:</span>
{#each TIME_FORMATS as format}
<button class="locale-btn {timeFormat === format ? 'locale-btn--active' : ''}" onclick={() => (timeFormat = format)}>
<button
class="locale-btn {timeFormat === format ? 'locale-btn--active' : ''}"
onclick={() => (timeFormat = format)}
>
{format === 24 ? '24h' : '12h'}
</button>
{/each}
@ -121,7 +169,9 @@
<DateRangeField.Input type="start" class="drf-input">
{#snippet children({ segments })}
{#each segments as { part, value }}
<DateRangeField.Segment {part} class="drf-seg drf-seg--{part}">{value}</DateRangeField.Segment>
<DateRangeField.Segment {part} class="drf-seg drf-seg--{part}"
>{value}</DateRangeField.Segment
>
{/each}
{/snippet}
</DateRangeField.Input>
@ -129,7 +179,9 @@
<DateRangeField.Input type="end" class="drf-input">
{#snippet children({ segments })}
{#each segments as { part, value }}
<DateRangeField.Segment {part} class="drf-seg drf-seg--{part}">{value}</DateRangeField.Segment>
<DateRangeField.Segment {part} class="drf-seg drf-seg--{part}"
>{value}</DateRangeField.Segment
>
{/each}
{/snippet}
</DateRangeField.Input>
@ -156,7 +208,9 @@
<DateRangeField.Input type="start" class="drf-input">
{#snippet children({ segments })}
{#each segments as { part, value }}
<DateRangeField.Segment {part} class="drf-seg drf-seg--{part}">{value}</DateRangeField.Segment>
<DateRangeField.Segment {part} class="drf-seg drf-seg--{part}"
>{value}</DateRangeField.Segment
>
{/each}
{/snippet}
</DateRangeField.Input>
@ -164,7 +218,9 @@
<DateRangeField.Input type="end" class="drf-input">
{#snippet children({ segments })}
{#each segments as { part, value }}
<DateRangeField.Segment {part} class="drf-seg drf-seg--{part}">{value}</DateRangeField.Segment>
<DateRangeField.Segment {part} class="drf-seg drf-seg--{part}"
>{value}</DateRangeField.Segment
>
{/each}
{/snippet}
</DateRangeField.Input>
@ -179,18 +235,24 @@
</DemoSection>
<!-- Demo 3: Validación -->
<DemoSection
title="Validación: 2–30 días"
description="El rango debe tener entre 2 y 30 días."
>
<DemoSection title="Validación: 2–30 días" description="El rango debe tener entre 2 y 30 días.">
<div class="field-wrap">
<DateRangeField.Provider bind:value={value3} {locale} {dateOrder} {timeFormat} validate={validateRange} class="drf">
<DateRangeField.Provider
bind:value={value3}
{locale}
{dateOrder}
{timeFormat}
validate={validateRange}
class="drf"
>
<DateRangeField.Label class="drf-label">Rango de fechas</DateRangeField.Label>
<div class="drf-inputs">
<DateRangeField.Input type="start" class="drf-input">
{#snippet children({ segments })}
{#each segments as { part, value }}
<DateRangeField.Segment {part} class="drf-seg drf-seg--{part}">{value}</DateRangeField.Segment>
<DateRangeField.Segment {part} class="drf-seg drf-seg--{part}"
>{value}</DateRangeField.Segment
>
{/each}
{/snippet}
</DateRangeField.Input>
@ -198,7 +260,9 @@
<DateRangeField.Input type="end" class="drf-input">
{#snippet children({ segments })}
{#each segments as { part, value }}
<DateRangeField.Segment {part} class="drf-seg drf-seg--{part}">{value}</DateRangeField.Segment>
<DateRangeField.Segment {part} class="drf-seg drf-seg--{part}"
>{value}</DateRangeField.Segment
>
{/each}
{/snippet}
</DateRangeField.Input>
@ -219,13 +283,22 @@
variant="bordered"
>
<div class="field-wrap">
<DateRangeField.Provider bind:value={value4} {locale} {dateOrder} {timeFormat} granularity="hour" class="drf">
<DateRangeField.Provider
bind:value={value4}
{locale}
{dateOrder}
{timeFormat}
granularity="hour"
class="drf"
>
<DateRangeField.Label class="drf-label">Reserva de sala</DateRangeField.Label>
<div class="drf-inputs">
<DateRangeField.Input type="start" class="drf-input">
{#snippet children({ segments })}
{#each segments as { part, value }}
<DateRangeField.Segment {part} class="drf-seg drf-seg--{part}">{value}</DateRangeField.Segment>
<DateRangeField.Segment {part} class="drf-seg drf-seg--{part}"
>{value}</DateRangeField.Segment
>
{/each}
{/snippet}
</DateRangeField.Input>
@ -233,7 +306,9 @@
<DateRangeField.Input type="end" class="drf-input">
{#snippet children({ segments })}
{#each segments as { part, value }}
<DateRangeField.Segment {part} class="drf-seg drf-seg--{part}">{value}</DateRangeField.Segment>
<DateRangeField.Segment {part} class="drf-seg drf-seg--{part}"
>{value}</DateRangeField.Segment
>
{/each}
{/snippet}
</DateRangeField.Input>
@ -248,10 +323,7 @@
</DemoSection>
<!-- Demo 5: disabled / readonly -->
<DemoSection
title="disabled / readonly"
description="Estados de interacción bloqueada."
>
<DemoSection title="disabled / readonly" description="Estados de interacción bloqueada.">
<div class="pair-col">
<div class="pair-item">
<p class="pair-label">readonly</p>
@ -261,7 +333,9 @@
<DateRangeField.Input type="start" class="drf-input">
{#snippet children({ segments })}
{#each segments as { part, value }}
<DateRangeField.Segment {part} class="drf-seg drf-seg--{part}">{value}</DateRangeField.Segment>
<DateRangeField.Segment {part} class="drf-seg drf-seg--{part}"
>{value}</DateRangeField.Segment
>
{/each}
{/snippet}
</DateRangeField.Input>
@ -269,7 +343,9 @@
<DateRangeField.Input type="end" class="drf-input">
{#snippet children({ segments })}
{#each segments as { part, value }}
<DateRangeField.Segment {part} class="drf-seg drf-seg--{part}">{value}</DateRangeField.Segment>
<DateRangeField.Segment {part} class="drf-seg drf-seg--{part}"
>{value}</DateRangeField.Segment
>
{/each}
{/snippet}
</DateRangeField.Input>
@ -286,7 +362,9 @@
<DateRangeField.Input type="start" class="drf-input">
{#snippet children({ segments })}
{#each segments as { part, value }}
<DateRangeField.Segment {part} class="drf-seg drf-seg--{part}">{value}</DateRangeField.Segment>
<DateRangeField.Segment {part} class="drf-seg drf-seg--{part}"
>{value}</DateRangeField.Segment
>
{/each}
{/snippet}
</DateRangeField.Input>
@ -294,7 +372,9 @@
<DateRangeField.Input type="end" class="drf-input">
{#snippet children({ segments })}
{#each segments as { part, value }}
<DateRangeField.Segment {part} class="drf-seg drf-seg--{part}">{value}</DateRangeField.Segment>
<DateRangeField.Segment {part} class="drf-seg drf-seg--{part}"
>{value}</DateRangeField.Segment
>
{/each}
{/snippet}
</DateRangeField.Input>
@ -319,7 +399,9 @@
<DateRangeField.Input type="start" class="drf-input">
{#snippet children({ segments })}
{#each segments as { part, value }}
<DateRangeField.Segment {part} class="drf-seg drf-seg--{part}">{value}</DateRangeField.Segment>
<DateRangeField.Segment {part} class="drf-seg drf-seg--{part}"
>{value}</DateRangeField.Segment
>
{/each}
{/snippet}
</DateRangeField.Input>
@ -327,7 +409,9 @@
<DateRangeField.Input type="end" class="drf-input">
{#snippet children({ segments })}
{#each segments as { part, value }}
<DateRangeField.Segment {part} class="drf-seg drf-seg--{part}">{value}</DateRangeField.Segment>
<DateRangeField.Segment {part} class="drf-seg drf-seg--{part}"
>{value}</DateRangeField.Segment
>
{/each}
{/snippet}
</DateRangeField.Input>
@ -465,7 +549,7 @@
outline: none;
}
:global(.drf-seg[data-type="literal"]) {
:global(.drf-seg[data-type='literal']) {
color: var(--color-text-dim);
min-width: auto;
padding: 0 2px;

@ -1,8 +1,8 @@
<script lang="ts">
import { TestLayout, StateItem, PropsTable, DemoSection } from '../_components';
import { DateRangePicker } from '$uix/terra';
import type { DateValue } from '$lib/util/dates';
import { today, getLocalTimeZone, CalendarDate } from '$lib/util/dates';
import type { DateValue } from '$lib/util/dias';
import { today, getLocalTimeZone, CalendarDate } from '$lib/util/dias';
const tz = getLocalTimeZone();
const now = today(tz);
@ -15,29 +15,35 @@
{ id: 'de-DE', label: 'de-DE', hint: 'TT.MM.JJJJ' },
{ id: 'fr-FR', label: 'fr-FR', hint: 'JJ/MM/AAAA' },
{ id: 'ja-JP', label: 'ja-JP', hint: 'AAAA/MM/DD' },
{ id: 'ar-SA', label: 'ar-SA', hint: 'RTL' },
{ id: 'ar-SA', label: 'ar-SA', hint: 'RTL' }
];
let locale = $state('es-ES');
// Demo states
let value1 = $state<{ start: DateValue | undefined; end: DateValue | undefined } | undefined>(undefined);
let value1 = $state<{ start: DateValue | undefined; end: DateValue | undefined } | undefined>(
undefined
);
let open1 = $state(false);
let value2 = $state<{ start: DateValue | undefined; end: DateValue | undefined }>({
start: new CalendarDate(now.year, now.month, 3),
end: new CalendarDate(now.year, now.month, 17),
end: new CalendarDate(now.year, now.month, 17)
});
let open2 = $state(false);
let value3 = $state<{ start: DateValue | undefined; end: DateValue | undefined } | undefined>(undefined);
let value3 = $state<{ start: DateValue | undefined; end: DateValue | undefined } | undefined>(
undefined
);
let open3 = $state(false);
let value4 = $state<{ start: DateValue | undefined; end: DateValue | undefined } | undefined>(undefined);
let value4 = $state<{ start: DateValue | undefined; end: DateValue | undefined } | undefined>(
undefined
);
let open4 = $state(false);
const fixedVal = {
start: new CalendarDate(now.year, now.month, 8),
end: new CalendarDate(now.year, now.month, 22),
end: new CalendarDate(now.year, now.month, 22)
};
function fmt(v: { start?: DateValue; end?: DateValue } | undefined) {
@ -48,20 +54,32 @@
}
const dateRangePickerProps = [
{ name: 'value', type: 'DateRange', default: 'undefined', description: 'Selected range (bind:value)' },
{ name: 'open', type: 'boolean', default: 'false', description: 'Popover open state (bind:open)' },
{
name: 'value',
type: 'DateRange',
default: 'undefined',
description: 'Selected range (bind:value)'
},
{
name: 'open',
type: 'boolean',
default: 'false',
description: 'Popover open state (bind:open)'
},
{ name: 'locale', type: 'string', default: '"en-US"', description: 'Locale for formatting' },
{ name: 'numberOfMonths', type: 'number', default: '1', description: 'Number of months visible' },
{
name: 'numberOfMonths',
type: 'number',
default: '1',
description: 'Number of months visible'
},
{ name: 'minDays', type: 'number', default: 'undefined', description: 'Minimum days in range' },
{ name: 'maxDays', type: 'number', default: 'undefined', description: 'Maximum days in range' },
{ name: 'disabled', type: 'boolean', default: 'false', description: 'Disable the picker' },
{ name: 'readonly', type: 'boolean', default: 'false', description: 'Readonly mode' },
{ name: 'readonly', type: 'boolean', default: 'false', description: 'Readonly mode' }
];
const breadcrumbs = [
{ label: 'Test', href: '/test' },
{ label: 'DateRangePicker' }
];
const breadcrumbs = [{ label: 'Test', href: '/test' }, { label: 'DateRangePicker' }];
</script>
<TestLayout
@ -78,10 +96,10 @@
<button
class="locale-btn {locale === loc.id ? 'locale-btn--active' : ''}"
onclick={() => (locale = loc.id)}
title={loc.hint}
>{loc.label}</button>
title={loc.hint}>{loc.label}</button
>
{/each}
<span class="locale-badge">{LOCALES.find(l => l.id === locale)?.hint ?? ''}</span>
<span class="locale-badge">{LOCALES.find((l) => l.id === locale)?.hint ?? ''}</span>
</div>
<!-- Demo 1: Basic -->
@ -95,7 +113,9 @@
<DateRangePicker.Input type="start" class="drp-input">
{#snippet children({ segments })}
{#each segments as { part, value }}
<DateRangePicker.Segment {part} class="drp-seg drp-seg--{part}">{value}</DateRangePicker.Segment>
<DateRangePicker.Segment {part} class="drp-seg drp-seg--{part}"
>{value}</DateRangePicker.Segment
>
{/each}
{/snippet}
</DateRangePicker.Input>
@ -103,15 +123,24 @@
<DateRangePicker.Input type="end" class="drp-input">
{#snippet children({ segments })}
{#each segments as { part, value }}
<DateRangePicker.Segment {part} class="drp-seg drp-seg--{part}">{value}</DateRangePicker.Segment>
<DateRangePicker.Segment {part} class="drp-seg drp-seg--{part}"
>{value}</DateRangePicker.Segment
>
{/each}
{/snippet}
</DateRangePicker.Input>
<DateRangePicker.Trigger class="drp-trigger">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="2" y="3" width="12" height="10" rx="2"/>
<path d="M5 1v2M11 1v2"/>
<path d="M2 6h12"/>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<rect x="2" y="3" width="12" height="10" rx="2" />
<path d="M5 1v2M11 1v2" />
<path d="M2 6h12" />
</svg>
</DateRangePicker.Trigger>
</div>
@ -163,7 +192,9 @@
<DateRangePicker.Input type="start" class="drp-input">
{#snippet children({ segments })}
{#each segments as { part, value }}
<DateRangePicker.Segment {part} class="drp-seg drp-seg--{part}">{value}</DateRangePicker.Segment>
<DateRangePicker.Segment {part} class="drp-seg drp-seg--{part}"
>{value}</DateRangePicker.Segment
>
{/each}
{/snippet}
</DateRangePicker.Input>
@ -171,15 +202,24 @@
<DateRangePicker.Input type="end" class="drp-input">
{#snippet children({ segments })}
{#each segments as { part, value }}
<DateRangePicker.Segment {part} class="drp-seg drp-seg--{part}">{value}</DateRangePicker.Segment>
<DateRangePicker.Segment {part} class="drp-seg drp-seg--{part}"
>{value}</DateRangePicker.Segment
>
{/each}
{/snippet}
</DateRangePicker.Input>
<DateRangePicker.Trigger class="drp-trigger">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="2" y="3" width="12" height="10" rx="2"/>
<path d="M5 1v2M11 1v2"/>
<path d="M2 6h12"/>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<rect x="2" y="3" width="12" height="10" rx="2" />
<path d="M5 1v2M11 1v2" />
<path d="M2 6h12" />
</svg>
</DateRangePicker.Trigger>
</div>
@ -230,7 +270,9 @@
<DateRangePicker.Input type="start" class="drp-input">
{#snippet children({ segments })}
{#each segments as { part, value }}
<DateRangePicker.Segment {part} class="drp-seg drp-seg--{part}">{value}</DateRangePicker.Segment>
<DateRangePicker.Segment {part} class="drp-seg drp-seg--{part}"
>{value}</DateRangePicker.Segment
>
{/each}
{/snippet}
</DateRangePicker.Input>
@ -238,15 +280,24 @@
<DateRangePicker.Input type="end" class="drp-input">
{#snippet children({ segments })}
{#each segments as { part, value }}
<DateRangePicker.Segment {part} class="drp-seg drp-seg--{part}">{value}</DateRangePicker.Segment>
<DateRangePicker.Segment {part} class="drp-seg drp-seg--{part}"
>{value}</DateRangePicker.Segment
>
{/each}
{/snippet}
</DateRangePicker.Input>
<DateRangePicker.Trigger class="drp-trigger">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="2" y="3" width="12" height="10" rx="2"/>
<path d="M5 1v2M11 1v2"/>
<path d="M2 6h12"/>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<rect x="2" y="3" width="12" height="10" rx="2" />
<path d="M5 1v2M11 1v2" />
<path d="M2 6h12" />
</svg>
</DateRangePicker.Trigger>
</div>
@ -258,13 +309,17 @@
<div class="month-col">
<div class="month-nav">
{#if i === 0}
<DateRangePicker.PrevButton class="cal-btn">&lsaquo;</DateRangePicker.PrevButton>
<DateRangePicker.PrevButton class="cal-btn"
>&lsaquo;</DateRangePicker.PrevButton
>
{:else}
<span></span>
{/if}
<DateRangePicker.Heading class="cal-heading" />
{#if i === months.length - 1}
<DateRangePicker.NextButton class="cal-btn">&rsaquo;</DateRangePicker.NextButton>
<DateRangePicker.NextButton class="cal-btn"
>&rsaquo;</DateRangePicker.NextButton
>
{:else}
<span></span>
{/if}
@ -273,7 +328,8 @@
<DateRangePicker.GridHead class="cal-thead">
<DateRangePicker.GridRow class="cal-tr">
{#each weekdays as day}
<DateRangePicker.HeadCell class="cal-th">{day}</DateRangePicker.HeadCell>
<DateRangePicker.HeadCell class="cal-th">{day}</DateRangePicker.HeadCell
>
{/each}
</DateRangePicker.GridRow>
</DateRangePicker.GridHead>
@ -304,13 +360,21 @@
description="Range must be between 3 and 14 days (minDays=3, maxDays=14)."
variant="bordered"
>
<DateRangePicker.Provider bind:value={value4} bind:open={open4} minDays={3} maxDays={14} {locale}>
<DateRangePicker.Provider
bind:value={value4}
bind:open={open4}
minDays={3}
maxDays={14}
{locale}
>
<DateRangePicker.Label class="drp-label">Booking period</DateRangePicker.Label>
<div class="drp-field">
<DateRangePicker.Input type="start" class="drp-input">
{#snippet children({ segments })}
{#each segments as { part, value }}
<DateRangePicker.Segment {part} class="drp-seg drp-seg--{part}">{value}</DateRangePicker.Segment>
<DateRangePicker.Segment {part} class="drp-seg drp-seg--{part}"
>{value}</DateRangePicker.Segment
>
{/each}
{/snippet}
</DateRangePicker.Input>
@ -318,15 +382,24 @@
<DateRangePicker.Input type="end" class="drp-input">
{#snippet children({ segments })}
{#each segments as { part, value }}
<DateRangePicker.Segment {part} class="drp-seg drp-seg--{part}">{value}</DateRangePicker.Segment>
<DateRangePicker.Segment {part} class="drp-seg drp-seg--{part}"
>{value}</DateRangePicker.Segment
>
{/each}
{/snippet}
</DateRangePicker.Input>
<DateRangePicker.Trigger class="drp-trigger">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="2" y="3" width="12" height="10" rx="2"/>
<path d="M5 1v2M11 1v2"/>
<path d="M2 6h12"/>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<rect x="2" y="3" width="12" height="10" rx="2" />
<path d="M5 1v2M11 1v2" />
<path d="M2 6h12" />
</svg>
</DateRangePicker.Trigger>
</div>
@ -367,10 +440,7 @@
</DemoSection>
<!-- Demo 5: Disabled / Readonly -->
<DemoSection
title="States"
description="Disabled and readonly states."
>
<DemoSection title="States" description="Disabled and readonly states.">
<div class="states-grid">
<div class="state-item">
<span class="state-label">readonly</span>
@ -379,7 +449,9 @@
<DateRangePicker.Input type="start" class="drp-input">
{#snippet children({ segments })}
{#each segments as { part, value }}
<DateRangePicker.Segment {part} class="drp-seg drp-seg--{part}">{value}</DateRangePicker.Segment>
<DateRangePicker.Segment {part} class="drp-seg drp-seg--{part}"
>{value}</DateRangePicker.Segment
>
{/each}
{/snippet}
</DateRangePicker.Input>
@ -387,15 +459,24 @@
<DateRangePicker.Input type="end" class="drp-input">
{#snippet children({ segments })}
{#each segments as { part, value }}
<DateRangePicker.Segment {part} class="drp-seg drp-seg--{part}">{value}</DateRangePicker.Segment>
<DateRangePicker.Segment {part} class="drp-seg drp-seg--{part}"
>{value}</DateRangePicker.Segment
>
{/each}
{/snippet}
</DateRangePicker.Input>
<DateRangePicker.Trigger class="drp-trigger" disabled>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="2" y="3" width="12" height="10" rx="2"/>
<path d="M5 1v2M11 1v2"/>
<path d="M2 6h12"/>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<rect x="2" y="3" width="12" height="10" rx="2" />
<path d="M5 1v2M11 1v2" />
<path d="M2 6h12" />
</svg>
</DateRangePicker.Trigger>
</div>
@ -409,7 +490,9 @@
<DateRangePicker.Input type="start" class="drp-input">
{#snippet children({ segments })}
{#each segments as { part, value }}
<DateRangePicker.Segment {part} class="drp-seg drp-seg--{part}">{value}</DateRangePicker.Segment>
<DateRangePicker.Segment {part} class="drp-seg drp-seg--{part}"
>{value}</DateRangePicker.Segment
>
{/each}
{/snippet}
</DateRangePicker.Input>
@ -417,15 +500,24 @@
<DateRangePicker.Input type="end" class="drp-input">
{#snippet children({ segments })}
{#each segments as { part, value }}
<DateRangePicker.Segment {part} class="drp-seg drp-seg--{part}">{value}</DateRangePicker.Segment>
<DateRangePicker.Segment {part} class="drp-seg drp-seg--{part}"
>{value}</DateRangePicker.Segment
>
{/each}
{/snippet}
</DateRangePicker.Input>
<DateRangePicker.Trigger class="drp-trigger" disabled>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="2" y="3" width="12" height="10" rx="2"/>
<path d="M5 1v2M11 1v2"/>
<path d="M2 6h12"/>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<rect x="2" y="3" width="12" height="10" rx="2" />
<path d="M5 1v2M11 1v2" />
<path d="M2 6h12" />
</svg>
</DateRangePicker.Trigger>
</div>
@ -540,7 +632,7 @@
outline: none;
}
:global(.drp-seg[data-type="literal"]) {
:global(.drp-seg[data-type='literal']) {
color: var(--color-text-dim);
min-width: auto;
padding: 0 2px;
@ -670,7 +762,7 @@
transition: all var(--transition-fast);
}
:global(.cal-day:hover:not([aria-disabled="true"]):not([data-outside-visible-months])) {
:global(.cal-day:hover:not([aria-disabled='true']):not([data-outside-visible-months])) {
background: var(--color-surface-elevated);
}
@ -703,7 +795,7 @@
}
:global(.cal-day[data-disabled]),
:global(.cal-day[aria-disabled="true"]) {
:global(.cal-day[aria-disabled='true']) {
color: var(--color-text-dim);
cursor: not-allowed;
pointer-events: none;

@ -2,11 +2,11 @@
import { TestLayout, StateItem, PropsTable, DemoSection } from '../_components';
import { Terra, RangeCalendar } from '$uix/terra';
import { createTerraTranslator, createTerraDateTimeFormatter } from '$uix/terra/system';
import type { DateOrder } from '$lib/util/dates';
import type { DateValue } from '$lib/util/dates';
import { today, getLocalTimeZone, CalendarDate } from '$lib/util/dates';
import type { DateOrder } from '$lib/util/dias';
import type { DateValue } from '$lib/util/dias';
import { today, getLocalTimeZone, CalendarDate } from '$lib/util/dias';
const tz = getLocalTimeZone();
const tz = getLocalTimeZone();
const now = today(tz);
// Config
@ -16,30 +16,33 @@
{ id: 'en-GB', label: 'en-GB', hint: 'DD/MM/YYYY' },
{ id: 'de-DE', label: 'de-DE', hint: 'TT.MM.JJJJ' },
{ id: 'fr-FR', label: 'fr-FR', hint: 'JJ/MM/AAAA' },
{ id: 'ja-JP', label: 'ja-JP', hint: 'AAAA/MM/DD' },
{ id: 'ja-JP', label: 'ja-JP', hint: 'AAAA/MM/DD' }
];
let locale = $state('es-ES');
const translator = createTerraTranslator({
locale: () => locale,
locale: () => locale
});
const DATE_ORDERS: DateOrder[] = ['DMY', 'MDY', 'YMD'];
let dateOrder = $state<DateOrder>('DMY');
const dateTimeFormatter = createTerraDateTimeFormatter({
dateOrder: () => dateOrder,
dateOrder: () => dateOrder
});
// States
let value1 = $state<{ start: DateValue | undefined; end: DateValue | undefined }>({
start: undefined,
end: undefined,
end: undefined
});
let value2 = $state<{ start: DateValue | undefined; end: DateValue | undefined }>({
start: new CalendarDate(now.year, now.month, 5),
end: new CalendarDate(now.year, now.month, 14),
end: new CalendarDate(now.year, now.month, 14)
});
let value3 = $state<{ start: DateValue | undefined; end: DateValue | undefined }>({ start: undefined, end: undefined });
let value3 = $state<{ start: DateValue | undefined; end: DateValue | undefined }>({
start: undefined,
end: undefined
});
const minVal = now.subtract({ months: 1 });
const maxVal = now.add({ months: 2 });
function isWeekend(date: DateValue) {
@ -47,18 +50,27 @@
return d === 0 || d === 6;
}
let value4 = $state<{ start: DateValue | undefined; end: DateValue | undefined }>({ start: undefined, end: undefined });
let value5 = $state<{ start: DateValue | undefined; end: DateValue | undefined }>({ start: undefined, end: undefined });
let value6 = $state<{ start: DateValue | undefined; end: DateValue | undefined }>({ start: undefined, end: undefined });
let value4 = $state<{ start: DateValue | undefined; end: DateValue | undefined }>({
start: undefined,
end: undefined
});
let value5 = $state<{ start: DateValue | undefined; end: DateValue | undefined }>({
start: undefined,
end: undefined
});
let value6 = $state<{ start: DateValue | undefined; end: DateValue | undefined }>({
start: undefined,
end: undefined
});
const fixedRange = {
start: new CalendarDate(now.year, now.month, 8),
end: new CalendarDate(now.year, now.month, 18),
end: new CalendarDate(now.year, now.month, 18)
};
function fmt(v: { start: DateValue | undefined; end: DateValue | undefined }) {
const d = (x: DateValue | undefined) =>
x ? `${x.year}-${String(x.month).padStart(2,'0')}-${String(x.day).padStart(2,'0')}` : '—';
x ? `${x.year}-${String(x.month).padStart(2, '0')}-${String(x.day).padStart(2, '0')}` : '—';
return `${d(v.start)} → ${d(v.end)}`;
}
@ -70,20 +82,57 @@
}
const rangeCalendarProps = [
{ name: 'locale', type: 'string', default: '"es-ES"', description: 'Locale para formato de fechas' },
{ name: 'dateOrder', type: 'DateOrder', default: '"DMY"', description: 'Orden de fecha: DMY, MDY, YMD' },
{ name: 'numberOfMonths', type: 'number', default: '1', description: 'Número de meses visibles' },
{ name: 'minValue / maxValue', type: 'DateValue', default: 'undefined', description: 'Límites de selección' },
{ name: 'minDays / maxDays', type: 'number', default: 'undefined', description: 'Días mínimos/máximos del rango' },
{ name: 'isDateDisabled', type: '(date) => boolean', default: 'undefined', description: 'Función para deshabilitar fechas' },
{ name: 'readonly', type: 'boolean', default: 'false', description: 'Solo lectura, navegable pero no editable' },
{ name: 'disabled', type: 'boolean', default: 'false', description: 'Completamente deshabilitado' },
{
name: 'locale',
type: 'string',
default: '"es-ES"',
description: 'Locale para formato de fechas'
},
{
name: 'dateOrder',
type: 'DateOrder',
default: '"DMY"',
description: 'Orden de fecha: DMY, MDY, YMD'
},
{
name: 'numberOfMonths',
type: 'number',
default: '1',
description: 'Número de meses visibles'
},
{
name: 'minValue / maxValue',
type: 'DateValue',
default: 'undefined',
description: 'Límites de selección'
},
{
name: 'minDays / maxDays',
type: 'number',
default: 'undefined',
description: 'Días mínimos/máximos del rango'
},
{
name: 'isDateDisabled',
type: '(date) => boolean',
default: 'undefined',
description: 'Función para deshabilitar fechas'
},
{
name: 'readonly',
type: 'boolean',
default: 'false',
description: 'Solo lectura, navegable pero no editable'
},
{
name: 'disabled',
type: 'boolean',
default: 'false',
description: 'Completamente deshabilitado'
}
];
const breadcrumbs = [
{ label: 'Test', href: '/test' },
{ label: 'RangeCalendar' }
];
const breadcrumbs = [{ label: 'Test', href: '/test' }, { label: 'RangeCalendar' }];
</script>
<TestLayout
@ -100,16 +149,19 @@
<button
class="locale-btn {locale === loc.id ? 'locale-btn--active' : ''}"
onclick={() => (locale = loc.id)}
title={loc.hint}
>{loc.label}</button>
title={loc.hint}>{loc.label}</button
>
{/each}
<span class="locale-badge">{LOCALES.find(l => l.id === locale)?.hint ?? ''}</span>
<span class="locale-badge">{LOCALES.find((l) => l.id === locale)?.hint ?? ''}</span>
</div>
<div class="locale-bar">
<span class="locale-label">Date order:</span>
{#each DATE_ORDERS as order}
<button class="locale-btn {dateOrder === order ? 'locale-btn--active' : ''}" onclick={() => (dateOrder = order)}>
<button
class="locale-btn {dateOrder === order ? 'locale-btn--active' : ''}"
onclick={() => (dateOrder = order)}
>
{order}
</button>
{/each}
@ -261,7 +313,14 @@
description="El rango debe tener entre 3 y 10 días."
variant="bordered"
>
<RangeCalendar.Provider bind:value={value4} minDays={3} maxDays={10} {locale} {dateOrder} class="cal">
<RangeCalendar.Provider
bind:value={value4}
minDays={3}
maxDays={10}
{locale}
{dateOrder}
class="cal"
>
{#snippet children({ months, weekdays })}
<RangeCalendar.Header class="cal-hd">
<RangeCalendar.PrevButton class="cal-btn">‹</RangeCalendar.PrevButton>
@ -373,7 +432,13 @@
<div class="pair-row">
<div class="pair-col">
<p class="pair-label">readonly</p>
<RangeCalendar.Provider value={fixedRange} {locale} {dateOrder} readonly class="cal cal--sm">
<RangeCalendar.Provider
value={fixedRange}
{locale}
{dateOrder}
readonly
class="cal cal--sm"
>
{#snippet children({ months, weekdays })}
<RangeCalendar.Header class="cal-hd">
<RangeCalendar.PrevButton class="cal-btn">‹</RangeCalendar.PrevButton>
@ -408,7 +473,13 @@
<div class="pair-col">
<p class="pair-label">disabled</p>
<RangeCalendar.Provider value={fixedRange} {locale} {dateOrder} disabled class="cal cal--sm">
<RangeCalendar.Provider
value={fixedRange}
{locale}
{dateOrder}
disabled
class="cal cal--sm"
>
{#snippet children({ months, weekdays })}
<RangeCalendar.Header class="cal-hd">
<RangeCalendar.PrevButton class="cal-btn">‹</RangeCalendar.PrevButton>
@ -562,8 +633,12 @@
width: 100%;
}
:global(.cal--sm) { font-size: var(--text-xs); }
:global(.cal--two) { width: 100%; }
:global(.cal--sm) {
font-size: var(--text-xs);
}
:global(.cal--two) {
width: 100%;
}
:global(.cal-hd) {
display: flex;
@ -638,7 +713,7 @@
transition: all var(--transition-fast);
}
:global(.cal-day:hover:not([aria-disabled="true"]):not([data-outside-visible-months])) {
:global(.cal-day:hover:not([aria-disabled='true']):not([data-outside-visible-months])) {
background: var(--color-surface-elevated);
}
@ -649,7 +724,7 @@
}
:global(.cal-day[data-selection-start]),
:global(.cal-day[data-selection-end]) {
:global(.cal-day[data-selection-end]) {
background: var(--color-primary);
color: var(--color-background);
font-weight: 700;
@ -671,7 +746,7 @@
}
:global(.cal-day[data-disabled]),
:global(.cal-day[aria-disabled="true"]) {
:global(.cal-day[aria-disabled='true']) {
color: var(--color-text-dim);
cursor: not-allowed;
pointer-events: none;

@ -19,10 +19,14 @@
<li><a href="/test/soma/popover">Popover</a></li>
<li><a href="/test/soma/tooltip">Tooltip</a></li>
<li><a href="/test/soma/dropdown-menu">DropdownMenu</a></li>
<li><a href="/test/soma/context-menu">ContextMenu</a></li>
<li><a href="/test/soma/link-preview">LinkPreview</a></li>
<li><a href="/test/soma/command">Command</a></li>
</ul>
<h2>Navigation</h2>
<ul>
<li><a href="/test/soma/menubar">Menubar</a></li>
<li><a href="/test/soma/tabs">Tabs</a></li>
<li><a href="/test/soma/toggle-group">ToggleGroup</a></li>
<li><a href="/test/soma/stepper">Stepper</a></li>
@ -35,6 +39,12 @@
<li><a href="/test/soma/table">Table</a></li>
</ul>
<h2>Dates</h2>
<ul>
<li><a href="/test/soma/calendar">Calendar</a></li>
<li><a href="/test/soma/range-calendar">RangeCalendar</a></li>
</ul>
<h2>Controls</h2>
<ul>
<li><a href="/test/soma/toolbar">Toolbar</a></li>
@ -56,6 +66,8 @@
<h2>Forms</h2>
<ul>
<li><a href="/test/soma/field">Field</a></li>
<li><a href="/test/soma/file-upload">FileUpload</a></li>
<li><a href="/test/soma/forms">Switch / Checkbox / RadioGroup</a></li>
<li><a href="/test/soma/select">Select</a></li>
<li><a href="/test/soma/combobox">Combobox</a></li>

@ -0,0 +1,581 @@
<script lang="ts">
import { Calendar } from '$soma/components';
import type { DateValue } from '$lib/util/dias';
import { CalendarDate, today, getLocalTimeZone, isSameMonth } from '$lib/util/dias';
const t = today(getLocalTimeZone());
const ref = new CalendarDate(t.year, t.month, t.day);
// ── Demo 1: single selection ─────────────────────────────────────────────
let singleValue = $state<DateValue | undefined>(ref);
// ── Demo 2: multiple selection (max 3) ────────────────────────────────────
let multipleValue = $state<DateValue[]>([ref]);
// ── Demo 3: two months side by side ──────────────────────────────────────
let twoMonthsValue = $state<DateValue | undefined>();
// ── Demo 4: fixedWeeks + weekNumbers ─────────────────────────────────────
let fixedValue = $state<DateValue | undefined>();
// ── Demo 5: holidays (weekends decorated too) ────────────────────────────
const sampleHolidays: DateValue[] = [
new CalendarDate(t.year, 1, 1),
new CalendarDate(t.year, 5, 1),
new CalendarDate(t.year, 12, 25),
new CalendarDate(t.year, 12, 31)
];
const isHoliday = (d: DateValue) =>
sampleHolidays.some((h) => h.compare(d) === 0);
// ── Demo 6: min/max and unavailable ──────────────────────────────────────
const minDate = ref.subtract({ months: 1 });
const maxDate = ref.add({ months: 2 });
const isUnavailable = (d: DateValue) => d.day === 15;
let boundedValue = $state<DateValue | undefined>();
// ── Demo 7: readonly ─────────────────────────────────────────────────────
const readonlyValue = ref;
</script>
<svelte:head>
<title>Calendar · Soma</title>
</svelte:head>
<div class="page">
<h1>Calendar</h1>
<p>
Headless calendar with 14 parts. Superior to ark/bits/radix via
<code>MonthSelect</code>, <code>YearSelect</code>, ISO week numbers,
holiday matcher, weekend flag, and navigation announcements.
</p>
<section>
<h2>1 · Single selection</h2>
<p>Bindable value. Click to select, click again to clear.</p>
<Calendar.Provider type="single" bind:value={singleValue} initialFocus>
{#snippet children({ months, weekdays })}
<Calendar.Header>
<Calendar.PrevButton>‹</Calendar.PrevButton>
<Calendar.Heading />
<Calendar.NextButton>›</Calendar.NextButton>
</Calendar.Header>
{#each months as m (m.value.toString())}
<Calendar.Grid>
<Calendar.GridHead>
<tr>
{#each weekdays as wd, i (i)}
<Calendar.HeadCell>{wd}</Calendar.HeadCell>
{/each}
</tr>
</Calendar.GridHead>
<Calendar.GridBody>
{#each m.weeks as week (week[0].toString())}
<Calendar.GridRow>
{#each week as date (date.toString())}
<Calendar.Cell {date} month={m.value}>
<Calendar.Day />
</Calendar.Cell>
{/each}
</Calendar.GridRow>
{/each}
</Calendar.GridBody>
</Calendar.Grid>
{/each}
{/snippet}
</Calendar.Provider>
<p class="state">Selected: <code>{singleValue?.toString() ?? '—'}</code></p>
</section>
<section>
<h2>2 · Multiple selection (max 3)</h2>
<Calendar.Provider type="multiple" bind:value={multipleValue} maxDays={3}>
{#snippet children({ months, weekdays })}
<Calendar.Header>
<Calendar.PrevButton>‹</Calendar.PrevButton>
<Calendar.Heading />
<Calendar.NextButton>›</Calendar.NextButton>
</Calendar.Header>
{#each months as m (m.value.toString())}
<Calendar.Grid>
<Calendar.GridHead>
<tr>
{#each weekdays as wd, i (i)}
<Calendar.HeadCell>{wd}</Calendar.HeadCell>
{/each}
</tr>
</Calendar.GridHead>
<Calendar.GridBody>
{#each m.weeks as week (week[0].toString())}
<Calendar.GridRow>
{#each week as date (date.toString())}
<Calendar.Cell {date} month={m.value}>
<Calendar.Day />
</Calendar.Cell>
{/each}
</Calendar.GridRow>
{/each}
</Calendar.GridBody>
</Calendar.Grid>
{/each}
{/snippet}
</Calendar.Provider>
<p class="state">Selected: <code>{multipleValue.map((d) => d.toString()).join(', ') || '—'}</code></p>
</section>
<section>
<h2>3 · Two months + MonthSelect / YearSelect</h2>
<Calendar.Provider type="single" bind:value={twoMonthsValue} numberOfMonths={2} pagedNavigation>
{#snippet children({ months, weekdays })}
<Calendar.Header>
<Calendar.PrevButton>‹</Calendar.PrevButton>
<Calendar.MonthSelect />
<Calendar.YearSelect />
<Calendar.NextButton>›</Calendar.NextButton>
</Calendar.Header>
<div class="two-grids">
{#each months as m (m.value.toString())}
<Calendar.Grid>
<Calendar.GridHead>
<tr>
{#each weekdays as wd, i (i)}
<Calendar.HeadCell>{wd}</Calendar.HeadCell>
{/each}
</tr>
</Calendar.GridHead>
<Calendar.GridBody>
{#each m.weeks as week (week[0].toString())}
<Calendar.GridRow>
{#each week as date (date.toString())}
<Calendar.Cell {date} month={m.value}>
<Calendar.Day />
</Calendar.Cell>
{/each}
</Calendar.GridRow>
{/each}
</Calendar.GridBody>
</Calendar.Grid>
{/each}
</div>
{/snippet}
</Calendar.Provider>
</section>
<section>
<h2>4 · Fixed weeks + ISO week numbers</h2>
<Calendar.Provider type="single" bind:value={fixedValue} fixedWeeks showWeekNumbers>
{#snippet children({ months, weekdays })}
<Calendar.Header>
<Calendar.PrevButton>‹</Calendar.PrevButton>
<Calendar.Heading />
<Calendar.NextButton>›</Calendar.NextButton>
</Calendar.Header>
{#each months as m (m.value.toString())}
<Calendar.Grid>
<Calendar.GridHead>
<tr>
<Calendar.HeadCell>Wk</Calendar.HeadCell>
{#each weekdays as wd, i (i)}
<Calendar.HeadCell>{wd}</Calendar.HeadCell>
{/each}
</tr>
</Calendar.GridHead>
<Calendar.GridBody>
{#each m.weeks as week (week[0].toString())}
<Calendar.GridRow week={week}>
{#snippet children({ weekNumber })}
<td class="wk" aria-hidden="true">{weekNumber}</td>
{#each week as date (date.toString())}
<Calendar.Cell {date} month={m.value}>
<Calendar.Day />
</Calendar.Cell>
{/each}
{/snippet}
</Calendar.GridRow>
{/each}
</Calendar.GridBody>
</Calendar.Grid>
{/each}
{/snippet}
</Calendar.Provider>
</section>
<section>
<h2>5 · Holidays + weekend decoration</h2>
<p><code>isDateHoliday</code> paints <code>data-holiday</code>. Weekends auto-flag.</p>
<Calendar.Provider type="single" isDateHoliday={isHoliday}>
{#snippet children({ months, weekdays })}
<Calendar.Header>
<Calendar.PrevButton>‹</Calendar.PrevButton>
<Calendar.Heading />
<Calendar.NextButton>›</Calendar.NextButton>
</Calendar.Header>
{#each months as m (m.value.toString())}
<Calendar.Grid>
<Calendar.GridHead>
<tr>
{#each weekdays as wd, i (i)}
<Calendar.HeadCell>{wd}</Calendar.HeadCell>
{/each}
</tr>
</Calendar.GridHead>
<Calendar.GridBody>
{#each m.weeks as week (week[0].toString())}
<Calendar.GridRow>
{#each week as date (date.toString())}
<Calendar.Cell {date} month={m.value}>
<Calendar.Day />
</Calendar.Cell>
{/each}
</Calendar.GridRow>
{/each}
</Calendar.GridBody>
</Calendar.Grid>
{/each}
{/snippet}
</Calendar.Provider>
</section>
<section>
<h2>6 · min / max / unavailable</h2>
<p>Day 15 of every month is unavailable. Range: prev month → +2 months.</p>
<Calendar.Provider
type="single"
bind:value={boundedValue}
minValue={minDate}
maxValue={maxDate}
isDateUnavailable={isUnavailable}
disableDaysOutsideMonth
>
{#snippet children({ months, weekdays })}
<Calendar.Header>
<Calendar.PrevButton>‹</Calendar.PrevButton>
<Calendar.Heading />
<Calendar.NextButton>›</Calendar.NextButton>
</Calendar.Header>
{#each months as m (m.value.toString())}
<Calendar.Grid>
<Calendar.GridHead>
<tr>
{#each weekdays as wd, i (i)}
<Calendar.HeadCell>{wd}</Calendar.HeadCell>
{/each}
</tr>
</Calendar.GridHead>
<Calendar.GridBody>
{#each m.weeks as week (week[0].toString())}
<Calendar.GridRow>
{#each week as date (date.toString())}
<Calendar.Cell {date} month={m.value}>
<Calendar.Day />
</Calendar.Cell>
{/each}
</Calendar.GridRow>
{/each}
</Calendar.GridBody>
</Calendar.Grid>
{/each}
{/snippet}
</Calendar.Provider>
</section>
<section>
<h2>7 · Read-only</h2>
<Calendar.Provider type="single" value={readonlyValue} readonly>
{#snippet children({ months, weekdays })}
<Calendar.Header>
<Calendar.PrevButton>‹</Calendar.PrevButton>
<Calendar.Heading />
<Calendar.NextButton>›</Calendar.NextButton>
</Calendar.Header>
{#each months as m (m.value.toString())}
<Calendar.Grid>
<Calendar.GridHead>
<tr>
{#each weekdays as wd, i (i)}
<Calendar.HeadCell>{wd}</Calendar.HeadCell>
{/each}
</tr>
</Calendar.GridHead>
<Calendar.GridBody>
{#each m.weeks as week (week[0].toString())}
<Calendar.GridRow>
{#each week as date (date.toString())}
<Calendar.Cell {date} month={m.value}>
<Calendar.Day />
</Calendar.Cell>
{/each}
</Calendar.GridRow>
{/each}
</Calendar.GridBody>
</Calendar.Grid>
{/each}
{/snippet}
</Calendar.Provider>
</section>
</div>
<style>
/* ── Page chrome ─────────────────────────────────────────────────────── */
.page {
max-width: 960px;
margin: 2rem auto 6rem;
padding: 0 1.25rem;
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
color: #0f172a;
}
h1 {
font-size: 2rem;
margin: 0 0 0.25rem;
letter-spacing: -0.02em;
}
h2 {
font-size: 1.1rem;
margin: 0 0 0.25rem;
letter-spacing: -0.01em;
}
p {
color: #475569;
margin: 0.25rem 0 1rem;
line-height: 1.5;
}
code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.85em;
background: #eef2f7;
color: #1e293b;
padding: 0.1em 0.35em;
border-radius: 4px;
}
section {
margin-top: 1.5rem;
padding: 1.25rem 1.5rem 1.5rem;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #fff;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}
.state {
display: inline-block;
margin-top: 0.75rem;
font-size: 0.85em;
color: #334155;
background: #f1f5f9;
padding: 0.25rem 0.5rem;
border-radius: 6px;
}
.two-grids {
display: flex;
gap: 1.5rem;
flex-wrap: wrap;
}
/* ── Root ─────────────────────────────────────────────────────────────── */
:global([data-calendar]) {
position: relative;
display: inline-block;
padding: 0.75rem;
background: #fafbfc;
border: 1px solid #e2e8f0;
border-radius: 10px;
min-width: 280px;
}
/* ── Header / Heading / Buttons ──────────────────────────────────────── */
:global([data-calendar-header]) {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
:global([data-calendar-heading]) {
flex: 1 1 auto;
text-align: center;
font-weight: 600;
font-size: 0.95rem;
color: #0f172a;
text-transform: capitalize;
}
:global([data-calendar-prev-button]),
:global([data-calendar-next-button]) {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
border: 1px solid #cbd5e1;
border-radius: 8px;
background: #fff;
cursor: pointer;
font-size: 16px;
line-height: 1;
color: #334155;
transition: background-color 120ms, border-color 120ms, color 120ms;
}
:global([data-calendar-prev-button]:hover:not([data-disabled])),
:global([data-calendar-next-button]:hover:not([data-disabled])) {
background: #f1f5f9;
border-color: #94a3b8;
color: #0f172a;
}
:global([data-calendar-prev-button]:focus-visible),
:global([data-calendar-next-button]:focus-visible) {
outline: 2px solid #0ea5e9;
outline-offset: 2px;
}
:global([data-calendar-prev-button][data-disabled]),
:global([data-calendar-next-button][data-disabled]) {
opacity: 0.4;
cursor: not-allowed;
}
/* ── Select (month / year) ───────────────────────────────────────────── */
:global([data-calendar-month-select]),
:global([data-calendar-year-select]) {
border: 1px solid #cbd5e1;
border-radius: 8px;
padding: 0.3rem 0.5rem;
background: #fff;
font-size: 0.85rem;
color: #0f172a;
cursor: pointer;
transition: border-color 120ms;
}
:global([data-calendar-month-select]:hover),
:global([data-calendar-year-select]:hover) {
border-color: #94a3b8;
}
:global([data-calendar-month-select]:focus-visible),
:global([data-calendar-year-select]:focus-visible) {
outline: 2px solid #0ea5e9;
outline-offset: 2px;
}
/* ── Grid ────────────────────────────────────────────────────────────── */
:global([data-calendar-grid]) {
border-collapse: separate;
border-spacing: 2px;
width: 100%;
}
:global([data-calendar-head-cell]) {
font-size: 11px;
color: #64748b;
font-weight: 600;
padding: 4px 0;
text-transform: uppercase;
letter-spacing: 0.05em;
}
:global([data-calendar-cell]) {
padding: 0;
text-align: center;
vertical-align: middle;
}
/* ── Day ─────────────────────────────────────────────────────────────── */
:global([data-calendar-day]) {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
margin: 0 auto;
border-radius: 8px;
cursor: pointer;
user-select: none;
font-size: 0.875rem;
font-variant-numeric: tabular-nums;
color: #0f172a;
background: transparent;
transition: background-color 100ms, color 100ms, transform 80ms;
}
:global([data-calendar-day]:hover:not([data-disabled]):not([data-unavailable]):not([data-selected])) {
background: #e0f2fe;
color: #075985;
}
:global([data-calendar-day]:active:not([data-disabled]):not([data-unavailable])) {
transform: scale(0.92);
}
/* Focus ring for roving tabindex */
:global([data-calendar-day]:focus-visible) {
outline: 2px solid #0ea5e9;
outline-offset: 2px;
z-index: 1;
}
/* Today — subtle ring */
:global([data-calendar-day][data-today]:not([data-selected])) {
box-shadow: inset 0 0 0 1.5px #0ea5e9;
color: #075985;
font-weight: 600;
}
/* Selected — solid blue */
:global([data-calendar-day][data-selected]) {
background: #0ea5e9;
color: #fff;
font-weight: 700;
box-shadow: 0 2px 4px rgba(14, 165, 233, 0.35);
}
:global([data-calendar-day][data-selected]:hover) {
background: #0284c7;
}
/* Outside current month — dimmed */
:global([data-calendar-day][data-outside-month]:not([data-selected])) {
color: #cbd5e1;
}
/* Disabled */
:global([data-calendar-day][data-disabled]) {
color: #cbd5e1;
cursor: not-allowed;
background: transparent !important;
}
/* Unavailable */
:global([data-calendar-day][data-unavailable]:not([data-selected])) {
color: #94a3b8;
text-decoration: line-through;
cursor: not-allowed;
}
/* Holiday — red dot marker + color */
:global([data-calendar-day][data-holiday]:not([data-selected])) {
color: #dc2626;
position: relative;
}
:global([data-calendar-day][data-holiday]:not([data-selected])::after) {
content: '';
position: absolute;
bottom: 4px;
left: 50%;
width: 4px;
height: 4px;
border-radius: 50%;
background: #dc2626;
transform: translateX(-50%);
}
/* Weekend — slightly muted unless holiday/selected */
:global(
[data-calendar-day][data-weekend]:not([data-selected]):not([data-holiday]):not(
[data-outside-month]
)
) {
color: #64748b;
}
/* Week-number column */
.wk {
font-size: 10px;
font-weight: 600;
color: #94a3b8;
text-align: center;
padding: 0 6px;
border-right: 1px dashed #e2e8f0;
vertical-align: middle;
}
</style>

@ -0,0 +1,766 @@
<script lang="ts">
import * as Command from '$soma/components/command';
import type { FileRejection } from '$soma/components/file-upload';
// ── Section 1: app commands ────────────────────────────────────────────
let selected = $state('');
let lastAction = $state<string | null>(null);
const run = (id: string) => {
lastAction = id;
};
// Frozen constant arrays — stable references across renders.
const commands = Object.freeze([
{ id: 'file.new', label: 'New File', keywords: ['create', 'add'], group: 'File' },
{ id: 'file.open', label: 'Open File…', keywords: ['browse'], group: 'File' },
{ id: 'file.save', label: 'Save File', keywords: ['write'], group: 'File' },
{ id: 'file.saveAs', label: 'Save As…', keywords: ['export'], group: 'File' },
{ id: 'edit.undo', label: 'Undo', keywords: ['revert'], group: 'Edit' },
{ id: 'edit.redo', label: 'Redo', keywords: ['restore'], group: 'Edit' },
{ id: 'edit.cut', label: 'Cut', keywords: [], group: 'Edit' },
{ id: 'edit.copy', label: 'Copy', keywords: ['duplicate'], group: 'Edit' },
{ id: 'edit.paste', label: 'Paste', keywords: [], group: 'Edit' },
{ id: 'view.zoomIn', label: 'Zoom In', keywords: ['larger', 'bigger'], group: 'View' },
{ id: 'view.zoomOut', label: 'Zoom Out', keywords: ['smaller'], group: 'View' },
{ id: 'view.toggleSidebar', label: 'Toggle Sidebar', keywords: ['panel'], group: 'View' },
{ id: 'terminal.new', label: 'New Terminal', keywords: ['shell', 'bash'], group: 'Terminal' },
{ id: 'terminal.clear', label: 'Clear Terminal', keywords: ['reset'], group: 'Terminal' }
] as const);
const groups = Object.freeze(['File', 'Edit', 'View', 'Terminal'] as const);
// ── Section 2: emoji grid ──────────────────────────────────────────────
const emojis = Object.freeze([
'😀', '😂', '🥰', '😎', '🤔', '🙃', '🤯', '🥳', '😅', '🤩',
'🙄', '😴', '🤤', '🫡', '🤗', '😇', '🙏', '💪', '👀', '🎉',
'🔥', '✨', '🚀', '💡', '❤️', '💙', '💚', '⭐', '🌈', '🌙'
] as const);
let picked = $state('');
const pickEmoji = (e: string) => {
picked = e;
};
// ── Section 3: async ──────────────────────────────────────────────────
let loading = $state(false);
let asyncItems = $state<{ id: string; label: string }[]>([]);
async function fetchItems() {
loading = true;
await new Promise((r) => setTimeout(r, 900));
asyncItems = Array.from({ length: 6 }, (_, i) => ({
id: `async-${i}`,
label: `Result ${i + 1} (loaded at ${new Date().toLocaleTimeString()})`
}));
loading = false;
}
// ── Section 5: Command.Dialog ─────────────────────────────────────────
let dialogOpen = $state(false);
let lastDialogAction = $state<string | null>(null);
const runDialog = (id: string) => {
lastDialogAction = id;
dialogOpen = false;
};
// ── Section 6: imperative API ─────────────────────────────────────────
let apiValue = $state('');
let apiSearch = $state('');
// Unused import guard
void undefined as unknown as FileRejection;
</script>
<svelte:head>
<title>Command · Soma</title>
</svelte:head>
<div class="page">
<header>
<p class="eyebrow">Soma · Overlay</p>
<h1>Command</h1>
<p class="lede">
A command palette with fuzzy scoring, vim-style navigation
(<kbd>Ctrl+N</kbd>/<kbd>Ctrl+P</kbd>), grid mode, keyword aliases, empty/loading states,
groups that auto-hide when empty, and link items.
</p>
</header>
<!-- ── 1. App command palette ──────────────────────────────────────────── -->
<section>
<div class="head">
<h2>1 · App commands</h2>
<span class="badge">fuzzy · vim · keywords</span>
</div>
<Command.Provider bind:value={selected}>
<div class="palette">
<div class="search-bar">
<span class="search-icon" aria-hidden="true">⌕</span>
<Command.Input placeholder="Type a command or search…" />
<kbd class="palette-kbd">⌘K</kbd>
</div>
<Command.List>
<Command.Empty>
<div class="empty">No commands found.</div>
</Command.Empty>
{#each groups as groupName (groupName)}
<Command.Group value={groupName}>
<Command.GroupHeading>
<span class="group-heading">{groupName}</span>
</Command.GroupHeading>
<Command.GroupItems>
{#each commands.filter((c) => c.group === groupName) as c (c.id)}
<Command.Item value={c.id} keywords={c.keywords} onSelect={run}>
<span class="item-icon" aria-hidden="true">›</span>
<span class="item-label">{c.label}</span>
<span class="item-hint">{c.group}</span>
</Command.Item>
{/each}
</Command.GroupItems>
</Command.Group>
{/each}
<Command.Separator />
<Command.Group value="links">
<Command.GroupHeading>
<span class="group-heading">Links</span>
</Command.GroupHeading>
<Command.GroupItems>
<Command.LinkItem
value="docs"
href="https://svelte.dev"
keywords={['help', 'manual', 'documentation']}
>
<span class="item-icon" aria-hidden="true">↗</span>
<span class="item-label">Open documentation</span>
<span class="item-hint">svelte.dev</span>
</Command.LinkItem>
</Command.GroupItems>
</Command.Group>
</Command.List>
<div class="footer">
<span>
<kbd>↑</kbd><kbd>↓</kbd> navigate ·
<kbd>↵</kbd> select ·
<kbd>esc</kbd> cancel
</span>
<span>
<kbd>⌃N</kbd>/<kbd>⌃P</kbd> vim
</span>
</div>
</div>
</Command.Provider>
<p class="log">
last action: <code>{lastAction ?? '—'}</code> · highlighted:
<code>{selected || '—'}</code>
</p>
</section>
<!-- ── 2. Grid (emoji picker) ──────────────────────────────────────────── -->
<section>
<div class="head">
<h2>2 · Grid mode — emoji picker</h2>
<span class="badge">columns=10 · Arrow keys in 2D</span>
</div>
<Command.Provider columns={10} label="Pick an emoji" bind:value={picked}>
<div class="palette">
<div class="search-bar">
<Command.Input placeholder="Filter emojis…" />
</div>
<Command.List>
<div class="grid">
<Command.Empty>
<div class="empty">Nothing here.</div>
</Command.Empty>
{#each emojis as e (e)}
<Command.Item value={e} onSelect={pickEmoji}>
<span class="emoji">{e}</span>
</Command.Item>
{/each}
</div>
</Command.List>
</div>
</Command.Provider>
<p class="log">picked: <code>{picked || '—'}</code></p>
</section>
<!-- ── 3. Loading / Empty ──────────────────────────────────────────────── -->
<section>
<div class="head">
<h2>3 · Async loading + empty</h2>
<span class="badge">Loading · indeterminate progress</span>
</div>
<div class="controls">
<button type="button" class="btn" onclick={fetchItems} disabled={loading}>
{loading ? 'Loading…' : 'Load results'}
</button>
<button
type="button"
class="btn secondary"
onclick={() => (asyncItems = [])}
disabled={asyncItems.length === 0}
>
Clear
</button>
</div>
<Command.Provider>
<div class="palette">
<div class="search-bar">
<Command.Input placeholder="Search results…" />
</div>
<Command.List>
{#if loading}
<Command.Loading>
<div class="loading">
<span class="spinner" aria-hidden="true"></span>
<span>Fetching results…</span>
</div>
</Command.Loading>
{/if}
{#if asyncItems.length > 0}
<Command.Group value="results">
<Command.GroupHeading>
<span class="group-heading">Results</span>
</Command.GroupHeading>
<Command.GroupItems>
{#each asyncItems as item (item.id)}
<Command.Item value={item.id} onSelect={run}>
<span class="item-label">{item.label}</span>
</Command.Item>
{/each}
</Command.GroupItems>
</Command.Group>
{:else if !loading}
<Command.Empty>
<div class="empty">Press "Load results" to fetch data.</div>
</Command.Empty>
{/if}
</Command.List>
</div>
</Command.Provider>
</section>
<!-- ── 4. No filter (manual) ───────────────────────────────────────────── -->
<section>
<div class="head">
<h2>4 · External filter (shouldFilter=false)</h2>
<span class="badge">consumer controls items</span>
</div>
<p class="sub-text">
When <code>shouldFilter=false</code> the palette doesn't run the scorer — the list you
provide is shown as-is. Useful when results come from a remote API.
</p>
<Command.Provider shouldFilter={false}>
<div class="palette">
<div class="search-bar">
<Command.Input placeholder="Search (server-side)…" />
</div>
<Command.List>
{#each commands.slice(0, 5) as c (c.id)}
<Command.Item value={c.id} onSelect={run}>
<span class="item-label">{c.label}</span>
</Command.Item>
{/each}
</Command.List>
</div>
</Command.Provider>
</section>
<!-- ── 5. Command.Dialog composition ───────────────────────────────────── -->
<section>
<div class="head">
<h2>5 · Command.Dialog — composition helper</h2>
<span class="badge">Dialog + Command in one</span>
</div>
<p class="sub-text">
<code>Command.Dialog</code> packages a Dialog with Overlay + Content and a Command
palette inside. All Command props are pass-through; the Dialog opens/closes via
<code>bind:open</code>.
</p>
<div class="controls">
<button type="button" class="btn" onclick={() => (dialogOpen = true)}>
<span>Open palette</span>
<kbd class="palette-kbd">⌘K</kbd>
</button>
<span class="log">last: <code>{lastDialogAction ?? '—'}</code></span>
</div>
<Command.Dialog bind:open={dialogOpen}>
<div class="dialog-palette">
<div class="search-bar">
<span class="search-icon" aria-hidden="true">⌕</span>
<Command.Input placeholder="Type a command…" />
<button
type="button"
class="close-btn"
aria-label="Close"
onclick={() => (dialogOpen = false)}
>
×
</button>
</div>
<Command.List>
<Command.Empty>
<div class="empty">No results.</div>
</Command.Empty>
{#each groups as groupName (groupName)}
<Command.Group value={groupName}>
<Command.GroupHeading>
<span class="group-heading">{groupName}</span>
</Command.GroupHeading>
<Command.GroupItems>
{#each commands.filter((c) => c.group === groupName) as c (c.id)}
<Command.Item
value={c.id}
keywords={c.keywords}
onSelect={runDialog}
>
<span class="item-icon" aria-hidden="true">›</span>
<span class="item-label">{c.label}</span>
<span class="item-hint">{c.group}</span>
</Command.Item>
{/each}
</Command.GroupItems>
</Command.Group>
{/each}
</Command.List>
</div>
</Command.Dialog>
</section>
<!-- ── 6. Imperative API via snippet props ─────────────────────────────── -->
<section>
<div class="head">
<h2>6 · Imperative API — snippet props</h2>
<span class="badge">next · prev · select · setSearch</span>
</div>
<p class="sub-text">
The Provider's <code>children</code> snippet receives
<code>{`{ value, search, visible, isEmpty, next, prev, select, setValue, setSearch, clearSearch, isVisible, isSelected }`}</code>.
Use it to drive the palette from outside the default UI.
</p>
<Command.Provider bind:value={apiValue} bind:search={apiSearch}>
{#snippet children({ visible, isEmpty, next, prev, first, last, clearSearch })}
<div class="palette">
<div class="search-bar">
<Command.Input placeholder="Search…" />
<button
type="button"
class="close-btn"
aria-label="Clear search"
disabled={apiSearch === ''}
onclick={() => clearSearch()}
>
×
</button>
</div>
<Command.List>
<Command.Empty>
<div class="empty">Nothing matches "{apiSearch}".</div>
</Command.Empty>
{#each commands as c (c.id)}
<Command.Item value={c.id} keywords={c.keywords}>
<span class="item-label">{c.label}</span>
<span class="item-hint">{c.group}</span>
</Command.Item>
{/each}
</Command.List>
<div class="footer api-footer">
<div class="api-buttons">
<button type="button" class="mini" onclick={() => first()}>⏮ First</button>
<button type="button" class="mini" onclick={() => prev()}>↑ Prev</button>
<button type="button" class="mini" onclick={() => next()}>↓ Next</button>
<button type="button" class="mini" onclick={() => last()}>⏭ Last</button>
</div>
<span>
{isEmpty ? 'no match' : `${visible.length} visible`} · selected:
<code>{apiValue || '—'}</code>
</span>
</div>
</div>
{/snippet}
</Command.Provider>
</section>
</div>
<style>
.page {
max-width: 760px;
margin: 3rem auto;
padding: 0 1.5rem 5rem;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
color: #0f172a;
}
header {
margin-bottom: 2.5rem;
}
.eyebrow {
margin: 0 0 0.5rem;
font-size: 0.7rem;
font-weight: 600;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.12em;
}
h1 {
margin: 0 0 0.75rem;
font-size: 2.25rem;
font-weight: 700;
letter-spacing: -0.025em;
}
.lede {
margin: 0;
max-width: 60ch;
color: #475569;
line-height: 1.65;
}
section {
padding: 1.25rem 1.5rem;
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 12px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.03);
margin-bottom: 0.75rem;
}
.head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.75rem;
}
h2 {
margin: 0;
font-size: 0.95rem;
font-weight: 600;
}
.badge {
font-size: 0.7rem;
color: #64748b;
font-family: 'SF Mono', ui-monospace, Menlo, monospace;
}
.sub-text {
margin: 0 0 0.75rem;
font-size: 0.82rem;
color: #64748b;
}
section code {
padding: 0.05rem 0.35rem;
background: #f1f5f9;
border-radius: 4px;
font-size: 0.82em;
color: #334155;
font-family: 'SF Mono', ui-monospace, Menlo, monospace;
}
.palette {
display: flex;
flex-direction: column;
border: 1px solid rgba(15, 23, 42, 0.12);
border-radius: 12px;
background: #ffffff;
overflow: hidden;
box-shadow: 0 12px 24px -8px rgba(15, 23, 42, 0.08);
}
.search-bar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 0.9rem;
border-bottom: 1px solid #e2e8f0;
background: #f8fafc;
}
.search-icon {
color: #94a3b8;
font-size: 1rem;
}
:global([data-command-input]) {
flex: 1 1 auto;
min-width: 0;
padding: 0.1rem 0;
border: none;
background: transparent;
outline: none;
font: inherit;
font-size: 0.9rem;
color: #0f172a;
}
:global([data-command-input]::placeholder) {
color: #94a3b8;
}
.palette-kbd {
font-family: 'SF Mono', ui-monospace, Menlo, monospace;
font-size: 0.7rem;
color: #64748b;
padding: 0.05rem 0.35rem;
border: 1px solid #cbd5e1;
border-bottom-width: 2px;
border-radius: 4px;
background: white;
}
:global([data-command-list]) {
max-height: 360px;
overflow-y: auto;
outline: none;
padding: 0.25rem;
}
:global([data-command-group][data-empty]) {
display: none;
}
.group-heading {
display: block;
padding: 0.5rem 0.6rem 0.2rem;
font-size: 0.68rem;
font-weight: 600;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.08em;
}
:global([data-command-item]),
:global([data-command-link-item]) {
display: flex;
align-items: center;
gap: 0.55rem;
padding: 0.45rem 0.6rem;
border-radius: 6px;
cursor: default;
font-size: 0.88rem;
color: inherit;
text-decoration: none;
}
:global([data-command-item][data-selected]),
:global([data-command-link-item][data-selected]) {
background: #1e293b;
color: white;
}
:global([data-command-item][data-disabled]),
:global([data-command-link-item][data-disabled]) {
color: #94a3b8;
pointer-events: none;
}
.item-icon {
color: #94a3b8;
width: 1rem;
text-align: center;
}
:global([data-command-item][data-selected]) .item-icon,
:global([data-command-link-item][data-selected]) .item-icon {
color: #cbd5e1;
}
.item-label {
flex: 1 1 auto;
}
.item-hint {
color: #94a3b8;
font-size: 0.74rem;
}
:global([data-command-item][data-selected]) .item-hint,
:global([data-command-link-item][data-selected]) .item-hint {
color: #cbd5e1;
}
:global([data-command-separator]) {
display: block;
height: 1px;
background: #e2e8f0;
margin: 4px 0.5rem;
}
.empty {
padding: 1.5rem 0.75rem;
text-align: center;
color: #94a3b8;
font-size: 0.85rem;
}
.footer {
display: flex;
justify-content: space-between;
padding: 0.5rem 0.9rem;
border-top: 1px solid #e2e8f0;
background: #f8fafc;
color: #64748b;
font-size: 0.72rem;
}
.footer kbd {
padding: 0.02rem 0.3rem;
font-family: 'SF Mono', ui-monospace, Menlo, monospace;
font-size: 0.7rem;
border: 1px solid #cbd5e1;
border-radius: 3px;
background: white;
margin: 0 1px;
}
.log {
margin-top: 0.75rem;
font-size: 0.8rem;
color: #64748b;
}
/* Grid variant */
.grid {
display: grid;
grid-template-columns: repeat(10, 1fr);
gap: 2px;
padding: 0.4rem;
}
.grid :global([data-command-item]) {
aspect-ratio: 1;
padding: 0;
justify-content: center;
}
.emoji {
font-size: 1.35rem;
line-height: 1;
}
.controls {
display: flex;
gap: 0.5rem;
margin-bottom: 0.75rem;
align-items: center;
}
.btn {
padding: 0.35rem 0.75rem;
font: inherit;
font-size: 0.82rem;
font-weight: 500;
color: white;
background: #1e293b;
border: none;
border-radius: 6px;
cursor: pointer;
}
.btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.btn.secondary {
color: #334155;
background: #f1f5f9;
}
.btn kbd {
margin-left: 0.35rem;
}
/* Command.Dialog floating overlay */
:global([data-dialog-overlay]) {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.45);
backdrop-filter: blur(4px);
z-index: 100;
}
:global([data-dialog-content]) {
position: fixed;
top: 20%;
left: 50%;
transform: translateX(-50%);
width: min(90vw, 560px);
z-index: 101;
outline: none;
}
.dialog-palette {
background: white;
border-radius: 14px;
overflow: hidden;
box-shadow:
0 24px 48px -12px rgba(15, 23, 42, 0.4),
0 4px 8px -4px rgba(15, 23, 42, 0.12);
}
.close-btn {
width: 24px;
height: 24px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border: none;
background: transparent;
color: #64748b;
font-size: 1rem;
cursor: pointer;
border-radius: 4px;
}
.close-btn:hover:not(:disabled) {
background: #f1f5f9;
color: #0f172a;
}
.close-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.api-footer {
align-items: center;
}
.api-buttons {
display: flex;
gap: 0.3rem;
}
.mini {
padding: 0.2rem 0.5rem;
font: inherit;
font-size: 0.72rem;
color: #334155;
background: white;
border: 1px solid #cbd5e1;
border-radius: 4px;
cursor: pointer;
}
.mini:hover {
border-color: #94a3b8;
}
.loading {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 1rem;
color: #64748b;
font-size: 0.85rem;
}
.spinner {
width: 14px;
height: 14px;
border: 2px solid #cbd5e1;
border-top-color: #2563eb;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
kbd {
display: inline-block;
}
.lede kbd {
font-family: 'SF Mono', ui-monospace, Menlo, monospace;
font-size: 0.72rem;
padding: 0.05rem 0.35rem;
border: 1px solid #cbd5e1;
border-bottom-width: 2px;
border-radius: 4px;
background: #f8fafc;
color: #334155;
vertical-align: middle;
}
</style>

@ -0,0 +1,558 @@
<script lang="ts">
import { RangeCalendar } from '$soma/components';
import type { DateRange, DateValue } from '$lib/util/dias';
import { CalendarDate, today, getLocalTimeZone } from '$lib/util/dias';
const t = today(getLocalTimeZone());
const ref = new CalendarDate(t.year, t.month, t.day);
// Demo 1 — basic range
let range1 = $state<DateRange>({ start: ref, end: ref.add({ days: 6 }) });
// Demo 2 — two months, paged navigation
let range2 = $state<DateRange>({ start: undefined, end: undefined });
// Demo 3 — fixedWeeks + ISO week numbers
let range3 = $state<DateRange>({ start: undefined, end: undefined });
// Demo 4 — min/max + holidays
const sampleHolidays: DateValue[] = [
new CalendarDate(t.year, 1, 1),
new CalendarDate(t.year, 5, 1),
new CalendarDate(t.year, 12, 25)
];
const isHoliday = (d: DateValue) => sampleHolidays.some((h) => h.compare(d) === 0);
let range4 = $state<DateRange>({ start: undefined, end: undefined });
// Demo 5 — minDays 2, maxDays 14
let range5 = $state<DateRange>({ start: undefined, end: undefined });
// Demo 6 — allowSingleDay false
let range6 = $state<DateRange>({ start: undefined, end: undefined });
// Demo 7 — readonly
const readonlyRange: DateRange = { start: ref.subtract({ days: 3 }), end: ref.add({ days: 3 }) };
function fmt(r: DateRange): string {
if (!r.start) return '—';
if (!r.end) return `${r.start.toString()} → …`;
return `${r.start.toString()} → ${r.end.toString()}`;
}
</script>
<svelte:head>
<title>RangeCalendar · Soma</title>
</svelte:head>
<div class="page">
<h1>RangeCalendar</h1>
<p>
Date-range picker. Beyond parity with ark/bits/radix/react-aria: live
<strong>hover preview</strong>, <code>minDays</code>/<code>maxDays</code>,
<code>allowSingleDay</code>, ISO week numbers, holidays, weekend flag, and
navigation announcements.
</p>
<section>
<h2>1 · Basic range (click start, click end)</h2>
<p>Hover between clicks shows live preview.</p>
<RangeCalendar.Provider bind:value={range1} initialFocus>
{#snippet children({ months, weekdays })}
<RangeCalendar.Header>
<RangeCalendar.PrevButton>‹</RangeCalendar.PrevButton>
<RangeCalendar.Heading />
<RangeCalendar.NextButton>›</RangeCalendar.NextButton>
</RangeCalendar.Header>
{#each months as m (m.value.toString())}
<RangeCalendar.Grid>
<RangeCalendar.GridHead>
<tr>
{#each weekdays as wd, i (i)}
<RangeCalendar.HeadCell>{wd}</RangeCalendar.HeadCell>
{/each}
</tr>
</RangeCalendar.GridHead>
<RangeCalendar.GridBody>
{#each m.weeks as week (week[0].toString())}
<RangeCalendar.GridRow>
{#each week as date (date.toString())}
<RangeCalendar.Cell {date} month={m.value}>
<RangeCalendar.Day />
</RangeCalendar.Cell>
{/each}
</RangeCalendar.GridRow>
{/each}
</RangeCalendar.GridBody>
</RangeCalendar.Grid>
{/each}
{/snippet}
</RangeCalendar.Provider>
<p class="state">Range: <code>{fmt(range1)}</code></p>
</section>
<section>
<h2>2 · Two months + paged navigation + MonthSelect / YearSelect</h2>
<RangeCalendar.Provider bind:value={range2} numberOfMonths={2} pagedNavigation>
{#snippet children({ months, weekdays })}
<RangeCalendar.Header>
<RangeCalendar.PrevButton>‹</RangeCalendar.PrevButton>
<RangeCalendar.MonthSelect />
<RangeCalendar.YearSelect />
<RangeCalendar.NextButton>›</RangeCalendar.NextButton>
</RangeCalendar.Header>
<div class="two-grids">
{#each months as m (m.value.toString())}
<RangeCalendar.Grid>
<RangeCalendar.GridHead>
<tr>
{#each weekdays as wd, i (i)}
<RangeCalendar.HeadCell>{wd}</RangeCalendar.HeadCell>
{/each}
</tr>
</RangeCalendar.GridHead>
<RangeCalendar.GridBody>
{#each m.weeks as week (week[0].toString())}
<RangeCalendar.GridRow>
{#each week as date (date.toString())}
<RangeCalendar.Cell {date} month={m.value}>
<RangeCalendar.Day />
</RangeCalendar.Cell>
{/each}
</RangeCalendar.GridRow>
{/each}
</RangeCalendar.GridBody>
</RangeCalendar.Grid>
{/each}
</div>
{/snippet}
</RangeCalendar.Provider>
<p class="state">Range: <code>{fmt(range2)}</code></p>
</section>
<section>
<h2>3 · Fixed weeks + ISO week numbers</h2>
<RangeCalendar.Provider bind:value={range3} fixedWeeks showWeekNumbers>
{#snippet children({ months, weekdays })}
<RangeCalendar.Header>
<RangeCalendar.PrevButton>‹</RangeCalendar.PrevButton>
<RangeCalendar.Heading />
<RangeCalendar.NextButton>›</RangeCalendar.NextButton>
</RangeCalendar.Header>
{#each months as m (m.value.toString())}
<RangeCalendar.Grid>
<RangeCalendar.GridHead>
<tr>
<RangeCalendar.HeadCell>Wk</RangeCalendar.HeadCell>
{#each weekdays as wd, i (i)}
<RangeCalendar.HeadCell>{wd}</RangeCalendar.HeadCell>
{/each}
</tr>
</RangeCalendar.GridHead>
<RangeCalendar.GridBody>
{#each m.weeks as week (week[0].toString())}
<RangeCalendar.GridRow {week}>
{#snippet children({ weekNumber })}
<td class="wk" aria-hidden="true">{weekNumber}</td>
{#each week as date (date.toString())}
<RangeCalendar.Cell {date} month={m.value}>
<RangeCalendar.Day />
</RangeCalendar.Cell>
{/each}
{/snippet}
</RangeCalendar.GridRow>
{/each}
</RangeCalendar.GridBody>
</RangeCalendar.Grid>
{/each}
{/snippet}
</RangeCalendar.Provider>
</section>
<section>
<h2>4 · Holidays</h2>
<RangeCalendar.Provider bind:value={range4} isDateHoliday={isHoliday}>
{#snippet children({ months, weekdays })}
<RangeCalendar.Header>
<RangeCalendar.PrevButton>‹</RangeCalendar.PrevButton>
<RangeCalendar.Heading />
<RangeCalendar.NextButton>›</RangeCalendar.NextButton>
</RangeCalendar.Header>
{#each months as m (m.value.toString())}
<RangeCalendar.Grid>
<RangeCalendar.GridHead>
<tr>
{#each weekdays as wd, i (i)}
<RangeCalendar.HeadCell>{wd}</RangeCalendar.HeadCell>
{/each}
</tr>
</RangeCalendar.GridHead>
<RangeCalendar.GridBody>
{#each m.weeks as week (week[0].toString())}
<RangeCalendar.GridRow>
{#each week as date (date.toString())}
<RangeCalendar.Cell {date} month={m.value}>
<RangeCalendar.Day />
</RangeCalendar.Cell>
{/each}
</RangeCalendar.GridRow>
{/each}
</RangeCalendar.GridBody>
</RangeCalendar.Grid>
{/each}
{/snippet}
</RangeCalendar.Provider>
</section>
<section>
<h2>5 · minDays 2 + maxDays 14</h2>
<p>Single-day ranges rejected; ranges longer than 14 days get clamped to 14.</p>
<RangeCalendar.Provider bind:value={range5} minDays={2} maxDays={14}>
{#snippet children({ months, weekdays })}
<RangeCalendar.Header>
<RangeCalendar.PrevButton>‹</RangeCalendar.PrevButton>
<RangeCalendar.Heading />
<RangeCalendar.NextButton>›</RangeCalendar.NextButton>
</RangeCalendar.Header>
{#each months as m (m.value.toString())}
<RangeCalendar.Grid>
<RangeCalendar.GridHead>
<tr>
{#each weekdays as wd, i (i)}
<RangeCalendar.HeadCell>{wd}</RangeCalendar.HeadCell>
{/each}
</tr>
</RangeCalendar.GridHead>
<RangeCalendar.GridBody>
{#each m.weeks as week (week[0].toString())}
<RangeCalendar.GridRow>
{#each week as date (date.toString())}
<RangeCalendar.Cell {date} month={m.value}>
<RangeCalendar.Day />
</RangeCalendar.Cell>
{/each}
</RangeCalendar.GridRow>
{/each}
</RangeCalendar.GridBody>
</RangeCalendar.Grid>
{/each}
{/snippet}
</RangeCalendar.Provider>
<p class="state">Range: <code>{fmt(range5)}</code></p>
</section>
<section>
<h2>6 · allowSingleDay = false</h2>
<p>Same-day second click is rejected.</p>
<RangeCalendar.Provider bind:value={range6} allowSingleDay={false}>
{#snippet children({ months, weekdays })}
<RangeCalendar.Header>
<RangeCalendar.PrevButton>‹</RangeCalendar.PrevButton>
<RangeCalendar.Heading />
<RangeCalendar.NextButton>›</RangeCalendar.NextButton>
</RangeCalendar.Header>
{#each months as m (m.value.toString())}
<RangeCalendar.Grid>
<RangeCalendar.GridHead>
<tr>
{#each weekdays as wd, i (i)}
<RangeCalendar.HeadCell>{wd}</RangeCalendar.HeadCell>
{/each}
</tr>
</RangeCalendar.GridHead>
<RangeCalendar.GridBody>
{#each m.weeks as week (week[0].toString())}
<RangeCalendar.GridRow>
{#each week as date (date.toString())}
<RangeCalendar.Cell {date} month={m.value}>
<RangeCalendar.Day />
</RangeCalendar.Cell>
{/each}
</RangeCalendar.GridRow>
{/each}
</RangeCalendar.GridBody>
</RangeCalendar.Grid>
{/each}
{/snippet}
</RangeCalendar.Provider>
</section>
<section>
<h2>7 · Read-only</h2>
<RangeCalendar.Provider value={readonlyRange} readonly>
{#snippet children({ months, weekdays })}
<RangeCalendar.Header>
<RangeCalendar.PrevButton>‹</RangeCalendar.PrevButton>
<RangeCalendar.Heading />
<RangeCalendar.NextButton>›</RangeCalendar.NextButton>
</RangeCalendar.Header>
{#each months as m (m.value.toString())}
<RangeCalendar.Grid>
<RangeCalendar.GridHead>
<tr>
{#each weekdays as wd, i (i)}
<RangeCalendar.HeadCell>{wd}</RangeCalendar.HeadCell>
{/each}
</tr>
</RangeCalendar.GridHead>
<RangeCalendar.GridBody>
{#each m.weeks as week (week[0].toString())}
<RangeCalendar.GridRow>
{#each week as date (date.toString())}
<RangeCalendar.Cell {date} month={m.value}>
<RangeCalendar.Day />
</RangeCalendar.Cell>
{/each}
</RangeCalendar.GridRow>
{/each}
</RangeCalendar.GridBody>
</RangeCalendar.Grid>
{/each}
{/snippet}
</RangeCalendar.Provider>
</section>
</div>
<style>
.page {
max-width: 960px;
margin: 2rem auto 6rem;
padding: 0 1.25rem;
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
color: #0f172a;
}
h1 { font-size: 2rem; margin: 0 0 .25rem; letter-spacing: -0.02em; }
h2 { font-size: 1.1rem; margin: 0 0 .25rem; letter-spacing: -0.01em; }
p { color: #475569; margin: .25rem 0 1rem; line-height: 1.5; }
code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.85em;
background: #eef2f7;
color: #1e293b;
padding: 0.1em 0.35em;
border-radius: 4px;
}
section {
margin-top: 1.5rem;
padding: 1.25rem 1.5rem 1.5rem;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #fff;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}
.state {
display: inline-block;
margin-top: 0.75rem;
font-size: 0.85em;
color: #334155;
background: #f1f5f9;
padding: 0.25rem 0.5rem;
border-radius: 6px;
}
.two-grids {
display: flex;
gap: 1.5rem;
flex-wrap: wrap;
}
/* ── Root ─────────────────────────────────────────────────────────────── */
:global([data-range-calendar]) {
position: relative;
display: inline-block;
padding: 0.75rem;
background: #fafbfc;
border: 1px solid #e2e8f0;
border-radius: 10px;
min-width: 280px;
}
:global([data-range-calendar-header]) {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
:global([data-range-calendar-heading]) {
flex: 1 1 auto;
text-align: center;
font-weight: 600;
font-size: 0.95rem;
color: #0f172a;
text-transform: capitalize;
}
:global([data-range-calendar-prev-button]),
:global([data-range-calendar-next-button]) {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
border: 1px solid #cbd5e1;
border-radius: 8px;
background: #fff;
cursor: pointer;
font-size: 16px;
line-height: 1;
color: #334155;
transition: background-color 120ms, border-color 120ms, color 120ms;
}
:global([data-range-calendar-prev-button]:hover:not([data-disabled])),
:global([data-range-calendar-next-button]:hover:not([data-disabled])) {
background: #f1f5f9;
border-color: #94a3b8;
color: #0f172a;
}
:global([data-range-calendar-prev-button]:focus-visible),
:global([data-range-calendar-next-button]:focus-visible) {
outline: 2px solid #0ea5e9;
outline-offset: 2px;
}
:global([data-range-calendar-prev-button][data-disabled]),
:global([data-range-calendar-next-button][data-disabled]) {
opacity: 0.4;
cursor: not-allowed;
}
:global([data-range-calendar-month-select]),
:global([data-range-calendar-year-select]) {
border: 1px solid #cbd5e1;
border-radius: 8px;
padding: 0.3rem 0.5rem;
background: #fff;
font-size: 0.85rem;
color: #0f172a;
cursor: pointer;
}
:global([data-range-calendar-month-select]:focus-visible),
:global([data-range-calendar-year-select]:focus-visible) {
outline: 2px solid #0ea5e9;
outline-offset: 2px;
}
:global([data-range-calendar-grid]) {
border-collapse: separate;
border-spacing: 0;
width: 100%;
}
:global([data-range-calendar-head-cell]) {
font-size: 11px;
color: #64748b;
font-weight: 600;
padding: 4px 0;
text-transform: uppercase;
letter-spacing: 0.05em;
}
:global([data-range-calendar-cell]) {
padding: 1px 0;
text-align: center;
vertical-align: middle;
position: relative;
}
/* Range-bar: stretch the hover/selected background across contiguous days */
:global([data-range-calendar-cell][data-in-range]),
:global([data-range-calendar-cell][data-range-start]:not([data-range-end])),
:global([data-range-calendar-cell][data-range-end]:not([data-range-start])) {
background: #e0f2fe;
}
:global([data-range-calendar-cell][data-range-start]:not([data-range-end])) {
border-top-left-radius: 8px;
border-bottom-left-radius: 8px;
}
:global([data-range-calendar-cell][data-range-end]:not([data-range-start])) {
border-top-right-radius: 8px;
border-bottom-right-radius: 8px;
}
/* Preview range */
:global([data-range-calendar-cell][data-in-preview-range]),
:global([data-range-calendar-cell][data-preview-start]:not([data-preview-end])),
:global([data-range-calendar-cell][data-preview-end]:not([data-preview-start])) {
background: #dbeafe;
}
/* ── Day ─────────────────────────────────────────────────────────────── */
:global([data-range-calendar-day]) {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
margin: 0 auto;
border-radius: 8px;
cursor: pointer;
user-select: none;
font-size: 0.875rem;
font-variant-numeric: tabular-nums;
color: #0f172a;
background: transparent;
transition: background-color 100ms, color 100ms, transform 80ms;
}
:global([data-range-calendar-day]:hover:not([data-disabled]):not([data-unavailable])) {
background: rgba(14, 165, 233, 0.15);
}
:global([data-range-calendar-day]:focus-visible) {
outline: 2px solid #0ea5e9;
outline-offset: 2px;
z-index: 1;
}
:global(
[data-range-calendar-day][data-today]:not([data-selected]):not([data-in-range]):not(
[data-in-preview-range]
)
) {
box-shadow: inset 0 0 0 1.5px #0ea5e9;
font-weight: 600;
}
/* Range endpoints — solid blue */
:global([data-range-calendar-day][data-range-start]),
:global([data-range-calendar-day][data-range-end]) {
background: #0ea5e9;
color: #fff;
font-weight: 700;
box-shadow: 0 2px 4px rgba(14, 165, 233, 0.35);
}
/* Preview endpoints — outlined blue */
:global([data-range-calendar-day][data-preview-start]:not([data-range-start]):not([data-range-end])),
:global([data-range-calendar-day][data-preview-end]:not([data-range-start]):not([data-range-end])) {
background: #bae6fd;
color: #0c4a6e;
font-weight: 600;
}
:global([data-range-calendar-day][data-outside-month]:not([data-selected]):not([data-in-range])) {
color: #cbd5e1;
}
:global([data-range-calendar-day][data-disabled]) {
color: #cbd5e1;
cursor: not-allowed;
background: transparent !important;
}
:global([data-range-calendar-day][data-unavailable]:not([data-selected])) {
color: #94a3b8;
text-decoration: line-through;
cursor: not-allowed;
}
:global([data-range-calendar-day][data-holiday]:not([data-selected])) {
color: #dc2626;
position: relative;
}
:global([data-range-calendar-day][data-holiday]:not([data-selected])::after) {
content: '';
position: absolute;
bottom: 4px;
left: 50%;
width: 4px;
height: 4px;
border-radius: 50%;
background: #dc2626;
transform: translateX(-50%);
}
:global([data-range-calendar-day][data-weekend]:not([data-selected]):not([data-holiday]):not([data-outside-month])) {
color: #64748b;
}
.wk {
font-size: 10px;
font-weight: 600;
color: #94a3b8;
text-align: center;
padding: 0 6px;
border-right: 1px dashed #e2e8f0;
vertical-align: middle;
}
</style>

@ -2,23 +2,23 @@
import { TestLayout, StateItem, PropsTable, DemoSection } from '../_components';
import { Terra, TimeField } from '$uix/terra';
import { createTerraTranslator, createTerraDateTimeFormatter } from '$uix/terra/system';
import { Time, type TimeValue, type HourCycle } from '$lib/util/dates';
import { Time, type TimeValue, type HourCycle } from '$lib/util/dias';
const LOCALES = [
{ id: 'es-ES', label: 'es-ES' },
{ id: 'en-US', label: 'en-US' },
{ id: 'en-GB', label: 'en-GB' },
{ id: 'de-DE', label: 'de-DE' },
{ id: 'ja-JP', label: 'ja-JP' },
{ id: 'ja-JP', label: 'ja-JP' }
];
let locale = $state('es-ES');
const translator = createTerraTranslator({
locale: () => locale,
locale: () => locale
});
let timeFormat = $state<HourCycle>(24);
const dateTimeFormatter = createTerraDateTimeFormatter({
timeFormat: () => timeFormat,
timeFormat: () => timeFormat
});
let value1 = $state<Time | undefined>(new Time(9, 30, 0));
@ -28,21 +28,40 @@
function fmt(value: Time | undefined) {
if (!value) return '—';
return [value.hour, value.minute, value.second].map((part) => String(part).padStart(2, '0')).join(':');
return [value.hour, value.minute, value.second]
.map((part) => String(part).padStart(2, '0'))
.join(':');
}
const timeFieldProps = [
{ name: 'locale', type: 'string', default: '"es-ES"', description: 'Locale para formato de hora' },
{ name: 'timeFormat', type: 'HourCycle', default: '24', description: 'Formato de hora: 12 o 24' },
{ name: 'granularity', type: '"hour" | "minute" | "second"', default: '"minute"', description: 'Nivel de precisión' },
{ name: 'readonlySegments', type: 'Segment[]', default: '[]', description: 'Segmentos en solo lectura' },
{ name: 'hourCycle', type: 'HourCycle', default: '24', description: 'Ciclo de hora (12h/24h)' },
{
name: 'locale',
type: 'string',
default: '"es-ES"',
description: 'Locale para formato de hora'
},
{
name: 'timeFormat',
type: 'HourCycle',
default: '24',
description: 'Formato de hora: 12 o 24'
},
{
name: 'granularity',
type: '"hour" | "minute" | "second"',
default: '"minute"',
description: 'Nivel de precisión'
},
{
name: 'readonlySegments',
type: 'Segment[]',
default: '[]',
description: 'Segmentos en solo lectura'
},
{ name: 'hourCycle', type: 'HourCycle', default: '24', description: 'Ciclo de hora (12h/24h)' }
];
const breadcrumbs = [
{ label: 'Test', href: '/test' },
{ label: 'TimeField' }
];
const breadcrumbs = [{ label: 'Test', href: '/test' }, { label: 'TimeField' }];
</script>
<TestLayout
@ -56,7 +75,10 @@
<div class="locale-bar">
<span class="locale-label">Locale:</span>
{#each LOCALES as option}
<button class="locale-btn {locale === option.id ? 'locale-btn--active' : ''}" onclick={() => (locale = option.id)}>
<button
class="locale-btn {locale === option.id ? 'locale-btn--active' : ''}"
onclick={() => (locale = option.id)}
>
{option.label}
</button>
{/each}
@ -64,15 +86,18 @@
<div class="locale-bar">
<span class="locale-label">Time format:</span>
<button class="locale-btn {timeFormat === 24 ? 'locale-btn--active' : ''}" onclick={() => (timeFormat = 24)}>24h</button>
<button class="locale-btn {timeFormat === 12 ? 'locale-btn--active' : ''}" onclick={() => (timeFormat = 12)}>12h</button>
<button
class="locale-btn {timeFormat === 24 ? 'locale-btn--active' : ''}"
onclick={() => (timeFormat = 24)}>24h</button
>
<button
class="locale-btn {timeFormat === 12 ? 'locale-btn--active' : ''}"
onclick={() => (timeFormat = 12)}>12h</button
>
</div>
<!-- Demo 1: Básico -->
<DemoSection
title="Básico"
description="Campo simple con segmentos autoformateados."
>
<DemoSection title="Básico" description="Campo simple con segmentos autoformateados.">
<TimeField.Provider bind:value={value1} {locale} {timeFormat}>
<TimeField.Label class="tf-label">Hora de inicio</TimeField.Label>
<TimeField.Input class="tf-input" name="start-time">
@ -243,7 +268,7 @@
cursor: pointer;
}
:global(.tf-seg[data-type="literal"]) {
:global(.tf-seg[data-type='literal']) {
min-width: auto;
padding: 0;
color: var(--color-text-dim);

@ -2,53 +2,66 @@
import { TestLayout, StateItem, PropsTable, DemoSection } from '../_components';
import { Terra, TimeRangeField } from '$uix/terra';
import { createTerraTranslator, createTerraDateTimeFormatter } from '$uix/terra/system';
import { Time, type HourCycle } from '$lib/util/dates';
import { Time, type HourCycle } from '$lib/util/dias';
const LOCALES = [
{ id: 'es-ES', label: 'es-ES' },
{ id: 'en-US', label: 'en-US' },
{ id: 'de-DE', label: 'de-DE' },
{ id: 'ja-JP', label: 'ja-JP' },
{ id: 'ja-JP', label: 'ja-JP' }
];
let locale = $state('es-ES');
const translator = createTerraTranslator({
locale: () => locale,
locale: () => locale
});
let timeFormat = $state<HourCycle>(24);
const dateTimeFormatter = createTerraDateTimeFormatter({
timeFormat: () => timeFormat,
timeFormat: () => timeFormat
});
let value1 = $state<{ start: Time | undefined; end: Time | undefined } | undefined>({
start: new Time(9, 30, 0),
end: new Time(17, 45, 0),
end: new Time(17, 45, 0)
});
let value2 = $state<{ start: Time | undefined; end: Time | undefined } | undefined>({
start: new Time(8, 0, 0),
end: new Time(12, 15, 30),
end: new Time(12, 15, 30)
});
let value3 = $state<{ start: Time | undefined; end: Time | undefined } | undefined>({
start: new Time(13, 0, 0),
end: new Time(18, 30, 0),
end: new Time(18, 30, 0)
});
function fmt(value: { start?: Time; end?: Time } | undefined) {
if (!value?.start || !value?.end) return '—';
const toStr = (t: Time) => [t.hour, t.minute, t.second].map((x) => String(x).padStart(2, '0')).join(':');
const toStr = (t: Time) =>
[t.hour, t.minute, t.second].map((x) => String(x).padStart(2, '0')).join(':');
return `${toStr(value.start)} → ${toStr(value.end)}`;
}
const timeRangeFieldProps = [
{ name: 'locale', type: 'string', default: '"es-ES"', description: 'Locale para formato de hora' },
{ name: 'timeFormat', type: 'HourCycle', default: '24', description: 'Formato de hora: 12 o 24' },
{ name: 'granularity', type: '"hour" | "minute" | "second"', default: '"minute"', description: 'Nivel de precisión' },
{
name: 'locale',
type: 'string',
default: '"es-ES"',
description: 'Locale para formato de hora'
},
{
name: 'timeFormat',
type: 'HourCycle',
default: '24',
description: 'Formato de hora: 12 o 24'
},
{
name: 'granularity',
type: '"hour" | "minute" | "second"',
default: '"minute"',
description: 'Nivel de precisión'
}
];
const breadcrumbs = [
{ label: 'Test', href: '/test' },
{ label: 'TimeRangeField' }
];
const breadcrumbs = [{ label: 'Test', href: '/test' }, { label: 'TimeRangeField' }];
</script>
<TestLayout
@ -62,7 +75,10 @@
<div class="locale-bar">
<span class="locale-label">Locale:</span>
{#each LOCALES as option}
<button class="locale-btn {locale === option.id ? 'locale-btn--active' : ''}" onclick={() => (locale = option.id)}>
<button
class="locale-btn {locale === option.id ? 'locale-btn--active' : ''}"
onclick={() => (locale = option.id)}
>
{option.label}
</button>
{/each}
@ -70,15 +86,18 @@
<div class="locale-bar">
<span class="locale-label">Time format:</span>
<button class="locale-btn {timeFormat === 24 ? 'locale-btn--active' : ''}" onclick={() => (timeFormat = 24)}>24h</button>
<button class="locale-btn {timeFormat === 12 ? 'locale-btn--active' : ''}" onclick={() => (timeFormat = 12)}>12h</button>
<button
class="locale-btn {timeFormat === 24 ? 'locale-btn--active' : ''}"
onclick={() => (timeFormat = 24)}>24h</button
>
<button
class="locale-btn {timeFormat === 12 ? 'locale-btn--active' : ''}"
onclick={() => (timeFormat = 12)}>12h</button
>
</div>
<!-- Demo 1: Básico -->
<DemoSection
title="Básico"
description="Campo de rango de horas con segmentos editables."
>
<DemoSection title="Básico" description="Campo de rango de horas con segmentos editables.">
<TimeRangeField.Provider bind:value={value1} {locale} {timeFormat}>
<TimeRangeField.Label class="tf-label">Horario</TimeRangeField.Label>
<div class="pair">
@ -265,7 +284,7 @@
cursor: pointer;
}
:global(.tf-seg[data-type="literal"]) {
:global(.tf-seg[data-type='literal']) {
min-width: auto;
padding: 0;
color: var(--color-text-dim);

@ -281,6 +281,18 @@ Pattern: `soma-{component}-{part}-{uid}`. Always descriptive.
[ ] 23. svelte-check: 0 errors
[ ] 24. Test in browser
[ ] 25. Document gaps vs reference libraries
[ ] 26. Create README.md in the component folder — anatomy, props, data-attrs,
keyboard, ARIA, and at least one composition example. Follow the
format used by dialog/README.md and accordion/README.md.
[ ] 27. Verify data-attr naming is consistent across code, CSS, and docs.
`createAttrs` emits `data-{component}` for root and
`data-{component}-{part}` for sub-parts — NEVER `data-soma-*`.
Grep the component folder for `data-soma-` and any other
prefix: if any querySelector, CSS selector, README, or inline
string uses a name that does not match what `createAttrs`
emits, the reference is broken (selectors return null, CSS
matches nothing) and the contract registration lies about
what is on the DOM.
```
## Common Mistakes
@ -305,6 +317,12 @@ Pattern: `soma-{component}-{part}-{uid}`. Always descriptive.
10. **Gesture capturing child clicks** — `setPointerCapture` must be deferred until moveBuffer is exceeded. Immediate capture on pointerdown steals click events from buttons inside the draggable area.
11. **`data-soma-*` prefix** — the framework never emits `data-soma-{component}-*`. `createAttrs` produces `data-{component}` for the root part and `data-{component}-{part}` for children. Writing a querySelector like `[data-soma-calendar-day]` returns `null` silently and the contract validator does NOT catch it (it only checks enum values, not attribute presence). Always grep the component folder for any `data-soma-` reference before completing the work — checklist item 27.
12. **Date types from the wrong module** — `DateValue`, `CalendarDate`, `CalendarDateTime`, `Time`, `ZonedDateTime`, `DateRange`, `Month`, etc. come from `$soma/external/dates` inside soma (which re-exports from `$lib/util/dias`). Never import from `$lib/util/dates` (the legacy vendored copy) or directly from `@internationalized/date`. Direct imports from `$lib/util/dias` are only acceptable from test pages outside the soma tree.
13. **Using `HourCycle` as `'12h' \| '24h'`** — the canonical form is numeric `12 \| 24`, matching `Intl.DateTimeFormat`'s `hour12` resolved option. The App-layer `ext/dates` service, `ext/app/types`, and the `dias` library all share this form. String forms are legacy.
## Audit-Derived Rules (mandatory for all components)
These rules were extracted from a full audit of all 25 soma components. Every issue below was found in multiple components. Follow these to avoid repeating them.

@ -119,14 +119,16 @@ const dir = this.soma?.presentation.getDir();
Los `data-*` attrs son la frontera entre soma y la capa visual. Cambiarlos es breaking change.
Convencion:
Convencion (obligatoria, sin excepciones):
- provider: `data-{component}` (no `data-{component}-provider`, no `data-soma-*`)
- provider: `data-{component}` (no `data-{component}-provider`, **no `data-soma-*`**)
- parte: `data-{component}-{part}`
- estado: `data-state`, `data-disabled`, `data-side`, `data-align`, `data-orientation`
- animacion: `data-starting-style`, `data-ending-style`
- nesting: `data-nested`, `data-nested-open`
Los nombres los emite exclusivamente `createAttrs({ component, parts })`. Cualquier `querySelector`, selector CSS, cadena en README o snippet debe coincidir exactamente con lo que `createAttrs` escribe en el DOM. El validador de contratos (`assertContract`) solo verifica valores enumerados, no nombres ni presencia — la consistencia de nombres es responsabilidad del autor del componente (checklist item 27).
### 3.6 La accesibilidad base no se delega
soma resuelve ARIA por defecto. El consumidor no necesita añadir `role`, `aria-modal`, `aria-expanded`, `aria-controls`, etc. — el Provider los genera.
@ -270,9 +272,23 @@ const soma = Soma.get();
soma?.langs.ts('#?common.buttons.close|Close'); // translation via idlangref
soma?.presentation.getDir(); // direction
soma?.money?.format(1099); // currency formatting
soma?.dates?.getDateOrder(); // DMY / MDY / YMD
soma?.dates?.getHourCycle(); // 12 | 24 (numeric — not '12h' / '24h')
soma?.portalTo; // portal target
```
### Date / time types and formatting
Soma imports all date-related symbols through a single boundary: `$soma/external/dates`. That module re-exports from `$lib/util/dias`, the canonical date library. Components **never** import from `$lib/util/dates` (legacy) or `@internationalized/date` directly.
- Value types: `CalendarDate`, `CalendarDateTime`, `Time`, `ZonedDateTime`
- Types: `DateValue`, `DateRange`, `DateMatcher`, `Month`, `WeekStartsOn`, `HourCycle`, `DateOrder`, `Granularity`
- Operations: `today`, `now`, `startOfMonth`, `endOfMonth`, `isSameDay`, `getDayOfWeek`, `getLastFirstDayOfWeek`, `getNextLastDayOfWeek`, etc.
- Parsing: `parseDate`, `parseDateTime`, `parseTime`
- Formatting: `DateFormatter`, `getCachedDateFormat`, `resolveDateOrder(locale)`, `resolveHourCycle(locale)`
`HourCycle` is canonically the numeric form `12 | 24` across the whole framework, matching `Intl.DateTimeFormat`'s `hour12` resolved option. String forms like `'12h'`/`'24h'` are legacy and must not appear in new code.
### Static method convention (project-wide)
All classes that use Svelte context follow the same pattern:

@ -0,0 +1,220 @@
# Calendar
A date-picker grid with single or multiple selection. Superior to every reference library (ark-ui, bits-ui, radix, react-aria) via built-in `MonthSelect`, `YearSelect`, ISO week numbers, a `holiday` matcher, weekend decoration, and navigation announcements. 14 parts, fully headless.
## Anatomy
```svelte
<Calendar.Provider type="single" bind:value>
{#snippet children({ months, weekdays })}
<Calendar.Header>
<Calendar.PrevButton>‹</Calendar.PrevButton>
<Calendar.Heading />
<Calendar.NextButton>›</Calendar.NextButton>
</Calendar.Header>
{#each months as m (m.value.toString())}
<Calendar.Grid>
<Calendar.GridHead>
<tr>
{#each weekdays as wd (wd)}
<Calendar.HeadCell>{wd}</Calendar.HeadCell>
{/each}
</tr>
</Calendar.GridHead>
<Calendar.GridBody>
{#each m.weeks as week (week[0].toString())}
<Calendar.GridRow>
{#each week as date (date.toString())}
<Calendar.Cell {date} month={m.value}>
<Calendar.Day />
</Calendar.Cell>
{/each}
</Calendar.GridRow>
{/each}
</Calendar.GridBody>
</Calendar.Grid>
{/each}
{/snippet}
</Calendar.Provider>
```
`MonthSelect` and `YearSelect` are optional extras inside `Header`.
## Parts
| Part | Element | Description |
| ------------- | ---------- | -------------------------------------------------------------- |
| `Provider` | `<div>` | Root context. Owns value, placeholder, navigation, keyboard. |
| `Header` | `<header>` | Structural wrapper for the title / controls row. |
| `Heading` | `<div>` | Auto-localized heading (`"April 2026"`, or range for multi-month). |
| `PrevButton` | `<button>` | Advances the placeholder backward (month or page). |
| `NextButton` | `<button>` | Advances the placeholder forward (month or page). |
| `MonthSelect` | `<select>` | Month picker. Driven by the root's `placeholder`. |
| `YearSelect` | `<select>` | Year picker. Defaults to placeholder year ± 50. |
| `Grid` | `<table>` | The month grid. `role="grid"`, labelled by Heading. |
| `GridHead` | `<thead>` | Header row of weekday labels. |
| `GridBody` | `<tbody>` | Rows of dates. |
| `GridRow` | `<tr>` | One row = one week. Exposes `weekNumber` via snippet. |
| `HeadCell` | `<th>` | Weekday column header. |
| `Cell` | `<td>` | One grid cell. Owns flags: selected / disabled / today / … |
| `Day` | `<div>` | Focusable day. Handles click + keyboard. |
## Props (`Provider`)
| Prop | Type | Default | Purpose |
| ------------------------- | -------------------------------------- | ------------ | ----------------------------------------------------------------- |
| `type` | `'single' \| 'multiple'` | required | Discriminator. `value` shape depends on this. |
| `value` | `DateValue` \| `DateValue[]` | `undefined` | Current selection. Bindable. |
| `onValueChange` | callback | — | Fires on every selection change. |
| `placeholder` | `DateValue` | today | Which month(s) are visible. Bindable, independent of selection. |
| `onPlaceholderChange` | callback | — | Fires on navigation. |
| `preventDeselect` | `boolean` | `false` | Clicking selected date keeps it selected. |
| `minValue` / `maxValue` | `DateValue` | — | Bounds. |
| `disabled` | `boolean` | `false` | Whole calendar disabled. |
| `pagedNavigation` | `boolean` | `false` | Prev/Next advance by `numberOfMonths` instead of 1. |
| `weekStartsOn` | `0..6` | locale | Override start-of-week. |
| `weekdayFormat` | Intl weekday format | `'narrow'` | Head cell labels. |
| `isDateDisabled` | `(d: DateValue) => boolean` | — | Custom disable matcher. |
| `isDateUnavailable` | `(d: DateValue) => boolean` | — | Valid but not selectable. |
| `isDateHoliday` | `(d: DateValue) => boolean` | — | Decorates with `data-holiday`. Informational. |
| `fixedWeeks` | `boolean` | `false` | Always render 6 rows. |
| `numberOfMonths` | `number` | `1` | Side-by-side month count. |
| `calendarLabel` | `string` | translated | `aria-label` for the root. |
| `readonly` | `boolean` | `false` | Navigable but not selectable. |
| `initialFocus` | `boolean` | `false` | Focus the placeholder day on mount. |
| `disableDaysOutsideMonth` | `boolean` | `false` | Disable padding cells. |
| `maxDays` | `number` | — | Multiple-selection cap; exceeding resets to `[date]`. |
| `monthFormat` | Intl option or `(m) => string` | `'long'` | Month label format. |
| `yearFormat` | Intl option or `(y) => string` | `'numeric'` | Year label format. |
| `showWeekNumbers` | `boolean` | `false` | Pass `weekNumber` to `GridRow`'s snippet. |
| `weekNumberFormat` | `'iso' \| 'simple'` | `'iso'` | Week numbering convention. |
| `announceNavigation` | `boolean` | `true` | Speak month/year changes to screen readers. |
| `dir` | `'ltr' \| 'rtl'` | from Soma | Inverts horizontal arrow navigation. |
| `locale` | `string` | from Soma | Override locale for month/weekday labels. |
Sub-parts accept the `id` / `child` / `children` trio like every other soma component; see `types.ts` for the full list.
## Snippet props
| Snippet | Provides |
| ---------------- | ---------------------------------------------------------------------------------------------------------- |
| `Provider#children` | `{ months, weekdays, dateOrder }` |
| `Heading#children` | `{ headingValue }` |
| `MonthSelect#children` | `{ monthItems, selectedMonthItem }` |
| `YearSelect#children` | `{ yearItems, selectedYearItem }` |
| `GridRow#children` | `{ weekNumber }` — only populated when `showWeekNumbers` is true |
| `Cell#children` | `{ disabled, unavailable, selected, today, weekend, holiday, outsideMonth }` |
| `Day#children` | same as `Cell` + `day` (locale-formatted label) |
## ARIA
| Part | Attribute | Value |
| ---------- | ---------------------- | ------------------------------ |
| Provider | `role` | `application` |
| Provider | `aria-label` | translated label + heading |
| Provider | `dir` | `ltr` / `rtl` |
| Grid | `role` | `grid` |
| Grid | `aria-labelledby` | Heading id |
| Grid | `aria-readonly` | `true` / `false` |
| Cell | `role` | `gridcell` |
| Cell | `aria-selected` | `true` / `false` |
| Cell | `aria-disabled` | `true` / `false` |
| Day | `role` | `button` |
| Day | `tabindex` | `0` for focused placeholder, `-1` otherwise |
| PrevButton | `aria-label` | translated "Previous month" |
| NextButton | `aria-label` | translated "Next month" |
| MonthSelect | `aria-label` | translated "Select month" |
| YearSelect | `aria-label` | translated "Select year" |
A visually hidden live-region inside `Provider` announces the current heading whenever navigation happens.
## Data attributes
| Part | Attribute | When |
| -------- | ---------------------- | ------------------------------------------ |
| Provider | `data-calendar` | always |
| Provider | `data-type` | `single` / `multiple` |
| Provider | `data-readonly` | when `readonly` |
| Provider | `data-disabled` | when `disabled` |
| Provider | `data-invalid` | any selected date fails validation |
| Cell | `data-selected` | cell is part of the selection |
| Cell | `data-disabled` | cell blocked (bounds / matcher / outside) |
| Cell | `data-unavailable` | matched by `isDateUnavailable` |
| Cell | `data-today` | cell is today |
| Cell | `data-weekend` | Saturday or Sunday (Gregorian) |
| Cell | `data-holiday` | matched by `isDateHoliday` |
| Cell | `data-outside-month` | padding cell around the visible month |
| Day | same flags as Cell | +`data-focused` for the roving tabindex=0 |
## Keyboard
| Key | Action |
| ---------------------------- | ----------------------------------------------------- |
| `ArrowRight` / `ArrowLeft` | ± 1 day (flipped in RTL) |
| `ArrowDown` / `ArrowUp` | ± 7 days |
| `Home` / `End` | Start / end of month |
| `PageUp` / `PageDown` | ± 1 month |
| `Shift + PageUp` / `PageDown`| ± 1 year |
| `Enter` / `Space` | Select focused day |
Focus lives on the placeholder day by default. Keyboard nav advances the placeholder across month boundaries — the calendar re-renders and focus follows after the next microtask.
## Comparison vs reference libraries
| Feature | react-aria | ark-ui | bits-ui | radix | soma |
| ------------------------- | :--------: | :----: | :-----: | :---: | :--: |
| Single + Multiple | ✓ | ✓ | ✓ | ✓ | ✓ |
| Multiple months | ✓ | ✓ | ✓ | ✓ | ✓ |
| Fixed weeks | ✓ | ✓ | ✓ | ✓ | ✓ |
| weekStartsOn / format | ✓ | ✓ | ✓ | ✓ | ✓ |
| min/max/disabled/unavail. | ✓ | ✓ | ✓ | ✓ | ✓ |
| Full ARIA & keyboard | ✓ | ✓ | ✓ | ✓ | ✓ |
| `MonthSelect` part | ✗ | ✗ | ✗ | ✗ | **✓** |
| `YearSelect` part | ✗ | ✗ | ✗ | ✗ | **✓** |
| ISO week numbers | ✗ | ✗ | ✗ | ✗ | **✓** |
| `isDateHoliday` matcher | ✗ | ✗ | ✗ | ✗ | **✓** |
| `data-weekend` flag | ✗ | ✗ | ✗ | ✗ | **✓** |
| Navigation announcements | partial | ✗ | ✗ | ✗ | **✓** |
## Composition example
```svelte
<Calendar.Provider type="single" bind:value showWeekNumbers fixedWeeks>
{#snippet children({ months, weekdays })}
<Calendar.Header>
<Calendar.PrevButton>‹</Calendar.PrevButton>
<Calendar.MonthSelect />
<Calendar.YearSelect />
<Calendar.NextButton>›</Calendar.NextButton>
</Calendar.Header>
{#each months as m (m.value.toString())}
<Calendar.Grid>
<Calendar.GridHead>
<tr>
<Calendar.HeadCell>Wk</Calendar.HeadCell>
{#each weekdays as wd (wd)}
<Calendar.HeadCell>{wd}</Calendar.HeadCell>
{/each}
</tr>
</Calendar.GridHead>
<Calendar.GridBody>
{#each m.weeks as week (week[0].toString())}
<Calendar.GridRow {week}>
{#snippet children({ weekNumber })}
<td class="wk">{weekNumber}</td>
{#each week as date (date.toString())}
<Calendar.Cell {date} month={m.value}>
<Calendar.Day />
</Calendar.Cell>
{/each}
{/snippet}
</Calendar.GridRow>
{/each}
</Calendar.GridBody>
</Calendar.Grid>
{/each}
{/snippet}
</Calendar.Provider>
```
See `src/routes/test/soma/calendar/+page.svelte` for seven demos covering every variant.

@ -0,0 +1,916 @@
import { Provider, context, type WithRefOpts } from '../../provider';
import {
createAttrs,
registerContract,
boolToEmptyStrOrUndef,
boolToStr
} from '../../attrs';
import {
readableActive,
state,
type ActiveProps,
type StateProps
} from '../../reactive';
import type { SomaKeyboardEvent, SomaMouseEvent, OnChangeFn } from '../../types';
import { KEYS, getDirectionalKeys } from '../../keyboard';
import { Soma } from '../../core/soma.svelte';
import { CALENDAR_LANGS } from './langs';
import {
type DateValue,
type DateMatcher,
type Month,
type WeekStartsOn,
type DateOrder,
isSameDay,
startOfMonth,
endOfMonth,
getLocalTimeZone
} from '../../external/dates';
import {
applyMultipleSelection,
applySingleSelection,
computeDateFlags,
createMonths,
getIsoWeekNumber,
getSimpleWeekNumber,
getWeekdays,
isOutsideMonth,
isToday,
isWeekend,
shiftDate
} from './helpers';
import type {
CalendarCellSnippetProps,
CalendarDaySnippetProps,
CalendarMonthSelectSnippetProps,
CalendarYearSelectSnippetProps
} from './types';
// ── Contract ─────────────────────────────────────────────────────────────────
const attrs = createAttrs({
component: 'calendar',
parts: [
'root',
'header',
'heading',
'prev-button',
'next-button',
'month-select',
'year-select',
'grid',
'grid-head',
'grid-body',
'grid-row',
'head-cell',
'cell',
'day',
'week-number'
] as const
});
registerContract({
name: 'calendar',
version: 1,
parts: {
root: [
{ attr: 'data-type', description: 'Selection mode: single | multiple' },
{ attr: 'data-readonly', description: 'Read-only flag' },
{ attr: 'data-disabled', description: 'Disabled flag' },
{ attr: 'data-invalid', description: 'Any selected date fails validation' }
],
header: [],
heading: [],
'prev-button': [{ attr: 'data-disabled', description: 'Cannot page back further' }],
'next-button': [{ attr: 'data-disabled', description: 'Cannot page forward further' }],
'month-select': [{ attr: 'data-disabled', description: 'Disabled flag' }],
'year-select': [{ attr: 'data-disabled', description: 'Disabled flag' }],
grid: [],
'grid-head': [],
'grid-body': [],
'grid-row': [],
'head-cell': [],
cell: [
{ attr: 'data-selected', description: 'Cell is part of the selection' },
{ attr: 'data-disabled', description: 'Cell cannot be interacted with' },
{ attr: 'data-unavailable', description: 'Valid date but not selectable' },
{ attr: 'data-today', description: 'Cell represents today' },
{ attr: 'data-weekend', description: 'Saturday or Sunday (Gregorian)' },
{ attr: 'data-holiday', description: 'Matched by isDateHoliday' },
{ attr: 'data-outside-month', description: 'Cell belongs to the padding around the visible month' }
],
day: [
{ attr: 'data-selected', description: 'Day is part of the selection' },
{ attr: 'data-disabled', description: 'Day cannot be interacted with' },
{ attr: 'data-unavailable', description: 'Valid date but not selectable' },
{ attr: 'data-today', description: 'Day is today' },
{ attr: 'data-weekend', description: 'Saturday or Sunday' },
{ attr: 'data-holiday', description: 'Matched by isDateHoliday' },
{ attr: 'data-outside-month', description: 'Day belongs to padding around visible month' },
{ attr: 'data-focused', description: 'Day currently owns tabindex=0 (roving focus)' }
],
'week-number': [
{ attr: 'data-value', description: 'Numeric week number for screen readers' }
]
}
});
// ── Root ────────────────────────────────────────────────────────────────────
export interface CalendarOpts
extends
WithRefOpts,
StateProps<{
placeholder: DateValue;
value: DateValue | DateValue[] | undefined;
}>,
ActiveProps<{
type: 'single' | 'multiple';
preventDeselect: boolean;
minValue: DateValue | undefined;
maxValue: DateValue | undefined;
disabled: boolean;
pagedNavigation: boolean;
weekStartsOn: WeekStartsOn | undefined;
weekdayFormat: Intl.DateTimeFormatOptions['weekday'];
isDateDisabled: DateMatcher | undefined;
isDateUnavailable: DateMatcher | undefined;
isDateHoliday: DateMatcher | undefined;
fixedWeeks: boolean;
numberOfMonths: number;
calendarLabel: string | undefined;
readonly: boolean;
disableDaysOutsideMonth: boolean;
maxDays: number | undefined;
monthFormat: Intl.DateTimeFormatOptions['month'] | ((month: number) => string);
yearFormat: Intl.DateTimeFormatOptions['year'] | ((year: number) => string);
showWeekNumbers: boolean;
weekNumberFormat: 'iso' | 'simple';
announceNavigation: boolean;
dir: 'ltr' | 'rtl';
locale: string;
}> {
onValueChange?: OnChangeFn<DateValue | DateValue[] | undefined>;
}
export class CalendarProvider extends Provider<CalendarOpts> {
static readonly ctx = context<CalendarProvider>('Calendar');
static get(): CalendarProvider | undefined {
return this.ctx.getOr(undefined) as CalendarProvider | undefined;
}
static require(): CalendarProvider {
return this.ctx.get();
}
static create(opts: CalendarOpts) {
return new CalendarProvider(opts);
}
readonly soma = Soma.get();
/** Child ids exposed so the Grid can wire aria-labelledby. */
headingId = state('');
/** Live-region text for announcements. */
announcement = $state('');
private constructor(opts: CalendarOpts) {
super(opts, 'Calendar', 'root', attrs.root, CalendarProvider.ctx);
}
// ── Derived reactive model ────────────────────────────────────────────────
readonly effectiveLocale = $derived.by(
() => this.opts.locale.current || this.soma?.langs.getLocale() || 'en'
);
readonly months: Month<DateValue>[] = $derived.by(() =>
createMonths(this.opts.placeholder.current, {
numberOfMonths: Math.max(1, this.opts.numberOfMonths.current),
weekStartsOn: this.opts.weekStartsOn.current,
fixedWeeks: this.opts.fixedWeeks.current,
locale: this.effectiveLocale
})
);
readonly weekStartsOnResolved = $derived.by(() => {
const custom = this.opts.weekStartsOn.current;
if (custom !== undefined) return custom;
// Locale default — derive from the first cell produced by the month
// generator (its day-of-week in en-US is the start).
const first = this.months[0]?.weeks[0]?.[0];
if (!first) return 0;
const dow = first.toDate(getLocalTimeZone()).getDay();
return dow as WeekStartsOn;
});
readonly weekdays = $derived.by(() =>
getWeekdays(
this.effectiveLocale,
this.weekStartsOnResolved,
this.opts.weekdayFormat.current
)
);
readonly dateOrder: DateOrder = $derived.by(() => {
return (this.soma?.dates?.getDateOrder?.() ?? 'DMY') as DateOrder;
});
/**
* Visible months as bare DateValues (first day of month). Used for
* prev/next button bound checks so we don't need to recreate
* `months` just for comparisons.
*/
readonly visibleMonths = $derived.by(() => this.months.map((m) => m.value));
/** Current value normalized to an array for uniform operations. */
readonly selectedDates: DateValue[] = $derived.by(() => {
const v = this.opts.value.current;
if (!v) return [];
return Array.isArray(v) ? v : [v];
});
readonly isInvalid = $derived.by(() => {
for (const d of this.selectedDates) {
const flags = this.computeFlags(d);
if (flags.disabled || flags.unavailable) return true;
}
return false;
});
readonly headingValue = $derived.by(() => {
const first = this.visibleMonths[0];
if (!first) return '';
const month = this.formatMonth(first.month);
const year = this.formatYear(first.year);
if (this.visibleMonths.length > 1) {
const last = this.visibleMonths[this.visibleMonths.length - 1];
const lastMonth = this.formatMonth(last.month);
const lastYear = this.formatYear(last.year);
return `${month} ${year} – ${lastMonth} ${lastYear}`;
}
return `${month} ${year}`;
});
readonly fullCalendarLabel = $derived.by(() => {
const base =
this.opts.calendarLabel.current ??
this.soma?.langs.ts(CALENDAR_LANGS.LABEL) ??
'Calendar';
return `${base}, ${this.headingValue}`;
});
// ── Navigation ────────────────────────────────────────────────────────────
prevPage(): void {
const step = this.opts.pagedNavigation.current
? Math.max(1, this.opts.numberOfMonths.current)
: 1;
this.opts.placeholder.current = startOfMonth(
this.opts.placeholder.current.subtract({ months: step })
);
this.announceNavigation();
}
nextPage(): void {
const step = this.opts.pagedNavigation.current
? Math.max(1, this.opts.numberOfMonths.current)
: 1;
this.opts.placeholder.current = startOfMonth(
this.opts.placeholder.current.add({ months: step })
);
this.announceNavigation();
}
prevYear(): void {
this.opts.placeholder.current = this.opts.placeholder.current.subtract({ years: 1 });
this.announceNavigation();
}
nextYear(): void {
this.opts.placeholder.current = this.opts.placeholder.current.add({ years: 1 });
this.announceNavigation();
}
setMonth(month: number): void {
this.opts.placeholder.current = this.opts.placeholder.current.set({ month });
this.announceNavigation();
}
setYear(year: number): void {
this.opts.placeholder.current = this.opts.placeholder.current.set({ year });
this.announceNavigation();
}
private announceNavigation(): void {
if (!this.opts.announceNavigation.current) return;
this.announcement = this.headingValue;
}
// ── Bound checks ──────────────────────────────────────────────────────────
readonly isPrevButtonDisabled = $derived.by(() => {
if (this.opts.disabled.current) return true;
const min = this.opts.minValue.current;
if (!min) return false;
const prev = this.visibleMonths[0]?.subtract({ months: 1 });
return prev ? endOfMonth(prev).compare(min) < 0 : false;
});
readonly isNextButtonDisabled = $derived.by(() => {
if (this.opts.disabled.current) return true;
const max = this.opts.maxValue.current;
if (!max) return false;
const last = this.visibleMonths[this.visibleMonths.length - 1];
const next = last?.add({ months: 1 });
return next ? next.compare(max) > 0 : false;
});
// ── Selection ────────────────────────────────────────────────────────────
/**
* Handle activation of a date (click or Enter/Space on a Day). Respects
* readonly, disabled, unavailable. Dispatches to single/multiple handler
* and calls the `onValueChange` callback configured in the wrapper.
*/
select(date: DateValue): void {
if (this.opts.readonly.current || this.opts.disabled.current) return;
const flags = this.computeFlags(date);
if (flags.disabled || flags.unavailable) return;
const type = this.opts.type.current;
const preventDeselect = this.opts.preventDeselect.current;
if (type === 'single') {
const prev = this.opts.value.current as DateValue | undefined;
const next = applySingleSelection(prev, date, preventDeselect);
this.opts.value.current = next;
} else {
const prev = (this.opts.value.current as DateValue[] | undefined) ?? [];
const next = applyMultipleSelection(
prev,
date,
preventDeselect,
this.opts.maxDays.current
);
this.opts.value.current = next;
}
}
isSelected(date: DateValue): boolean {
for (const d of this.selectedDates) if (isSameDay(d, date)) return true;
return false;
}
computeFlags(date: DateValue) {
return computeDateFlags(date, {
minValue: this.opts.minValue.current,
maxValue: this.opts.maxValue.current,
disabled: this.opts.disabled.current,
isDateDisabled: this.opts.isDateDisabled.current,
isDateUnavailable: this.opts.isDateUnavailable.current,
isDateHoliday: this.opts.isDateHoliday.current
});
}
// ── Keyboard ──────────────────────────────────────────────────────────────
/**
* Keyboard routing for the root element — delegated to by Day onkeydown.
* Returns true when the event was handled (caller must preventDefault).
*/
handleDayKeydown(e: SomaKeyboardEvent<HTMLElement>, date: DateValue): boolean {
const { nextKey: horizNext, prevKey: horizPrev } = getDirectionalKeys(
this.opts.dir.current,
'horizontal'
);
let target: DateValue | undefined;
if (e.key === horizNext) target = shiftDate(date, 1);
else if (e.key === horizPrev) target = shiftDate(date, -1);
else if (e.key === KEYS.ARROW_DOWN) target = shiftDate(date, 7);
else if (e.key === KEYS.ARROW_UP) target = shiftDate(date, -7);
else if (e.key === KEYS.HOME) {
const monthStart = startOfMonth(date);
target = monthStart;
} else if (e.key === KEYS.END) {
target = endOfMonth(date);
} else if (e.key === KEYS.PAGE_UP) {
target = e.shiftKey
? date.subtract({ years: 1 })
: date.subtract({ months: 1 });
} else if (e.key === KEYS.PAGE_DOWN) {
target = e.shiftKey ? date.add({ years: 1 }) : date.add({ months: 1 });
} else if (e.key === KEYS.ENTER || e.key === ' ') {
e.preventDefault();
this.select(date);
return true;
}
if (!target) return false;
e.preventDefault();
// If target is outside the visible months, advance the placeholder
// so the focused cell renders.
if (isOutsideMonth(target, this.visibleMonths[0]) &&
(this.visibleMonths.length === 1 || target.compare(this.visibleMonths[this.visibleMonths.length - 1]) < 0 || target.compare(this.visibleMonths[0]) < 0)
) {
this.opts.placeholder.current = startOfMonth(target);
}
// Move focus after the DOM updates.
queueMicrotask(() => {
const root = this.opts.ref.current;
if (!root) return;
const el = root.querySelector<HTMLElement>(
`[data-calendar-day][data-value="${target!.toString()}"]`
);
el?.focus();
});
return true;
}
// ── Formatters ────────────────────────────────────────────────────────────
formatMonth(month: number): string {
const fmt = this.opts.monthFormat.current;
if (typeof fmt === 'function') return fmt(month);
const anchor = new Date(Date.UTC(2024, month - 1, 15));
return new Intl.DateTimeFormat(this.effectiveLocale, { month: fmt ?? 'long' }).format(anchor);
}
formatYear(year: number): string {
const fmt = this.opts.yearFormat.current;
if (typeof fmt === 'function') return fmt(year);
const anchor = new Date(Date.UTC(year, 5, 15));
return new Intl.DateTimeFormat(this.effectiveLocale, { year: fmt ?? 'numeric' }).format(anchor);
}
formatDay(date: DateValue): string {
return new Intl.DateTimeFormat(this.effectiveLocale, { day: 'numeric' }).format(
date.toDate(getLocalTimeZone())
);
}
// ── Week numbers ──────────────────────────────────────────────────────────
getWeekNumber(weekFirstDate: DateValue): number {
return this.opts.weekNumberFormat.current === 'iso'
? getIsoWeekNumber(weekFirstDate)
: getSimpleWeekNumber(weekFirstDate, this.weekStartsOnResolved, this.effectiveLocale);
}
// ── Props ─────────────────────────────────────────────────────────────────
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
role: 'application' as const,
dir: this.opts.dir.current,
'aria-label': this.fullCalendarLabel,
'aria-disabled': boolToStr(this.opts.disabled.current),
'data-type': this.opts.type.current,
'data-readonly': boolToEmptyStrOrUndef(this.opts.readonly.current),
'data-disabled': boolToEmptyStrOrUndef(this.opts.disabled.current),
'data-invalid': boolToEmptyStrOrUndef(this.isInvalid)
} as const)
);
}
// ── Header ───────────────────────────────────────────────────────────────────
export class CalendarHeaderProvider extends Provider<WithRefOpts> {
static create(opts: WithRefOpts) {
return new CalendarHeaderProvider(opts);
}
private constructor(opts: WithRefOpts) {
super(opts, 'Calendar', 'header', attrs.header);
CalendarProvider.require();
}
readonly props = $derived.by(() => this.assertProps({ ...this.baseProps } as const));
}
// ── Heading ──────────────────────────────────────────────────────────────────
export class CalendarHeadingProvider extends Provider<WithRefOpts> {
static create(opts: WithRefOpts) {
return new CalendarHeadingProvider(opts);
}
readonly provider: CalendarProvider;
private constructor(opts: WithRefOpts) {
super(opts, 'Calendar', 'heading', attrs.heading);
this.provider = CalendarProvider.require();
this.provider.headingId.current = opts.id.current;
}
readonly headingValue = $derived.by(() => this.provider.headingValue);
readonly props = $derived.by(() =>
this.assertProps({ ...this.baseProps, 'aria-hidden': true as const } as const)
);
}
// ── Prev / Next Buttons ─────────────────────────────────────────────────────
abstract class CalendarPaginationButtonProvider extends Provider<WithRefOpts> {
readonly provider: CalendarProvider;
protected readonly labelRef: string;
protected abstract disabled(): boolean;
protected abstract trigger(): void;
protected constructor(
opts: WithRefOpts,
part: 'prev-button' | 'next-button',
labelRef: string
) {
super(opts, 'Calendar', part, attrs[part]);
this.provider = CalendarProvider.require();
this.labelRef = labelRef;
}
readonly onclick = (_e: SomaMouseEvent<HTMLButtonElement>) => {
if (this.disabled()) return;
this.trigger();
};
readonly props = $derived.by(() => {
const disabled = this.disabled();
return this.assertProps({
...this.baseProps,
type: 'button' as const,
'aria-label': this.provider.soma?.langs.ts(
this.labelRef
),
'aria-disabled': boolToStr(disabled),
'data-disabled': boolToEmptyStrOrUndef(disabled),
disabled,
onclick: this.onclick
} as const);
});
}
export class CalendarPrevButtonProvider extends CalendarPaginationButtonProvider {
static create(opts: WithRefOpts) {
return new CalendarPrevButtonProvider(opts);
}
private constructor(opts: WithRefOpts) {
super(opts, 'prev-button', CALENDAR_LANGS.PREV_MONTH);
}
protected disabled() {
return this.provider.isPrevButtonDisabled;
}
protected trigger() {
this.provider.prevPage();
}
}
export class CalendarNextButtonProvider extends CalendarPaginationButtonProvider {
static create(opts: WithRefOpts) {
return new CalendarNextButtonProvider(opts);
}
private constructor(opts: WithRefOpts) {
super(opts, 'next-button', CALENDAR_LANGS.NEXT_MONTH);
}
protected disabled() {
return this.provider.isNextButtonDisabled;
}
protected trigger() {
this.provider.nextPage();
}
}
// ── MonthSelect ──────────────────────────────────────────────────────────────
export interface CalendarMonthSelectOpts
extends WithRefOpts,
ActiveProps<{
months: number[];
monthFormat: Intl.DateTimeFormatOptions['month'] | ((month: number) => string) | undefined;
disabled: boolean;
}> {}
export class CalendarMonthSelectProvider extends Provider<CalendarMonthSelectOpts> {
static create(opts: CalendarMonthSelectOpts) {
return new CalendarMonthSelectProvider(opts);
}
readonly provider: CalendarProvider;
private constructor(opts: CalendarMonthSelectOpts) {
super(opts, 'Calendar', 'month-select', attrs['month-select']);
this.provider = CalendarProvider.require();
}
readonly monthItems: CalendarMonthSelectSnippetProps['monthItems'] = $derived.by(() => {
const customFmt = this.opts.monthFormat.current;
const formatter = (m: number) => {
if (typeof customFmt === 'function') return customFmt(m);
if (typeof customFmt === 'string') {
const anchor = new Date(Date.UTC(2024, m - 1, 15));
return new Intl.DateTimeFormat(this.provider.effectiveLocale, {
month: customFmt
}).format(anchor);
}
return this.provider.formatMonth(m);
};
return this.opts.months.current.map((value) => ({ value, label: formatter(value) }));
});
readonly selectedMonthItem = $derived.by(() => {
const month = this.provider.opts.placeholder.current.month;
return (
this.monthItems.find((i) => i.value === month) ??
this.monthItems[0] ?? { value: month, label: String(month) }
);
});
readonly onchange = (e: Event) => {
const target = e.currentTarget as HTMLSelectElement;
const month = Number.parseInt(target.value, 10);
if (!Number.isNaN(month)) this.provider.setMonth(month);
};
readonly props = $derived.by(() => {
const disabled = this.opts.disabled.current || this.provider.opts.disabled.current;
return this.assertProps({
...this.baseProps,
'aria-label': this.provider.soma?.langs.ts(
CALENDAR_LANGS.MONTH_SELECT
),
'aria-disabled': boolToStr(disabled),
'data-disabled': boolToEmptyStrOrUndef(disabled),
disabled,
value: String(this.selectedMonthItem.value),
onchange: this.onchange
} as const);
});
}
// ── YearSelect ───────────────────────────────────────────────────────────────
export interface CalendarYearSelectOpts
extends WithRefOpts,
ActiveProps<{
years: number[];
yearFormat: Intl.DateTimeFormatOptions['year'] | ((year: number) => string) | undefined;
disabled: boolean;
}> {}
export class CalendarYearSelectProvider extends Provider<CalendarYearSelectOpts> {
static create(opts: CalendarYearSelectOpts) {
return new CalendarYearSelectProvider(opts);
}
readonly provider: CalendarProvider;
private constructor(opts: CalendarYearSelectOpts) {
super(opts, 'Calendar', 'year-select', attrs['year-select']);
this.provider = CalendarProvider.require();
}
readonly yearItems: CalendarYearSelectSnippetProps['yearItems'] = $derived.by(() => {
const customFmt = this.opts.yearFormat.current;
const formatter = (y: number) => {
if (typeof customFmt === 'function') return customFmt(y);
if (typeof customFmt === 'string') {
const anchor = new Date(Date.UTC(y, 5, 15));
return new Intl.DateTimeFormat(this.provider.effectiveLocale, {
year: customFmt
}).format(anchor);
}
return this.provider.formatYear(y);
};
return this.opts.years.current.map((value) => ({ value, label: formatter(value) }));
});
readonly selectedYearItem = $derived.by(() => {
const year = this.provider.opts.placeholder.current.year;
return (
this.yearItems.find((i) => i.value === year) ??
this.yearItems[0] ?? { value: year, label: String(year) }
);
});
readonly onchange = (e: Event) => {
const target = e.currentTarget as HTMLSelectElement;
const year = Number.parseInt(target.value, 10);
if (!Number.isNaN(year)) this.provider.setYear(year);
};
readonly props = $derived.by(() => {
const disabled = this.opts.disabled.current || this.provider.opts.disabled.current;
return this.assertProps({
...this.baseProps,
'aria-label': this.provider.soma?.langs.ts(
CALENDAR_LANGS.YEAR_SELECT
),
'aria-disabled': boolToStr(disabled),
'data-disabled': boolToEmptyStrOrUndef(disabled),
disabled,
value: String(this.selectedYearItem.value),
onchange: this.onchange
} as const);
});
}
// ── Grid / GridHead / GridBody / GridRow / HeadCell (presentational) ────────
export class CalendarGridProvider extends Provider<WithRefOpts> {
static create(opts: WithRefOpts) {
return new CalendarGridProvider(opts);
}
readonly provider: CalendarProvider;
private constructor(opts: WithRefOpts) {
super(opts, 'Calendar', 'grid', attrs.grid);
this.provider = CalendarProvider.require();
}
readonly props = $derived.by(() => {
const headingId = this.provider.headingId.current;
return this.assertProps({
...this.baseProps,
role: 'grid' as const,
'aria-labelledby': headingId || undefined,
'aria-readonly': boolToStr(this.provider.opts.readonly.current),
'aria-disabled': boolToStr(this.provider.opts.disabled.current)
} as const);
});
}
export class CalendarGridHeadProvider extends Provider<WithRefOpts> {
static create(opts: WithRefOpts) {
return new CalendarGridHeadProvider(opts);
}
private constructor(opts: WithRefOpts) {
super(opts, 'Calendar', 'grid-head', attrs['grid-head']);
CalendarProvider.require();
}
readonly props = $derived.by(() => this.assertProps({ ...this.baseProps } as const));
}
export class CalendarGridBodyProvider extends Provider<WithRefOpts> {
static create(opts: WithRefOpts) {
return new CalendarGridBodyProvider(opts);
}
private constructor(opts: WithRefOpts) {
super(opts, 'Calendar', 'grid-body', attrs['grid-body']);
CalendarProvider.require();
}
readonly props = $derived.by(() => this.assertProps({ ...this.baseProps } as const));
}
export class CalendarGridRowProvider extends Provider<WithRefOpts> {
static create(opts: WithRefOpts) {
return new CalendarGridRowProvider(opts);
}
private constructor(opts: WithRefOpts) {
super(opts, 'Calendar', 'grid-row', attrs['grid-row']);
CalendarProvider.require();
}
readonly props = $derived.by(() =>
this.assertProps({ ...this.baseProps, role: 'row' as const } as const)
);
}
export class CalendarHeadCellProvider extends Provider<WithRefOpts> {
static create(opts: WithRefOpts) {
return new CalendarHeadCellProvider(opts);
}
private constructor(opts: WithRefOpts) {
super(opts, 'Calendar', 'head-cell', attrs['head-cell']);
CalendarProvider.require();
}
readonly props = $derived.by(() =>
this.assertProps({ ...this.baseProps, scope: 'col' as const } as const)
);
}
// ── Cell ─────────────────────────────────────────────────────────────────────
export interface CalendarCellOpts
extends WithRefOpts, ActiveProps<{ date: DateValue; month: DateValue }> {}
export class CalendarCellProvider extends Provider<CalendarCellOpts> {
static readonly ctx = context<CalendarCellProvider>('CalendarCell');
static get(): CalendarCellProvider | undefined {
return this.ctx.getOr(undefined) as CalendarCellProvider | undefined;
}
static require(): CalendarCellProvider {
return this.ctx.get();
}
static create(opts: CalendarCellOpts) {
return new CalendarCellProvider(opts);
}
readonly provider: CalendarProvider;
private constructor(opts: CalendarCellOpts) {
super(opts, 'Calendar', 'cell', attrs.cell, CalendarCellProvider.ctx);
this.provider = CalendarProvider.require();
}
readonly flags = $derived.by(() => this.provider.computeFlags(this.opts.date.current));
readonly selected = $derived.by(() => this.provider.isSelected(this.opts.date.current));
readonly outside = $derived.by(() =>
isOutsideMonth(this.opts.date.current, this.opts.month.current)
);
readonly todayFlag = $derived.by(() => isToday(this.opts.date.current));
readonly weekendFlag = $derived.by(() => isWeekend(this.opts.date.current));
readonly isDisabled = $derived.by(() => {
const base = this.flags.disabled;
if (base) return true;
return this.outside && this.provider.opts.disableDaysOutsideMonth.current;
});
readonly snippetProps: CalendarCellSnippetProps = $derived.by(() => ({
disabled: this.isDisabled,
unavailable: this.flags.unavailable,
selected: this.selected,
today: this.todayFlag,
weekend: this.weekendFlag,
holiday: this.flags.holiday,
outsideMonth: this.outside
}));
readonly props = $derived.by(() =>
this.assertProps({
...this.baseProps,
role: 'gridcell' as const,
'aria-selected': boolToStr(this.selected),
'aria-disabled': boolToStr(this.isDisabled),
'data-selected': boolToEmptyStrOrUndef(this.selected),
'data-disabled': boolToEmptyStrOrUndef(this.isDisabled),
'data-unavailable': boolToEmptyStrOrUndef(this.flags.unavailable),
'data-today': boolToEmptyStrOrUndef(this.todayFlag),
'data-weekend': boolToEmptyStrOrUndef(this.weekendFlag),
'data-holiday': boolToEmptyStrOrUndef(this.flags.holiday),
'data-outside-month': boolToEmptyStrOrUndef(this.outside),
'data-value': this.opts.date.current.toString()
} as const)
);
}
// ── Day ──────────────────────────────────────────────────────────────────────
export class CalendarDayProvider extends Provider<WithRefOpts> {
static create(opts: WithRefOpts) {
return new CalendarDayProvider(opts);
}
readonly provider: CalendarProvider;
readonly cell: CalendarCellProvider;
private constructor(opts: WithRefOpts) {
super(opts, 'Calendar', 'day', attrs.day);
this.provider = CalendarProvider.require();
this.cell = CalendarCellProvider.require();
}
readonly focused = $derived.by(() =>
isSameDay(this.cell.opts.date.current, this.provider.opts.placeholder.current)
);
readonly day = $derived.by(() => this.provider.formatDay(this.cell.opts.date.current));
readonly snippetProps: CalendarDaySnippetProps = $derived.by(() => ({
...this.cell.snippetProps,
day: this.day
}));
readonly onclick = (_e: SomaMouseEvent<HTMLElement>) => {
const date = this.cell.opts.date.current;
this.provider.select(date);
};
readonly onkeydown = (e: SomaKeyboardEvent<HTMLElement>) => {
this.provider.handleDayKeydown(e, this.cell.opts.date.current);
};
readonly props = $derived.by(() => {
const s = this.cell.snippetProps;
const tabindex = this.focused && !s.disabled ? 0 : -1;
return this.assertProps({
...this.baseProps,
role: 'button' as const,
tabindex,
'aria-disabled': boolToStr(s.disabled),
'aria-selected': boolToStr(s.selected),
'data-selected': boolToEmptyStrOrUndef(s.selected),
'data-disabled': boolToEmptyStrOrUndef(s.disabled),
'data-unavailable': boolToEmptyStrOrUndef(s.unavailable),
'data-today': boolToEmptyStrOrUndef(s.today),
'data-weekend': boolToEmptyStrOrUndef(s.weekend),
'data-holiday': boolToEmptyStrOrUndef(s.holiday),
'data-outside-month': boolToEmptyStrOrUndef(s.outsideMonth),
'data-focused': boolToEmptyStrOrUndef(this.focused),
'data-value': this.cell.opts.date.current.toString(),
onclick: this.onclick,
onkeydown: this.onkeydown
} as const);
});
}

@ -0,0 +1,39 @@
<script lang="ts">
import { readableActive, writableActive } from '../../../reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { CalendarCellProvider } from '../calendar-provider.svelte';
import type { CalendarCellProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'calendar-cell'),
date,
month,
children,
child,
...restProps
}: CalendarCellProps = $props();
const state = CalendarCellProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
date: readableActive(() => date),
month: readableActive(() => month)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ ...state.snippetProps, props: mergedProps })}
{:else}
<td {...mergedProps}>
{@render children?.(state.snippetProps)}
</td>
{/if}

@ -0,0 +1,39 @@
<script lang="ts">
import { readableActive, writableActive } from '../../../reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { CalendarDayProvider } from '../calendar-provider.svelte';
import type { CalendarDayProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'calendar-day'),
children,
child,
...restProps
}: CalendarDayProps = $props();
const state = CalendarDayProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ ...state.snippetProps, props: mergedProps })}
{:else}
<div {...mergedProps}>
{#if children}
{@render children(state.snippetProps)}
{:else}
{state.snippetProps.day}
{/if}
</div>
{/if}

@ -0,0 +1,35 @@
<script lang="ts">
import { readableActive, writableActive } from '../../../reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { CalendarGridBodyProvider } from '../calendar-provider.svelte';
import type { CalendarGridBodyProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'calendar-grid-body'),
children,
child,
...restProps
}: CalendarGridBodyProps = $props();
const state = CalendarGridBodyProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<tbody {...mergedProps}>
{@render children?.()}
</tbody>
{/if}

@ -0,0 +1,35 @@
<script lang="ts">
import { readableActive, writableActive } from '../../../reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { CalendarGridHeadProvider } from '../calendar-provider.svelte';
import type { CalendarGridHeadProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'calendar-grid-head'),
children,
child,
...restProps
}: CalendarGridHeadProps = $props();
const state = CalendarGridHeadProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<thead {...mergedProps}>
{@render children?.()}
</thead>
{/if}

@ -0,0 +1,46 @@
<script lang="ts">
import { readableActive, writableActive } from '../../../reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { CalendarGridRowProvider, CalendarProvider } from '../calendar-provider.svelte';
import type { CalendarGridRowProps } from '../types';
import type { DateValue } from '../../../external/dates';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'calendar-grid-row'),
children,
child,
...restProps
}: CalendarGridRowProps & { week?: DateValue[] } = $props();
const state = CalendarGridRowProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
// Week number is derived from the first DateValue in this row when the
// consumer passes `week`. Purely informational; headless.
const provider = CalendarProvider.require();
const weekNumber = $derived.by(() => {
const wk = (restProps as { week?: DateValue[] }).week;
if (!wk?.length || !provider.opts.showWeekNumbers.current) return undefined;
return provider.getWeekNumber(wk[0]);
});
const mergedProps = $derived(mergeProps(restProps, state.props));
const snippetProps = $derived({ weekNumber });
</script>
{#if child}
{@render child({ ...snippetProps, props: mergedProps })}
{:else}
<tr {...mergedProps}>
{@render children?.(snippetProps)}
</tr>
{/if}

@ -0,0 +1,35 @@
<script lang="ts">
import { readableActive, writableActive } from '../../../reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { CalendarGridProvider } from '../calendar-provider.svelte';
import type { CalendarGridProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'calendar-grid'),
children,
child,
...restProps
}: CalendarGridProps = $props();
const state = CalendarGridProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<table {...mergedProps}>
{@render children?.()}
</table>
{/if}

@ -0,0 +1,35 @@
<script lang="ts">
import { readableActive, writableActive } from '../../../reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { CalendarHeadCellProvider } from '../calendar-provider.svelte';
import type { CalendarHeadCellProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'calendar-head-cell'),
children,
child,
...restProps
}: CalendarHeadCellProps = $props();
const state = CalendarHeadCellProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<th {...mergedProps}>
{@render children?.()}
</th>
{/if}

@ -0,0 +1,35 @@
<script lang="ts">
import { readableActive, writableActive } from '../../../reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { CalendarHeaderProvider } from '../calendar-provider.svelte';
import type { CalendarHeaderProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'calendar-header'),
children,
child,
...restProps
}: CalendarHeaderProps = $props();
const state = CalendarHeaderProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<header {...mergedProps}>
{@render children?.()}
</header>
{/if}

@ -0,0 +1,40 @@
<script lang="ts">
import { readableActive, writableActive } from '../../../reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { CalendarHeadingProvider } from '../calendar-provider.svelte';
import type { CalendarHeadingProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'calendar-heading'),
children,
child,
...restProps
}: CalendarHeadingProps = $props();
const state = CalendarHeadingProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
const snippetProps = $derived({ headingValue: state.headingValue });
</script>
{#if child}
{@render child({ ...snippetProps, props: mergedProps })}
{:else}
<div {...mergedProps}>
{#if children}
{@render children(snippetProps)}
{:else}
{state.headingValue}
{/if}
</div>
{/if}

@ -0,0 +1,51 @@
<script lang="ts">
import { readableActive, writableActive } from '../../../reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { CalendarMonthSelectProvider } from '../calendar-provider.svelte';
import type { CalendarMonthSelectProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'calendar-month-select'),
months = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
monthFormat,
disabled = false,
children,
child,
...restProps
}: CalendarMonthSelectProps = $props();
const state = CalendarMonthSelectProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
months: readableActive(() => months),
monthFormat: readableActive(() => monthFormat),
disabled: readableActive(() => disabled)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
const snippetProps = $derived({
monthItems: state.monthItems,
selectedMonthItem: state.selectedMonthItem
});
</script>
{#if child}
{@render child({ ...snippetProps, props: mergedProps })}
{:else}
<select {...mergedProps}>
{#if children}
{@render children(snippetProps)}
{:else}
{#each state.monthItems as item (item.value)}
<option value={String(item.value)}>{item.label}</option>
{/each}
{/if}
</select>
{/if}

@ -0,0 +1,35 @@
<script lang="ts">
import { readableActive, writableActive } from '../../../reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { CalendarNextButtonProvider } from '../calendar-provider.svelte';
import type { CalendarNextButtonProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'calendar-next-button'),
children,
child,
...restProps
}: CalendarNextButtonProps = $props();
const state = CalendarNextButtonProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<button {...mergedProps}>
{@render children?.()}
</button>
{/if}

@ -0,0 +1,35 @@
<script lang="ts">
import { readableActive, writableActive } from '../../../reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { CalendarPrevButtonProvider } from '../calendar-provider.svelte';
import type { CalendarPrevButtonProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'calendar-prev-button'),
children,
child,
...restProps
}: CalendarPrevButtonProps = $props();
const state = CalendarPrevButtonProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<button {...mergedProps}>
{@render children?.()}
</button>
{/if}

@ -0,0 +1,63 @@
<script lang="ts">
import { readableActive, writableActive } from '../../../reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { CalendarYearSelectProvider } from '../calendar-provider.svelte';
import type { CalendarYearSelectProps } from '../types';
import { CalendarProvider } from '../calendar-provider.svelte';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'calendar-year-select'),
years,
yearFormat,
disabled = false,
children,
child,
...restProps
}: CalendarYearSelectProps = $props();
// Default range: placeholder year ± 50 so the user can always travel a
// century without configuring this prop. Callers can pass `years` for
// a custom set.
const provider = CalendarProvider.require();
const defaultYears = $derived.by(() => {
const y = provider.opts.placeholder.current.year;
const arr: number[] = [];
for (let i = y - 50; i <= y + 50; i++) arr.push(i);
return arr;
});
const state = CalendarYearSelectProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
years: readableActive(() => years ?? defaultYears),
yearFormat: readableActive(() => yearFormat),
disabled: readableActive(() => disabled)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
const snippetProps = $derived({
yearItems: state.yearItems,
selectedYearItem: state.selectedYearItem
});
</script>
{#if child}
{@render child({ ...snippetProps, props: mergedProps })}
{:else}
<select {...mergedProps}>
{#if children}
{@render children(snippetProps)}
{:else}
{#each state.yearItems as item (item.value)}
<option value={String(item.value)}>{item.label}</option>
{/each}
{/if}
</select>
{/if}

@ -0,0 +1,145 @@
<script lang="ts">
import { readableActive, writableActive } from '../../../reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { CalendarProvider } from '../calendar-provider.svelte';
import type { CalendarProps } from '../types';
import { Soma } from '../../../core/soma.svelte';
import { CalendarDate, today, getLocalTimeZone } from '../../../external/dates';
const uid = $props.id();
const soma = Soma.get();
let {
ref = $bindable(null),
id = createId(uid, 'calendar'),
type,
value = $bindable(),
onValueChange = () => {},
placeholder = $bindable(),
onPlaceholderChange = () => {},
preventDeselect = false,
minValue,
maxValue,
disabled = false,
pagedNavigation = false,
weekStartsOn,
weekdayFormat = 'narrow',
isDateDisabled,
isDateUnavailable,
isDateHoliday,
fixedWeeks = false,
numberOfMonths = 1,
calendarLabel,
readonly = false,
initialFocus = false,
disableDaysOutsideMonth = false,
maxDays,
monthFormat = 'long',
yearFormat = 'numeric',
showWeekNumbers = false,
weekNumberFormat = 'iso',
announceNavigation = true,
dir,
locale,
children,
child,
...restProps
}: CalendarProps = $props();
// Ensure placeholder always resolves to a concrete DateValue even when
// neither placeholder nor value is provided (fallback = today).
function initialPlaceholder() {
if (placeholder) return placeholder;
if (type === 'single' && value) return value;
if (type === 'multiple' && Array.isArray(value) && value.length > 0) {
return value[value.length - 1];
}
const t = today(getLocalTimeZone());
return new CalendarDate(t.year, t.month, t.day);
}
if (placeholder === undefined) placeholder = initialPlaceholder() as never;
const state = CalendarProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
placeholder: writableActive(
() => placeholder!,
(v) => {
placeholder = v;
onPlaceholderChange(v);
}
),
value: writableActive(
() => value,
(v) => {
value = v as never;
onValueChange(v as never);
}
),
type: readableActive(() => type),
preventDeselect: readableActive(() => preventDeselect),
minValue: readableActive(() => minValue),
maxValue: readableActive(() => maxValue),
disabled: readableActive(() => disabled),
pagedNavigation: readableActive(() => pagedNavigation),
weekStartsOn: readableActive(() => weekStartsOn),
weekdayFormat: readableActive(() => weekdayFormat),
isDateDisabled: readableActive(() => isDateDisabled),
isDateUnavailable: readableActive(() => isDateUnavailable),
isDateHoliday: readableActive(() => isDateHoliday),
fixedWeeks: readableActive(() => fixedWeeks),
numberOfMonths: readableActive(() => numberOfMonths),
calendarLabel: readableActive(() => calendarLabel),
readonly: readableActive(() => readonly),
disableDaysOutsideMonth: readableActive(() => disableDaysOutsideMonth),
maxDays: readableActive(() => maxDays),
monthFormat: readableActive(() => monthFormat),
yearFormat: readableActive(() => yearFormat),
showWeekNumbers: readableActive(() => showWeekNumbers),
weekNumberFormat: readableActive(() => weekNumberFormat),
announceNavigation: readableActive(() => announceNavigation),
dir: readableActive(() => dir ?? soma?.presentation.getDir() ?? 'ltr'),
locale: readableActive(() => locale ?? '')
});
// Initial focus on the placeholder day once the DOM is present.
$effect(() => {
if (!initialFocus) return;
const root = ref;
if (!root) return;
queueMicrotask(() => {
const el = root.querySelector<HTMLElement>(
`[data-calendar-day][data-focused]`
);
el?.focus();
});
});
const mergedProps = $derived(mergeProps(restProps, state.props));
const snippetProps = $derived({
months: state.months,
weekdays: state.weekdays,
dateOrder: state.dateOrder
});
</script>
{#if child}
{@render child({ ...snippetProps, props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.(snippetProps)}
<!-- Live region for navigation + selection announcements. -->
<div
aria-live="polite"
aria-atomic="true"
style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);"
>
{state.announcement}
</div>
</div>
{/if}

@ -0,0 +1,38 @@
export { default as Provider } from './components/calendar.svelte';
export { default as Header } from './components/calendar-header.svelte';
export { default as Heading } from './components/calendar-heading.svelte';
export { default as PrevButton } from './components/calendar-prev-button.svelte';
export { default as NextButton } from './components/calendar-next-button.svelte';
export { default as MonthSelect } from './components/calendar-month-select.svelte';
export { default as YearSelect } from './components/calendar-year-select.svelte';
export { default as Grid } from './components/calendar-grid.svelte';
export { default as GridHead } from './components/calendar-grid-head.svelte';
export { default as GridBody } from './components/calendar-grid-body.svelte';
export { default as GridRow } from './components/calendar-grid-row.svelte';
export { default as HeadCell } from './components/calendar-head-cell.svelte';
export { default as Cell } from './components/calendar-cell.svelte';
export { default as Day } from './components/calendar-day.svelte';
export type {
CalendarProps,
CalendarHeaderProps,
CalendarHeadingProps,
CalendarPrevButtonProps,
CalendarNextButtonProps,
CalendarMonthSelectProps,
CalendarYearSelectProps,
CalendarGridProps,
CalendarGridHeadProps,
CalendarGridBodyProps,
CalendarGridRowProps,
CalendarHeadCellProps,
CalendarCellProps,
CalendarDayProps,
CalendarProviderSnippetProps,
CalendarCellSnippetProps,
CalendarDaySnippetProps,
CalendarHeadingSnippetProps,
CalendarMonthSelectSnippetProps,
CalendarYearSelectSnippetProps,
CalendarGridRowSnippetProps
} from './types';

@ -0,0 +1,298 @@
/**
* Pure helpers for Calendar. No reactivity, no DOM, no context access.
* Each function takes everything it needs as parameters and returns a new
* value. Kept outside the Provider so it is trivially testable.
*/
import {
CalendarDate,
CalendarDateTime,
endOfMonth,
getDaysInMonth,
getDayOfWeek,
getLastFirstDayOfWeek,
getNextLastDayOfWeek,
getLocalTimeZone,
isSameDay,
isSameMonth,
startOfMonth,
today
} from '../../external/dates';
import type { DateValue, Month, WeekStartsOn } from '../../external/dates';
// ── Month generation ────────────────────────────────────────────────────────
export interface CreateMonthsOpts {
numberOfMonths: number;
weekStartsOn: WeekStartsOn | undefined;
fixedWeeks: boolean;
locale: string;
}
/**
* Produce the visible grids. Each grid walks from the week-start before the
* 1st of the month through the week-end after the last day, chunked into
* rows of 7. When `fixedWeeks` is on, the grid is padded up to 42 cells
* (6 rows) so layout height stays constant.
*/
export function createMonths(
placeholder: DateValue,
opts: CreateMonthsOpts
): Month<DateValue>[] {
const months: Month<DateValue>[] = [];
for (let i = 0; i < opts.numberOfMonths; i++) {
const monthStart = startOfMonth(addMonthsPreserving(placeholder, i));
months.push(buildMonth(monthStart, opts));
}
return months;
}
function buildMonth(
monthStart: DateValue,
opts: Omit<CreateMonthsOpts, 'numberOfMonths'>
): Month<DateValue> {
const monthEnd = endOfMonth(monthStart);
const daysInMonth = getDaysInMonth(monthStart);
// All 1..n dates in the current month.
const inMonth: DateValue[] = [];
for (let day = 1; day <= daysInMonth; day++) {
inMonth.push(monthStart.set({ day }));
}
// Leading padding: days from the previous month that complete the first
// visible week.
const firstCell = opts.weekStartsOn === undefined
? getLastFirstDayOfWeek(monthStart, 0, opts.locale)
: getLastFirstDayOfWeek(monthStart, opts.weekStartsOn, 'en-US');
const lead: DateValue[] = [];
let cursor = firstCell;
while (cursor.compare(monthStart) < 0) {
lead.push(cursor);
cursor = cursor.add({ days: 1 });
}
// Trailing padding to complete the last visible week.
const lastCell = opts.weekStartsOn === undefined
? getNextLastDayOfWeek(monthEnd, 0, opts.locale)
: getNextLastDayOfWeek(monthEnd, opts.weekStartsOn, 'en-US');
const trail: DateValue[] = [];
cursor = monthEnd.add({ days: 1 });
while (cursor.compare(lastCell) <= 0) {
trail.push(cursor);
cursor = cursor.add({ days: 1 });
}
let dates = [...lead, ...inMonth, ...trail];
if (opts.fixedWeeks && dates.length < 42) {
const extra = 42 - dates.length;
for (let i = 0; i < extra; i++) {
dates.push(dates[dates.length - 1].add({ days: 1 }));
}
}
// Chunk into weeks of 7.
const weeks: DateValue[][] = [];
for (let i = 0; i < dates.length; i += 7) weeks.push(dates.slice(i, i + 7));
return { value: monthStart, dates, weeks };
}
/**
* `value.add({ months: n })` shifts day-of-month if the target month is
* shorter; we only care about the year+month, so normalize by going through
* `startOfMonth` first.
*/
function addMonthsPreserving(value: DateValue, months: number): DateValue {
return startOfMonth(value).add({ months });
}
// ── Week numbers ────────────────────────────────────────────────────────────
/**
* ISO 8601 week number: week 1 is the week containing the first Thursday
* of the year. Independent of `weekStartsOn`.
*/
export function getIsoWeekNumber(date: DateValue): number {
const d = date.toDate(getLocalTimeZone());
// Make a UTC-normalized copy so DST does not shift us across days.
const utc = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
const dayNum = utc.getUTCDay() || 7; // Sunday → 7
utc.setUTCDate(utc.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(utc.getUTCFullYear(), 0, 1));
return Math.ceil(((utc.getTime() - yearStart.getTime()) / 86_400_000 + 1) / 7);
}
/**
* Simple week number: 1-based count starting with the week containing
* January 1, counted from the user's `weekStartsOn` (0 = Sunday).
*/
export function getSimpleWeekNumber(
date: DateValue,
weekStartsOn: number,
locale: string
): number {
const yearStart = new CalendarDate(date.year, 1, 1);
const firstCell = getLastFirstDayOfWeek(yearStart, weekStartsOn, locale);
const d = date.toDate(getLocalTimeZone());
const first = firstCell.toDate(getLocalTimeZone());
const days = Math.floor((d.getTime() - first.getTime()) / 86_400_000);
return Math.floor(days / 7) + 1;
}
// ── Validation composition ──────────────────────────────────────────────────
export interface ComposeValidationOpts {
minValue: DateValue | undefined;
maxValue: DateValue | undefined;
disabled: boolean;
isDateDisabled: ((d: DateValue) => boolean) | undefined;
isDateUnavailable: ((d: DateValue) => boolean) | undefined;
isDateHoliday: ((d: DateValue) => boolean) | undefined;
}
export interface DateFlags {
disabled: boolean;
unavailable: boolean;
holiday: boolean;
}
/**
* Compute per-date flags. Order of precedence:
* 1. root `disabled` → every date disabled.
* 2. min/max bounds → disabled if outside.
* 3. user matcher `isDateDisabled` → disabled.
* 4. `isDateUnavailable` → unavailable (non-blocking for focus).
* 5. `isDateHoliday` → informational flag only.
*/
export function computeDateFlags(date: DateValue, opts: ComposeValidationOpts): DateFlags {
const disabled =
opts.disabled ||
(opts.minValue !== undefined && date.compare(opts.minValue) < 0) ||
(opts.maxValue !== undefined && date.compare(opts.maxValue) > 0) ||
(opts.isDateDisabled?.(date) ?? false);
const unavailable = opts.isDateUnavailable?.(date) ?? false;
const holiday = opts.isDateHoliday?.(date) ?? false;
return { disabled, unavailable, holiday };
}
// ── Selection ───────────────────────────────────────────────────────────────
/**
* Single-select toggle. Returns:
* - `undefined` when the user clicked the currently selected date AND
* `preventDeselect` is false (clear).
* - the same date otherwise (noop, but keeps downstream logic uniform).
* - `date` when it differs from the current value.
*/
export function applySingleSelection(
prev: DateValue | undefined,
date: DateValue,
preventDeselect: boolean
): DateValue | undefined {
if (prev && isSameDay(prev, date)) return preventDeselect ? prev : undefined;
return getDateWithPreviousTime(prev, date);
}
/**
* Preserve time-of-day when switching selected date, so a calendar+time
* combo does not reset the hour on every date pick.
*/
export function getDateWithPreviousTime(
prev: DateValue | undefined,
next: DateValue
): DateValue {
if (!prev) return next;
if (prev instanceof CalendarDateTime && next instanceof CalendarDateTime) return next;
if (prev instanceof CalendarDateTime && next instanceof CalendarDate) {
return new CalendarDateTime(
next.year,
next.month,
next.day,
prev.hour,
prev.minute,
prev.second,
prev.millisecond
);
}
return next;
}
/**
* Multi-select toggle + cap. Returns the next array:
* - Toggles the date in/out of the existing selection.
* - If adding would exceed `maxDays`, resets to `[date]`.
*/
export function applyMultipleSelection(
prev: DateValue[],
date: DateValue,
preventDeselect: boolean,
maxDays: number | undefined
): DateValue[] {
const idx = prev.findIndex((d) => isSameDay(d, date));
if (idx >= 0) {
if (preventDeselect && prev.length === 1) return prev;
const next = [...prev];
next.splice(idx, 1);
return next;
}
if (maxDays !== undefined && prev.length >= maxDays) return [date];
return [...prev, date];
}
// ── Focus navigation ────────────────────────────────────────────────────────
/**
* Given a keyboard step, compute the next date to focus. Pure — the caller
* is responsible for actually moving focus in the DOM.
*/
export function shiftDate(date: DateValue, days: number): DateValue {
return days >= 0 ? date.add({ days }) : date.subtract({ days: -days });
}
// ── Weekday header generation ───────────────────────────────────────────────
/**
* Produce weekday labels for the head row. Uses `Intl.DateTimeFormat` via a
* known anchor week (Jan 4 is always ISO week 1 and sits in the first
* Thursday). Returns labels in display order starting from `weekStartsOn`.
*/
export function getWeekdays(
locale: string,
weekStartsOn: number,
format: Intl.DateTimeFormatOptions['weekday']
): string[] {
const fmt = new Intl.DateTimeFormat(locale, { weekday: format ?? 'narrow' });
// Jan 2024 week: 2024-01-01 was a Monday. Build a reference week.
const anchorMonday = new Date(Date.UTC(2024, 0, 1));
const labels: string[] = [];
for (let i = 0; i < 7; i++) {
const idx = (weekStartsOn + i) % 7;
const d = new Date(anchorMonday);
// anchor is Monday (getUTCDay === 1). Shift to desired weekday:
// target day-index (0=Sunday … 6=Saturday) → offset from Monday.
const offset = (idx + 6) % 7; // 0 (Monday=0) → 0, …
d.setUTCDate(anchorMonday.getUTCDate() + offset);
labels.push(fmt.format(d));
}
return labels;
}
// ── Miscellaneous ───────────────────────────────────────────────────────────
/** Tuple of today / is-same helpers kept here to centralize TZ handling. */
export function isToday(date: DateValue): boolean {
return isSameDay(date, today(getLocalTimeZone()));
}
export function isWeekend(date: DateValue): boolean {
const dow = getDayOfWeek(date, 'en-US'); // 0 = Sunday
return dow === 0 || dow === 6;
}
export function isOutsideMonth(date: DateValue, monthStart: DateValue): boolean {
return !isSameMonth(date, monthStart);
}

@ -0,0 +1 @@
export * from './exports';

@ -0,0 +1,15 @@
/** Idlangref constants for Calendar. Values are fallback defaults. */
export const CALENDAR_LANGS = {
LABEL: '#?components.calendar.label|Calendar',
PREV_MONTH: '#?components.calendar.prevMonth|Previous month',
NEXT_MONTH: '#?components.calendar.nextMonth|Next month',
PREV_YEAR: '#?components.calendar.prevYear|Previous year',
NEXT_YEAR: '#?components.calendar.nextYear|Next year',
MONTH_SELECT: '#?components.calendar.monthSelect|Select month',
YEAR_SELECT: '#?components.calendar.yearSelect|Select year',
WEEK_LABEL: '#?components.calendar.weekLabel|Week',
WEEK_NUMBER_ABBR: '#?components.calendar.weekNumberAbbr|Wk',
HOLIDAY: '#?components.calendar.holiday|holiday',
TODAY: '#?components.calendar.today|today',
SELECTED: '#?components.calendar.selected|selected'
} as const;

@ -0,0 +1,289 @@
import type { Snippet } from 'svelte';
import type { WithChild, Without, OnChangeFn } from '../../types';
import type {
PrimitiveButtonAttributes,
PrimitiveDivAttributes,
PrimitiveHeaderAttributes,
PrimitiveSelectAttributes,
PrimitiveTableAttributes,
PrimitiveTBodyAttributes,
PrimitiveTDAttributes,
PrimitiveTHAttributes,
PrimitiveTHeadAttributes,
PrimitiveTRAttributes
} from '../../types';
import type {
DateValue,
DateMatcher,
Month,
WeekStartsOn,
DateOrder
} from '../../external/dates';
// ── Snippet props ────────────────────────────────────────────────────────────
/** Snippet props exposed by the root `Calendar.Provider`. */
export type CalendarProviderSnippetProps = {
/** One `Month` per visible grid (length === `numberOfMonths`). */
months: Month<DateValue>[];
/** Locale-formatted weekday labels in display order. */
weekdays: string[];
/** Locale's canonical date-field order (`'DMY' | 'MDY' | 'YMD'`). */
dateOrder: DateOrder;
};
/** Snippet props exposed by `Calendar.Cell`. */
export type CalendarCellSnippetProps = {
/** True when the date is blocked for interaction. */
disabled: boolean;
/** True when the date is reachable but cannot be selected. */
unavailable: boolean;
/** True when the date is part of the current value. */
selected: boolean;
/** True when the date is `today()`. */
today: boolean;
/** True when the date is Saturday or Sunday (weekend in Gregorian). */
weekend: boolean;
/** True when the date matches `isDateHoliday`. */
holiday: boolean;
/** True when the cell renders a day outside the currently visible month. */
outsideMonth: boolean;
};
/** Snippet props exposed by `Calendar.Day`. */
export type CalendarDaySnippetProps = CalendarCellSnippetProps & {
/** Localized day-of-month number as a display string. */
day: string;
};
/** Snippet props exposed by `Calendar.Heading`. */
export type CalendarHeadingSnippetProps = {
/** Formatted heading — e.g. `"April 2026"`. */
headingValue: string;
};
/** Snippet props exposed by `Calendar.MonthSelect`. */
export type CalendarMonthSelectSnippetProps = {
/** One entry per option (`value`: 1-12, `label`: localized). */
monthItems: Array<{ value: number; label: string }>;
/** The currently selected month entry. */
selectedMonthItem: { value: number; label: string };
};
/** Snippet props exposed by `Calendar.YearSelect`. */
export type CalendarYearSelectSnippetProps = {
/** Full year range shown (`value`: year, `label`: localized). */
yearItems: Array<{ value: number; label: string }>;
/** The currently selected year entry. */
selectedYearItem: { value: number; label: string };
};
/** Snippet props exposed by `Calendar.GridRow` when `showWeekNumbers` is on. */
export type CalendarGridRowSnippetProps = {
/** ISO (or simple) week number for this row, `undefined` if disabled. */
weekNumber: number | undefined;
};
// ── Base provider props ──────────────────────────────────────────────────────
type CalendarBaseProps = {
/** DOM id. Auto-generated when omitted. */
id?: string;
/**
* Month currently visible. Drives navigation independently of `value`.
* Bindable.
*/
placeholder?: DateValue;
/** Called when `placeholder` changes (user nav or programmatic). */
onPlaceholderChange?: OnChangeFn<DateValue>;
/**
* When `true`, clicking the already-selected date keeps it selected
* instead of clearing.
* @default false
*/
preventDeselect?: boolean;
/** Earliest date the user can select. */
minValue?: DateValue;
/** Latest date the user can select. */
maxValue?: DateValue;
/** Disables the entire calendar. */
disabled?: boolean;
/**
* When `true`, navigation advances by `numberOfMonths` at a time.
* @default false
*/
pagedNavigation?: boolean;
/**
* Override the first day of the week (`0`-Sunday … `6`-Saturday). Leave
* undefined to use the locale's default.
*/
weekStartsOn?: WeekStartsOn;
/** Intl weekday format for head cells. @default `'narrow'` */
weekdayFormat?: Intl.DateTimeFormatOptions['weekday'];
/** Matcher: return `true` to disable (blocks interaction). */
isDateDisabled?: DateMatcher;
/** Matcher: return `true` for valid-but-unavailable dates. */
isDateUnavailable?: DateMatcher;
/**
* Matcher: return `true` to mark the date as a holiday. Rendered as
* `data-holiday` on Cell and Day; purely informational. @default none
*/
isDateHoliday?: DateMatcher;
/**
* When `true`, every month grid always renders exactly 6 rows — useful
* for keeping layout height constant.
* @default false
*/
fixedWeeks?: boolean;
/** Number of months to render side-by-side. @default 1 */
numberOfMonths?: number;
/** `aria-label` for the calendar root. Localized via translator if omitted. */
calendarLabel?: string;
/** Read-only calendar: focusable + navigable, but selection blocked. */
readonly?: boolean;
/** Focus the placeholder date on mount. @default false */
initialFocus?: boolean;
/**
* When `true`, cells for days outside the visible month are disabled
* (useful to avoid accidental "jump months" on keyboard nav).
* @default false
*/
disableDaysOutsideMonth?: boolean;
/**
* Cap for multiple-selection: if the user would exceed this, the array
* resets to `[date]`.
*/
maxDays?: number;
/** Intl month format or a custom formatter. @default `'long'` */
monthFormat?: Intl.DateTimeFormatOptions['month'] | ((month: number) => string);
/** Intl year format or a custom formatter. @default `'numeric'` */
yearFormat?: Intl.DateTimeFormatOptions['year'] | ((year: number) => string);
/**
* Render an extra leading column with week numbers.
* @default false
*/
showWeekNumbers?: boolean;
/**
* Week-number convention. `'iso'` uses ISO 8601 (Monday-start, week 1
* contains first Thursday); `'simple'` counts from 1 starting at the
* first week containing day 1 of the year.
* @default `'iso'`
*/
weekNumberFormat?: 'iso' | 'simple';
/**
* Announce navigation (not just selection) via `aria-live`. @default true
*/
announceNavigation?: boolean;
/**
* Writing direction. Inverts horizontal arrow navigation when `'rtl'`.
* Falls back to `soma.presentation.getDir()`.
*/
dir?: 'ltr' | 'rtl';
/**
* Override the locale used to format month/weekday labels. Falls back to
* `soma.langs.getLocale()`.
*/
locale?: string;
};
// ── Root provider — discriminated union ─────────────────────────────────────
export type CalendarSingleProps = CalendarBaseProps & {
/** Single-date selection. */
type: 'single';
/** Current value. Bindable. */
value?: DateValue;
/** Called when the selection changes. */
onValueChange?: OnChangeFn<DateValue | undefined>;
};
export type CalendarMultipleProps = CalendarBaseProps & {
/** Multi-date selection. Toggling removes; `maxDays` resets. */
type: 'multiple';
/** Current value array. Bindable. */
value?: DateValue[];
/** Called when the selection changes. */
onValueChange?: OnChangeFn<DateValue[]>;
};
export type CalendarProps = WithChild<
CalendarSingleProps | CalendarMultipleProps,
CalendarProviderSnippetProps
> &
Without<PrimitiveDivAttributes, {}>;
// ── Child parts ──────────────────────────────────────────────────────────────
export type CalendarHeaderProps = WithChild<{ id?: string }> &
Without<PrimitiveHeaderAttributes, {}>;
export type CalendarHeadingProps = WithChild<{ id?: string }, CalendarHeadingSnippetProps> &
Without<PrimitiveDivAttributes, {}>;
export type CalendarPrevButtonProps = WithChild<{ id?: string }> &
Without<PrimitiveButtonAttributes, {}>;
export type CalendarNextButtonProps = WithChild<{ id?: string }> &
Without<PrimitiveButtonAttributes, {}>;
export type CalendarMonthSelectProps = WithChild<
{
id?: string;
/** Restrict the option list (default: all 12 months). */
months?: number[];
/** Per-instance format override. */
monthFormat?: Intl.DateTimeFormatOptions['month'] | ((month: number) => string);
/** Disable the select independently of the calendar. */
disabled?: boolean;
},
CalendarMonthSelectSnippetProps
> &
Without<PrimitiveSelectAttributes, {}>;
export type CalendarYearSelectProps = WithChild<
{
id?: string;
/** Explicit year list. When omitted, derived from placeholder year ± 50. */
years?: number[];
/** Per-instance format override. */
yearFormat?: Intl.DateTimeFormatOptions['year'] | ((year: number) => string);
/** Disable the select independently of the calendar. */
disabled?: boolean;
},
CalendarYearSelectSnippetProps
> &
Without<PrimitiveSelectAttributes, {}>;
export type CalendarGridProps = WithChild<{ id?: string }> &
Without<PrimitiveTableAttributes, {}>;
export type CalendarGridHeadProps = WithChild<{ id?: string }> &
Without<PrimitiveTHeadAttributes, {}>;
export type CalendarGridBodyProps = WithChild<{ id?: string }> &
Without<PrimitiveTBodyAttributes, {}>;
export type CalendarGridRowProps = WithChild<{ id?: string }, CalendarGridRowSnippetProps> &
Without<PrimitiveTRAttributes, {}>;
export type CalendarHeadCellProps = WithChild<{
id?: string;
/** Optional children override (e.g. to inject a week-number column header). */
children?: Snippet;
}> &
Without<PrimitiveTHAttributes, {}>;
export type CalendarCellProps = WithChild<
{
id?: string;
/** The date this cell represents. */
date: DateValue;
/** The month this grid belongs to (used to flag outside-month cells). */
month: DateValue;
},
CalendarCellSnippetProps
> &
Without<PrimitiveTDAttributes, {}>;
export type CalendarDayProps = WithChild<{ id?: string }, CalendarDaySnippetProps> &
Without<PrimitiveDivAttributes, {}>;

@ -0,0 +1,213 @@
# Command
A command palette with fuzzy scoring, keyboard navigation, groups, empty/loading states, and vim-style keybindings. Supports grid mode (emoji picker), external filtering, and a Dialog composition variant.
## Anatomy
```svelte
<Command.Provider bind:value={selected}>
<Command.Input placeholder="Type a command…" />
<Command.List>
<Command.Empty>No results.</Command.Empty>
<Command.Group value="File">
<Command.GroupHeading>File</Command.GroupHeading>
<Command.GroupItems>
<Command.Item value="file.new" onSelect={run}>New File</Command.Item>
<Command.Item value="file.open" onSelect={run}>Open</Command.Item>
</Command.GroupItems>
</Command.Group>
<Command.Separator />
<Command.LinkItem value="docs" href="https://svelte.dev">Docs</Command.LinkItem>
</Command.List>
</Command.Provider>
```
## Parts
| Part | Element | Description |
| -------------- | --------- | ---------------------------------------------------------------- |
| `Provider` | `<div>` | Root context. Manages value, search, filtering, and navigation. |
| `Input` | `<input>` | Combobox input. Drives search filtering and keyboard navigation. |
| `List` | `<div>` | Listbox container for items, groups, and empty state. |
| `Viewport` | `<div>` | Measures child height and exposes `--soma-command-list-height`. |
| `Item` | `<div>` | Selectable option. Only renders when visible (passes filter). |
| `LinkItem` | `<a>` | Same as Item but renders an anchor for navigation. |
| `Group` | `<div>` | Groups related items. Always renders; sets `data-empty` for CSS. |
| `GroupHeading` | `<div>` | Group label, linked via `aria-labelledby`. |
| `GroupItems` | `<div>` | Wrapper for items within a group. |
| `Empty` | `<div>` | Renders when no items survive the filter. |
| `Loading` | `<div>` | Optional progress bar for async states. |
| `Separator` | `<span>` | Visual divider between groups or sections. |
| `Dialog` | — | Composition: wraps Dialog + Command for modal usage. |
## ARIA
| Part | Attribute | Value |
| ------------ | ----------------------- | ------------------------------------ |
| Input | `role` | `combobox` |
| Input | `aria-autocomplete` | `list` |
| Input | `aria-expanded` | `true` |
| Input | `aria-controls` | ID of List |
| Input | `aria-activedescendant` | ID of highlighted Item |
| List | `role` | `listbox` |
| List | `aria-label` | Translated label |
| Item | `role` | `option` |
| Item | `aria-selected` | `true` \| `false` |
| Item | `aria-disabled` | `true` \| `false` |
| LinkItem | `role` | `option` |
| LinkItem | `aria-selected` | `true` \| `false` |
| LinkItem | `aria-disabled` | `true` \| `false` |
| Group | `role` | `group` |
| Group | `aria-labelledby` | ID of GroupHeading |
| GroupHeading | `aria-hidden` | `true` |
| GroupItems | `role` | `presentation` |
| Empty | `role` | `status` |
| Empty | `aria-live` | `polite` |
| Loading | `role` | `progressbar` |
| Loading | `aria-valuemin` | `0` |
| Loading | `aria-valuemax` | `100` |
| Loading | `aria-valuenow` | `0`–`100` or omitted (indeterminate) |
| Separator | `role` | `separator` |
| Separator | `aria-orientation` | `horizontal` |
## Data Attributes
| Part | Attribute | Values |
| ------------ | ---------------------------- | ----------------------------- |
| Provider | `data-command` | Always present |
| Input | `data-command-input` | Always present |
| List | `data-command-list` | Always present |
| Viewport | `data-command-viewport` | Always present |
| Item | `data-command-item` | Always present |
| Item | `data-selected` | Present when highlighted |
| Item | `data-disabled` | Present when disabled |
| Item | `data-value` | Item's value string |
| LinkItem | `data-command-link-item` | Always present |
| LinkItem | `data-selected` | Present when highlighted |
| LinkItem | `data-disabled` | Present when disabled |
| LinkItem | `data-value` | Item's value string |
| Group | `data-command-group` | Always present |
| Group | `data-empty` | Present when no items visible |
| GroupHeading | `data-command-group-heading` | Always present |
| GroupItems | `data-command-group-items` | Always present |
| Empty | `data-command-empty` | Always present |
| Loading | `data-command-loading` | Always present |
| Loading | `data-value` | Progress value |
| Separator | `data-command-separator` | Always present |
## CSS Variables
| Variable | Part | Description |
| ---------------------------- | -------- | -------------------------------- |
| `--soma-command-list-height` | Viewport | Measured height of child element |
## Keyboard
| Key | Action |
| ------------------- | ----------------------------------- |
| `ArrowDown` | Move highlight down (or by columns) |
| `ArrowUp` | Move highlight up (or by columns) |
| `ArrowRight` | Move highlight right (grid mode) |
| `ArrowLeft` | Move highlight left (grid mode) |
| `Home` | Highlight first item |
| `End` | Highlight last item |
| `Enter` | Select highlighted item |
| `Ctrl+N` / `Ctrl+J` | Next item (vim bindings) |
| `Ctrl+P` / `Ctrl+K` | Previous item (vim bindings) |
Horizontal arrow keys are direction-inverted in RTL mode.
## Usage
### Basic command palette
```svelte
<script>
import { Command } from '$soma/components';
let selected = $state('');
</script>
<Command.Provider bind:value={selected}>
<Command.Input placeholder="Type a command…" />
<Command.List>
<Command.Empty>No results.</Command.Empty>
<Command.Item value="copy" onSelect={(v) => console.log(v)}>Copy</Command.Item>
<Command.Item value="paste">Paste</Command.Item>
</Command.List>
</Command.Provider>
```
### With groups
```svelte
<Command.List>
<Command.Group value="edit">
<Command.GroupHeading>Edit</Command.GroupHeading>
<Command.GroupItems>
<Command.Item value="cut">Cut</Command.Item>
<Command.Item value="copy">Copy</Command.Item>
</Command.GroupItems>
</Command.Group>
</Command.List>
```
### Grid mode (emoji picker)
```svelte
<Command.Provider columns={10} label="Pick an emoji">
<Command.Input placeholder="Filter…" />
<Command.List>
{#each emojis as e (e)}
<Command.Item value={e}>{e}</Command.Item>
{/each}
</Command.List>
</Command.Provider>
```
### External filter
```svelte
<Command.Provider shouldFilter={false}>
<!-- Items are always visible; consumer controls what's shown -->
</Command.Provider>
```
### Dialog composition
```svelte
<script>
let open = $state(false);
</script>
<button onclick={() => (open = true)}>Open</button>
<Command.Dialog bind:open>
<Command.Input placeholder="Search…" />
<Command.List>
<Command.Item value="action">Action</Command.Item>
</Command.List>
</Command.Dialog>
```
### Imperative API via snippet props
```svelte
<Command.Provider bind:value={apiValue} bind:search={apiSearch}>
{#snippet children({ next, prev, select, isEmpty, visible })}
<Command.Input />
<Command.List>
<Command.Item value="a">Item A</Command.Item>
</Command.List>
<div>
<button onclick={() => prev()}>Prev</button>
<button onclick={() => next()}>Next</button>
<span>{isEmpty ? 'no match' : `${visible.length} visible`}</span>
</div>
{/snippet}
</Command.Provider>
```

File diff suppressed because it is too large Load Diff

@ -0,0 +1,89 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import * as Dialog from '../../dialog';
import Portal from '../../internal/portal.svelte';
import Command from './command.svelte';
import { computeCommandScore } from '../score';
import type { CommandFilterFn, CommandSnippetProps } from '../types';
import type { OnChangeFn } from '../../../types';
type CommandDialogProps = {
/** Controls the dialog open state. Bindable. @default false */
open?: boolean;
/** Callback on dialog open/close. */
onOpenChange?: OnChangeFn<boolean>;
/** Whether the dialog blocks outside interaction. @default true */
modal?: boolean;
/** Portal target for the dialog content. @default 'body' */
portalTo?: string | HTMLElement;
// ── Command pass-through ────────────────────────────────────────────────
label?: string;
value?: string;
onValueChange?: OnChangeFn<string>;
search?: string;
onSearchChange?: OnChangeFn<string>;
shouldFilter?: boolean;
filter?: CommandFilterFn;
loop?: boolean;
vimBindings?: boolean;
columns?: number;
disablePointerSelection?: boolean;
/** Optional overlay content — if omitted, a default `Dialog.Overlay` is used. */
overlay?: Snippet;
children?: Snippet<[CommandSnippetProps]>;
};
let {
open = $bindable(false),
onOpenChange = () => {},
modal = true,
portalTo,
// Command
label,
value = $bindable(''),
onValueChange = () => {},
search = $bindable(''),
onSearchChange = () => {},
shouldFilter = true,
filter = computeCommandScore,
loop = false,
vimBindings = true,
columns = 1,
disablePointerSelection = false,
overlay: overlaySnippet,
// Rename to avoid shadowing the inner `children` snippet, which would
// otherwise be a recursive reference and freeze the renderer.
children: userChildren
}: CommandDialogProps = $props();
</script>
<Dialog.Provider bind:open {modal} onOpenChange={(v) => onOpenChange(v)}>
<Portal to={portalTo}>
{#if overlaySnippet}
{@render overlaySnippet()}
{:else}
<Dialog.Overlay />
{/if}
<Dialog.Content>
<Command
{label}
bind:value
onValueChange={(v) => onValueChange(v)}
bind:search
onSearchChange={(v) => onSearchChange(v)}
{shouldFilter}
{filter}
{loop}
{vimBindings}
{columns}
{disablePointerSelection}
>
{#snippet children(sp)}
{@render userChildren?.(sp)}
{/snippet}
</Command>
</Dialog.Content>
</Portal>
</Dialog.Provider>

@ -0,0 +1,37 @@
<script lang="ts">
import { readableActive, writableActive } from '../../../reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { CommandEmptyProvider } from '../command-provider.svelte';
import type { CommandEmptyProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'command-empty'),
children,
child,
...restProps
}: CommandEmptyProps = $props();
const state = CommandEmptyProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if state.shouldRender}
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}
{/if}

@ -0,0 +1,35 @@
<script lang="ts">
import { readableActive, writableActive } from '../../../reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { CommandGroupHeadingProvider } from '../command-provider.svelte';
import type { CommandGroupHeadingProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'command-group-heading'),
children,
child,
...restProps
}: CommandGroupHeadingProps = $props();
const state = CommandGroupHeadingProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}

@ -0,0 +1,35 @@
<script lang="ts">
import { readableActive, writableActive } from '../../../reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { CommandGroupItemsProvider } from '../command-provider.svelte';
import type { CommandGroupItemsProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'command-group-items'),
children,
child,
...restProps
}: CommandGroupItemsProps = $props();
const state = CommandGroupItemsProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}

@ -0,0 +1,46 @@
<script lang="ts">
import { readableActive, writableActive } from '../../../reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { CommandGroupProvider } from '../command-provider.svelte';
import type { CommandGroupProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'command-group'),
value = id,
forceMount = false,
children,
child,
...restProps
}: CommandGroupProps = $props();
const state = CommandGroupProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
value: readableActive(() => value),
forceMount: readableActive(() => forceMount)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
<!--
Always render the group so its Items can mount and register. When the
group has no visible items it exposes `data-empty` — the consumer's CSS
(e.g. `[data-command-group][data-empty] { display: none; }`) handles the
visual hiding. Otherwise we'd deadlock: no items registered → hasVisibleItems
false → children never render → items never mount.
-->
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}

@ -0,0 +1,32 @@
<script lang="ts">
import { readableActive, writableActive } from '../../../reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { CommandInputProvider } from '../command-provider.svelte';
import type { CommandInputProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'command-input'),
child,
...restProps
}: CommandInputProps = $props();
const state = CommandInputProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<input {...mergedProps} />
{/if}

@ -0,0 +1,47 @@
<script lang="ts">
import { readableActive, writableActive } from '../../../reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { CommandItemProvider } from '../command-provider.svelte';
import type { CommandItemProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'command-item'),
value,
keywords = [] as readonly string[],
disabled = false,
forceMount = false,
onSelect,
children,
child,
...restProps
}: CommandItemProps = $props();
const state = CommandItemProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
value: readableActive(() => value),
keywords: readableActive(() => keywords),
disabled: readableActive(() => disabled),
forceMount: readableActive(() => forceMount),
onSelect: readableActive(() => onSelect)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if state.isVisible}
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}
{/if}

@ -0,0 +1,49 @@
<script lang="ts">
import { readableActive, writableActive } from '../../../reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { CommandLinkItemProvider } from '../command-provider.svelte';
import type { CommandLinkItemProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'command-link-item'),
value,
keywords = [] as readonly string[],
disabled = false,
forceMount = false,
href,
onSelect,
children,
child,
...restProps
}: CommandLinkItemProps = $props();
const state = CommandLinkItemProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
value: readableActive(() => value),
keywords: readableActive(() => keywords),
disabled: readableActive(() => disabled),
forceMount: readableActive(() => forceMount),
href: readableActive(() => href),
onSelect: readableActive(() => onSelect)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if state.isVisible}
{#if child}
{@render child({ props: mergedProps })}
{:else}
<a {...mergedProps}>
{@render children?.()}
</a>
{/if}
{/if}

@ -0,0 +1,39 @@
<script lang="ts">
import { readableActive, writableActive } from '../../../reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { CommandListProvider } from '../command-provider.svelte';
import type { CommandListProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'command-list'),
children,
child,
...restProps
}: CommandListProps = $props();
const state = CommandListProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived({
...mergeProps(restProps, state.props),
'aria-label':
(restProps as { 'aria-label'?: string })['aria-label'] ?? state.props['aria-label']
});
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}

@ -0,0 +1,37 @@
<script lang="ts">
import { readableActive, writableActive } from '../../../reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { CommandLoadingProvider } from '../command-provider.svelte';
import type { CommandLoadingProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'command-loading'),
progress,
children,
child,
...restProps
}: CommandLoadingProps = $props();
const state = CommandLoadingProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
progress: readableActive(() => progress)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}

@ -0,0 +1,39 @@
<script lang="ts">
import { readableActive, writableActive } from '../../../reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { CommandSeparatorProvider } from '../command-provider.svelte';
import type { CommandSeparatorProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'command-separator'),
forceMount = false,
children,
child,
...restProps
}: CommandSeparatorProps = $props();
const state = CommandSeparatorProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
forceMount: readableActive(() => forceMount)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if state.shouldRender}
{#if child}
{@render child({ props: mergedProps })}
{:else}
<span {...mergedProps}>
{@render children?.()}
</span>
{/if}
{/if}

@ -0,0 +1,35 @@
<script lang="ts">
import { readableActive, writableActive } from '../../../reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { CommandViewportProvider } from '../command-provider.svelte';
import type { CommandViewportProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'command-viewport'),
children,
child,
...restProps
}: CommandViewportProps = $props();
const state = CommandViewportProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}

@ -0,0 +1,72 @@
<script lang="ts">
import { readableActive, writableActive } from '../../../reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { CommandProvider } from '../command-provider.svelte';
import type { CommandProps } from '../types';
import { computeCommandScore } from '../score';
import { Soma } from '../../../core/soma.svelte';
const uid = $props.id();
const soma = Soma.get();
let {
ref = $bindable(null),
id = createId(uid, 'command'),
label,
value = $bindable(''),
onValueChange = () => {},
search = $bindable(''),
onSearchChange = () => {},
shouldFilter = true,
filter = computeCommandScore,
loop = false,
vimBindings = true,
columns = 1,
disablePointerSelection = false,
dir,
children,
child,
...restProps
}: CommandProps = $props();
const state = CommandProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
value: writableActive(
() => value,
(v) => {
value = v;
onValueChange(v);
}
),
search: writableActive(
() => search,
(v) => {
search = v;
onSearchChange(v);
}
),
label: readableActive(() => label),
shouldFilter: readableActive(() => shouldFilter),
filter: readableActive(() => filter),
loop: readableActive(() => loop),
vimBindings: readableActive(() => vimBindings),
columns: readableActive(() => columns),
disablePointerSelection: readableActive(() => disablePointerSelection),
dir: readableActive(() => dir ?? soma?.presentation.getDir() ?? 'ltr')
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ ...state.snippetProps, props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.(state.snippetProps)}
</div>
{/if}

@ -0,0 +1,34 @@
export { default as Provider } from './components/command.svelte';
/** Composition helper: Dialog + Command in a single component. */
export { default as Dialog } from './components/command-dialog.svelte';
export { default as Input } from './components/command-input.svelte';
export { default as List } from './components/command-list.svelte';
export { default as Viewport } from './components/command-viewport.svelte';
export { default as Item } from './components/command-item.svelte';
export { default as LinkItem } from './components/command-link-item.svelte';
export { default as Group } from './components/command-group.svelte';
export { default as GroupHeading } from './components/command-group-heading.svelte';
export { default as GroupItems } from './components/command-group-items.svelte';
export { default as Empty } from './components/command-empty.svelte';
export { default as Loading } from './components/command-loading.svelte';
export { default as Separator } from './components/command-separator.svelte';
export { computeCommandScore } from './score';
export type {
CommandFilterFn,
CommandApi,
CommandSnippetProps,
CommandProps as ProviderProps,
CommandInputProps as InputProps,
CommandListProps as ListProps,
CommandViewportProps as ViewportProps,
CommandItemProps as ItemProps,
CommandLinkItemProps as LinkItemProps,
CommandGroupProps as GroupProps,
CommandGroupHeadingProps as GroupHeadingProps,
CommandGroupItemsProps as GroupItemsProps,
CommandEmptyProps as EmptyProps,
CommandLoadingProps as LoadingProps,
CommandSeparatorProps as SeparatorProps
} from './types';

@ -0,0 +1 @@
export * from './exports';

@ -0,0 +1,4 @@
/** Idlangref constants for Command. */
export const COMMAND_LANGS = {
LIST: '#?components.command.list|Command palette'
} as const;

@ -0,0 +1,89 @@
// Public `cmdk` scoring algorithm. A score of 1 is a continuous match;
// 0 is no match. Matches at the start of a word (space / symbol jump) are
// rewarded; character-by-character gaps are penalised.
const SCORE_CONTINUE_MATCH = 1;
const SCORE_SPACE_WORD_JUMP = 0.9;
const SCORE_NON_SPACE_WORD_JUMP = 0.8;
const SCORE_CHARACTER_JUMP = 0.17;
const SCORE_TRANSPOSITION = 0.1;
const PENALTY_SKIPPED = 0.999;
const PENALTY_CASE_MISMATCH = 0.9999;
const PENALTY_NOT_COMPLETE = 0.99;
const IS_GAP_REGEXP = /[\\/_+.#"@[({&]/;
const COUNT_GAPS_REGEXP = /[\\/_+.#"@[({&]/g;
const IS_SPACE_REGEXP = /[\s-]/;
const COUNT_SPACE_REGEXP = /[\s-]/g;
function scoreInner(
s: string,
abbr: string,
ls: string,
la: string,
sIdx: number,
aIdx: number,
memo: Record<string, number>
): number {
if (aIdx === abbr.length) return sIdx === s.length ? SCORE_CONTINUE_MATCH : PENALTY_NOT_COMPLETE;
const key = `${sIdx},${aIdx}`;
if (memo[key] !== undefined) return memo[key];
const abbrChar = la.charAt(aIdx);
let i = ls.indexOf(abbrChar, sIdx);
let best = 0;
while (i >= 0) {
let score = scoreInner(s, abbr, ls, la, i + 1, aIdx + 1, memo);
if (score > best) {
if (i === sIdx) {
score *= SCORE_CONTINUE_MATCH;
} else if (IS_GAP_REGEXP.test(s.charAt(i - 1))) {
score *= SCORE_NON_SPACE_WORD_JUMP;
const gaps = s.slice(sIdx, i - 1).match(COUNT_GAPS_REGEXP);
if (gaps && sIdx > 0) score *= PENALTY_SKIPPED ** gaps.length;
} else if (IS_SPACE_REGEXP.test(s.charAt(i - 1))) {
score *= SCORE_SPACE_WORD_JUMP;
const spaces = s.slice(sIdx, i - 1).match(COUNT_SPACE_REGEXP);
if (spaces && sIdx > 0) score *= PENALTY_SKIPPED ** spaces.length;
} else {
score *= SCORE_CHARACTER_JUMP;
if (sIdx > 0) score *= PENALTY_SKIPPED ** (i - sIdx);
}
if (s.charAt(i) !== abbr.charAt(aIdx)) score *= PENALTY_CASE_MISMATCH;
}
if (
(score < SCORE_TRANSPOSITION && ls.charAt(i - 1) === la.charAt(aIdx + 1)) ||
(la.charAt(aIdx + 1) === la.charAt(aIdx) && ls.charAt(i - 1) !== la.charAt(aIdx))
) {
const transposed = scoreInner(s, abbr, ls, la, i + 1, aIdx + 2, memo);
if (transposed * SCORE_TRANSPOSITION > score) score = transposed * SCORE_TRANSPOSITION;
}
if (score > best) best = score;
i = ls.indexOf(abbrChar, i + 1);
}
memo[key] = best;
return best;
}
function normalize(s: string): string {
return s.toLowerCase().replace(COUNT_SPACE_REGEXP, ' ');
}
/**
* Default scorer. Returns a float in [0, 1]; 0 means no match.
* Keywords are joined with the value and scored as a single string.
*/
export function computeCommandScore(
value: string,
search: string,
keywords?: readonly string[]
): number {
if (!search) return 1;
const combined = keywords && keywords.length > 0 ? `${value} ${keywords.join(' ')}` : value;
return scoreInner(combined, search, normalize(combined), normalize(search), 0, 0, {});
}

@ -0,0 +1,173 @@
import type { Snippet } from 'svelte';
import type { WithChild, Without, OnChangeFn } from '../../types';
import type {
PrimitiveAnchorAttributes,
PrimitiveDivAttributes,
PrimitiveInputAttributes,
PrimitiveSpanAttributes
} from '../../types';
/** Signature of a custom scoring function. Return 0 to exclude, >0 to include. */
export type CommandFilterFn = (
value: string,
search: string,
keywords?: readonly string[]
) => number;
/**
* Imperative API exposed by the Command provider through snippet props. All
* methods are bound to the provider instance.
*/
export type CommandApi = {
/** Move the highlight down by `step` (default 1, or `columns` via Input). */
next: (step?: number) => void;
/** Move the highlight up by `step`. */
prev: (step?: number) => void;
/** Highlight the first visible item. */
first: () => void;
/** Highlight the last visible item. */
last: () => void;
/** Activate the currently highlighted item (fires its `onSelect`). */
select: () => void;
/** Set the highlighted value explicitly. Ignored if not visible. */
setValue: (value: string) => void;
/** Set the search string programmatically. */
setSearch: (value: string) => void;
/** Shortcut for `setSearch('')`. */
clearSearch: () => void;
/** Query whether a value is currently visible (passes the filter). */
isVisible: (value: string) => boolean;
/** Query whether a value is the current highlight. */
isSelected: (value: string) => boolean;
};
/** Snippet props exposed by the Provider — reactive state + imperative API. */
export type CommandSnippetProps = CommandApi & {
/** Currently highlighted value. */
value: string;
/** Current search string. */
search: string;
/** Ordered list of visible item values (after filtering). */
visible: string[];
/** True when the filter leaves no visible items. */
isEmpty: boolean;
};
export type CommandProps = WithChild<
{
/** Unique identifier. Auto-generated if omitted. */
id?: string;
/** Accessible label for the listbox. */
label?: string;
/** Currently highlighted item value. Bindable. */
value?: string;
/** Callback on highlighted value change. */
onValueChange?: OnChangeFn<string>;
/** Current search string. Bindable. @default '' */
search?: string;
/** Callback on search change. */
onSearchChange?: OnChangeFn<string>;
/** Whether to run the scoring filter over items. @default true */
shouldFilter?: boolean;
/** Custom scoring function. @default built-in fuzzy matcher */
filter?: CommandFilterFn;
/** Loop keyboard navigation at the edges. @default false */
loop?: boolean;
/**
* Enable vim-style keyboard bindings: `Ctrl+N`/`Ctrl+J` = next,
* `Ctrl+P`/`Ctrl+K` = previous.
* @default true
*/
vimBindings?: boolean;
/**
* Number of grid columns. When > 1, arrow keys move across columns
* (Right/Left = ±1, Down/Up = ±columns).
* @default 1
*/
columns?: number;
/**
* When true, hovering an item with the pointer does NOT change the
* highlighted value (useful for heavy "on-change" side effects).
* @default false
*/
disablePointerSelection?: boolean;
/**
* Writing direction. When `'rtl'`, grid arrow-key navigation inverts
* (`ArrowLeft` moves forward, `ArrowRight` moves backward). Falls back
* to `soma.presentation.getDir()`, then `'ltr'`.
*/
dir?: 'ltr' | 'rtl';
},
CommandSnippetProps
> &
Without<PrimitiveDivAttributes, {}>;
export type CommandInputProps = WithChild<{ id?: string }> &
Without<PrimitiveInputAttributes, {}>;
export type CommandListProps = WithChild<{ id?: string }> &
Without<PrimitiveDivAttributes, {}>;
export type CommandViewportProps = WithChild<{ id?: string }> &
Without<PrimitiveDivAttributes, {}>;
export type CommandItemProps = WithChild<{
id?: string;
/** Item value. Required. Must be unique in the palette. */
value: string;
/** Optional text aliases used by the scorer (e.g. `['new', 'create']`). */
keywords?: readonly string[] | string[];
/** Disable activation (still focusable if `accessibleWhenDisabled`). */
disabled?: boolean;
/** Always render even when filtered out. @default false */
forceMount?: boolean;
/** Callback when the item is selected (Enter or click). */
onSelect?: (value: string) => void;
}> &
Without<PrimitiveDivAttributes, {}>;
export type CommandLinkItemProps = WithChild<{
id?: string;
value: string;
keywords?: readonly string[] | string[];
disabled?: boolean;
forceMount?: boolean;
onSelect?: (value: string) => void;
/** URL — rendered as `<a href>`. Activation navigates. */
href: string;
}> &
Without<PrimitiveAnchorAttributes, {}>;
export type CommandGroupProps = WithChild<{
id?: string;
/** Identifier for the group. Useful for imperative access. */
value?: string;
/** Always render the group even when all items are filtered out. @default false */
forceMount?: boolean;
}> &
Without<PrimitiveDivAttributes, {}>;
export type CommandGroupHeadingProps = WithChild<{ id?: string }> &
Without<PrimitiveDivAttributes, {}>;
export type CommandGroupItemsProps = WithChild<{ id?: string }> &
Without<PrimitiveDivAttributes, {}>;
export type CommandEmptyProps = WithChild<{ id?: string; children?: Snippet }> &
Without<PrimitiveDivAttributes, {}>;
export type CommandLoadingProps = WithChild<{
id?: string;
/** Progress 0–100. When undefined, rendered as indeterminate. */
progress?: number;
/** Always render while loading. */
children?: Snippet;
}> &
Without<PrimitiveDivAttributes, {}>;
export type CommandSeparatorProps = WithChild<{
id?: string;
/** Always render (don't hide when adjacent groups are empty). @default false */
forceMount?: boolean;
}> &
Without<PrimitiveSpanAttributes, {}>;

@ -0,0 +1,23 @@
<script lang="ts">
import { writableActive } from '../../../reactive';
import { MenuCheckboxGroupProvider } from '../dropdown-menu-provider.svelte';
import type { MenuCheckboxGroupProps } from '../types';
let {
value = $bindable<string[]>([]),
onValueChange = () => {},
children
}: MenuCheckboxGroupProps = $props();
MenuCheckboxGroupProvider.create({
value: writableActive(
() => value,
(v) => {
value = v;
onValueChange(v);
}
)
});
</script>
{@render children?.()}

@ -1,14 +1,22 @@
export * as Accordion from './accordion';
export * as Calendar from './calendar';
export * as Checkbox from './checkbox';
export * as Collapsible from './collapsible';
export * as Combobox from './combobox';
export * as Command from './command';
export * as ContextMenu from './context-menu';
export * as Dialog from './dialog';
export * as DropdownMenu from './dropdown-menu';
export * as Editable from './editable';
export * as Field from './field';
export * as FileUpload from './file-upload';
export * as LinkPreview from './link-preview';
export * as Menubar from './menubar';
export * as NumberField from './number-field';
export * as Pagination from './pagination';
export * as Popover from './popover';
export * as RadioGroup from './radio-group';
export * as RangeCalendar from './range-calendar';
export * as ScrollArea from './scroll-area';
export * as Select from './select';
export * as Slider from './slider';

@ -0,0 +1,188 @@
# Menubar
A horizontal menu bar of dropdown menus. Supports submenus, checkbox items, radio groups, keyboard navigation, typeahead, and cross-menu arrow navigation. Built on top of DropdownMenu.
## Anatomy
```svelte
<Menubar.Provider>
<Menubar.Menu value="file">
<Menubar.Trigger>File</Menubar.Trigger>
<Menubar.Content>
<Menubar.Item>New File</Menubar.Item>
<Menubar.Item>Open…</Menubar.Item>
<Menubar.Separator />
<Menubar.Item>Save</Menubar.Item>
</Menubar.Content>
</Menubar.Menu>
<Menubar.Menu value="edit">
<Menubar.Trigger>Edit</Menubar.Trigger>
<Menubar.Content>
<Menubar.Item>Undo</Menubar.Item>
<Menubar.Item>Redo</Menubar.Item>
<Menubar.Separator />
<Menubar.CheckboxItem bind:checked={showToolbar}>Show Toolbar</Menubar.CheckboxItem>
</Menubar.Content>
</Menubar.Menu>
</Menubar.Provider>
```
## Parts
| Part | Element | Description |
| --------------- | ---------- | ------------------------------------------------------------ |
| `Provider` | `<div>` | Root context. Manages which menu is open. |
| `Menu` | none | Wrapper for a single menu. Creates an internal DropdownMenu. |
| `Trigger` | `<button>` | Opens the menu on click or keyboard. Roving tabindex. |
| `Content` | `<div>` | Menu container. Floating positioning, cross-menu navigation. |
| `Arrow` | `<svg>` | Optional arrow (re-exported from DropdownMenu). |
| `Item` | `<div>` | Action item (re-exported from DropdownMenu). |
| `Group` | `<div>` | Groups related items (re-exported from DropdownMenu). |
| `GroupHeading` | `<div>` | Group label (re-exported from DropdownMenu). |
| `Separator` | `<div>` | Visual separator (re-exported from DropdownMenu). |
| `CheckboxItem` | `<div>` | Toggleable item (re-exported from DropdownMenu). |
| `CheckboxGroup` | `<div>` | Multi-select checkbox group (re-exported from DropdownMenu). |
| `RadioGroup` | `<div>` | Mutually exclusive group (re-exported from DropdownMenu). |
| `RadioItem` | `<div>` | Radio option (re-exported from DropdownMenu). |
| `Sub` | none | Submenu context (re-exported from DropdownMenu). |
| `SubTrigger` | `<div>` | Opens a submenu (re-exported from DropdownMenu). |
| `SubContent` | `<div>` | Submenu container (re-exported from DropdownMenu). |
## ARIA
| Part | Attribute | Value |
| -------- | ------------------ | ----------------- |
| Provider | `role` | `menubar` |
| Provider | `aria-orientation` | `horizontal` |
| Trigger | `role` | `menuitem` |
| Trigger | `aria-haspopup` | `menu` |
| Trigger | `aria-expanded` | `true` \| `false` |
| Trigger | `aria-controls` | ID of Content |
| Content | `role` | `menu` |
| Content | `aria-labelledby` | ID of Trigger |
| Content | `aria-orientation` | `vertical` |
Item, CheckboxItem, RadioGroup, RadioItem, SubTrigger, SubContent, Group, Separator, and Arrow inherit ARIA attributes from DropdownMenu.
## Data Attributes
| Part | Attribute | Values |
| -------- | ---------------------- | -------------------------------------- |
| Provider | `data-menubar` | Always present |
| Trigger | `data-menubar-trigger` | Always present |
| Trigger | `data-state` | `open` \| `closed` |
| Trigger | `data-disabled` | Present when disabled |
| Trigger | `data-highlighted` | Present when focused |
| Trigger | `data-menubar-value` | The parent Menu's value |
| Content | `data-state` | `open` \| `closed` |
| Content | `data-side` | `top` \| `right` \| `bottom` \| `left` |
| Content | `data-align` | `start` \| `center` \| `end` |
Item, CheckboxItem, RadioItem, SubTrigger, SubContent, Group, Separator, and Arrow inherit data attributes from DropdownMenu.
## CSS Variables
| Variable | Part | Description |
| ---------------------------------- | ------- | ------------------------------- |
| `--soma-floating-transform-origin` | Content | Transform origin for animations |
| `--soma-floating-available-width` | Content | Available viewport width |
| `--soma-floating-available-height` | Content | Available viewport height |
| `--soma-floating-anchor-width` | Content | Anchor element width |
| `--soma-floating-anchor-height` | Content | Anchor element height |
## Keyboard
### On Trigger (menubar level)
| Key | Action |
| ------------------ | ------------------------------ |
| `Enter` / `Space` | Open the associated menu |
| `ArrowDown` | Open the associated menu |
| `ArrowRight` (LTR) | Move focus to next trigger |
| `ArrowLeft` (LTR) | Move focus to previous trigger |
| `Home` | Focus first trigger |
| `End` | Focus last trigger |
### On Content (menu level)
| Key | Action |
| ------------------ | --------------------------------- |
| `ArrowDown` | Move to next item |
| `ArrowUp` | Move to previous item |
| `Home` | Move to first item |
| `End` | Move to last item |
| `Enter` / `Space` | Activate focused item |
| `Escape` | Close menu, focus trigger |
| `ArrowRight` (LTR) | Open next menu (cross-menu nav) |
| `ArrowLeft` (LTR) | Open previous menu (cross-menu) |
| Character keys | Typeahead — jump to matching item |
Horizontal arrow keys switch between menus while a menu is open. Directional keys are flipped in RTL mode.
Triggers use roving tabindex: one trigger has `tabindex="0"`, all others have `tabindex="-1"`.
## Usage
### Basic
```svelte
<script>
import { Menubar } from '$soma/components';
</script>
<Menubar.Provider>
<Menubar.Menu value="file">
<Menubar.Trigger>File</Menubar.Trigger>
<Menubar.Content>
<Menubar.Item>New File</Menubar.Item>
<Menubar.Item>Open</Menubar.Item>
<Menubar.Separator />
<Menubar.Item>Save</Menubar.Item>
</Menubar.Content>
</Menubar.Menu>
<Menubar.Menu value="edit">
<Menubar.Trigger>Edit</Menubar.Trigger>
<Menubar.Content>
<Menubar.Item>Undo</Menubar.Item>
<Menubar.Item>Redo</Menubar.Item>
</Menubar.Content>
</Menubar.Menu>
</Menubar.Provider>
```
### Checkbox item
```svelte
<script>
let showToolbar = $state(true);
</script>
<Menubar.CheckboxItem bind:checked={showToolbar}>Show Toolbar</Menubar.CheckboxItem>
```
### Radio group
```svelte
<script>
let theme = $state('light');
</script>
<Menubar.RadioGroup bind:value={theme}>
<Menubar.RadioItem value="light">Light</Menubar.RadioItem>
<Menubar.RadioItem value="dark">Dark</Menubar.RadioItem>
</Menubar.RadioGroup>
```
### Submenu
```svelte
<Menubar.Sub>
<Menubar.SubTrigger>Recent</Menubar.SubTrigger>
<Menubar.SubContent>
<Menubar.Item>File 1</Menubar.Item>
<Menubar.Item>File 2</Menubar.Item>
</Menubar.SubContent>
</Menubar.Sub>
```

@ -0,0 +1,81 @@
<script lang="ts">
import DropdownMenuContent from '../../dropdown-menu/components/dropdown-menu-content.svelte';
import type { MenuContentProps } from '../../dropdown-menu/types';
import { MenubarMenuProvider } from '../menubar-provider.svelte';
import { KEYS, getDirectionalKeys } from '../../../keyboard';
let {
children: outerChildren,
child: outerChild,
onkeydown: userKeydown,
...rest
}: MenuContentProps & { onkeydown?: (e: KeyboardEvent) => void } = $props();
const menu = MenubarMenuProvider.require();
const provider = menu.provider;
/**
* Cross-menu arrow navigation while focus is inside the open menu's content.
* - `ArrowRight` (LTR): close current, open next menu — unless focus is on a
* SubTrigger (opens submenu instead) or already inside a SubContent.
* - `ArrowLeft` (LTR): close current, open previous menu — unless focus is
* inside a SubContent (closes submenu instead).
* Runs BEFORE DropdownMenu's own onkeydown; preventDefault suppresses the
* item-level vertical-navigation handler only for these two keys.
*/
const crossMenuNav = (e: KeyboardEvent) => {
const dir = provider.opts.dir.current;
const { nextKey, prevKey } = getDirectionalKeys(dir, 'horizontal');
if (e.key !== nextKey && e.key !== prevKey) return;
const target = e.target as HTMLElement | null;
if (!target) return;
const isSubTrigger = target.hasAttribute('data-dropdown-menu-sub-trigger');
const isInsideSub = !!target.closest('[data-dropdown-menu-sub-content]');
if (e.key === nextKey && isSubTrigger) return;
if (e.key === prevKey && isInsideSub) return;
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) return;
e.preventDefault();
const currentValue = menu.opts.value.current;
const delta: 1 | -1 = e.key === nextKey ? 1 : -1;
const values = provider.getOrderedValues();
if (values.length === 0) return;
const idx = values.indexOf(currentValue);
let nextIdx: number;
if (idx === -1) {
nextIdx = delta === 1 ? 0 : values.length - 1;
} else if (provider.opts.loop.current) {
nextIdx = (idx + delta + values.length) % values.length;
} else {
nextIdx = Math.max(0, Math.min(values.length - 1, idx + delta));
}
const nextValue = values[nextIdx];
if (!nextValue || nextValue === currentValue) return;
provider.open(nextValue);
provider.focusTrigger(nextValue);
};
const composedKeydown = (e: KeyboardEvent) => {
if (e.key === KEYS.ESCAPE) {
const current = menu.opts.value.current;
queueMicrotask(() => provider.focusTrigger(current));
}
crossMenuNav(e);
if (!e.defaultPrevented) userKeydown?.(e);
};
</script>
{#if outerChild}
<DropdownMenuContent {...rest} onkeydown={composedKeydown} child={outerChild} />
{:else}
<DropdownMenuContent {...rest} onkeydown={composedKeydown}>
{#snippet children(sp)}
{@render outerChildren?.(sp)}
{/snippet}
</DropdownMenuContent>
{/if}

@ -0,0 +1,20 @@
<script lang="ts">
import { readableActive } from '../../../reactive';
import { MenubarMenuProvider } from '../menubar-provider.svelte';
import type { MenubarMenuProps } from '../types';
let {
value,
onOpenChange = () => {},
onOpenChangeComplete = () => {},
children
}: MenubarMenuProps = $props();
MenubarMenuProvider.create({
value: readableActive(() => value),
onOpenChange: readableActive(() => onOpenChange),
onOpenChangeComplete: readableActive(() => onOpenChangeComplete)
});
</script>
{@render children?.()}

@ -0,0 +1,37 @@
<script lang="ts">
import { readableActive, writableActive } from '../../../reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { MenubarTriggerProvider } from '../menubar-provider.svelte';
import type { MenubarTriggerProps } from '../types';
const uid = $props.id();
let {
ref = $bindable(null),
id = createId(uid, 'menubar-trigger'),
disabled = false,
children,
child,
...restProps
}: MenubarTriggerProps = $props();
const state = MenubarTriggerProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
disabled: readableActive(() => disabled)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<button {...mergedProps}>
{@render children?.()}
</button>
{/if}

@ -0,0 +1,50 @@
<script lang="ts">
import { readableActive, writableActive } from '../../../reactive';
import { mergeProps } from '../../../props';
import { createId } from '../../../id';
import { MenubarProvider } from '../menubar-provider.svelte';
import type { MenubarProps } from '../types';
import { Soma } from '../../../core/soma.svelte';
const uid = $props.id();
const soma = Soma.get();
let {
ref = $bindable(null),
id = createId(uid, 'menubar'),
value = $bindable(''),
onValueChange = () => {},
dir,
loop = true,
children,
child,
...restProps
}: MenubarProps = $props();
const state = MenubarProvider.create({
id: readableActive(() => id),
ref: writableActive(
() => ref,
(v) => (ref = v)
),
value: writableActive(
() => value,
(v) => {
value = v;
onValueChange(v);
}
),
dir: readableActive(() => dir ?? soma?.presentation.getDir() ?? 'ltr'),
loop: readableActive(() => loop)
});
const mergedProps = $derived(mergeProps(restProps, state.props));
</script>
{#if child}
{@render child({ props: mergedProps })}
{:else}
<div {...mergedProps}>
{@render children?.()}
</div>
{/if}

@ -0,0 +1,44 @@
export { default as Provider } from './components/menubar.svelte';
export { default as Menu } from './components/menubar-menu.svelte';
export { default as Trigger } from './components/menubar-trigger.svelte';
// Menubar-specific Content adds cross-menu ArrowLeft/Right navigation on top
// of DropdownMenu.Content (which supplies Floating, Presence, Dismissal, etc.).
export { default as Content } from './components/menubar-content.svelte';
// Reused DropdownMenu parts — they read from the nested MenuProvider context
// that MenubarMenu establishes automatically.
export { default as Arrow } from '../dropdown-menu/components/dropdown-menu-arrow.svelte';
export { default as Item } from '../dropdown-menu/components/dropdown-menu-item.svelte';
export { default as Group } from '../dropdown-menu/components/dropdown-menu-group.svelte';
export { default as GroupHeading } from '../dropdown-menu/components/dropdown-menu-group-heading.svelte';
export { default as Separator } from '../dropdown-menu/components/dropdown-menu-separator.svelte';
export { default as CheckboxItem } from '../dropdown-menu/components/dropdown-menu-checkbox-item.svelte';
export { default as CheckboxGroup } from '../dropdown-menu/components/dropdown-menu-checkbox-group.svelte';
export { default as RadioGroup } from '../dropdown-menu/components/dropdown-menu-radio-group.svelte';
export { default as RadioItem } from '../dropdown-menu/components/dropdown-menu-radio-item.svelte';
export { default as Sub } from '../dropdown-menu/components/dropdown-menu-sub.svelte';
export { default as SubTrigger } from '../dropdown-menu/components/dropdown-menu-sub-trigger.svelte';
export { default as SubContent } from '../dropdown-menu/components/dropdown-menu-sub-content.svelte';
export type {
MenubarProps as ProviderProps,
MenubarMenuProps as MenuProps,
MenubarTriggerProps as TriggerProps
} from './types';
// Re-export Menu part prop types so Menubar consumers get a complete API.
export type {
MenuContentProps as ContentProps,
MenuArrowProps as ArrowProps,
MenuItemProps as ItemProps,
MenuGroupProps as GroupProps,
MenuGroupHeadingProps as GroupHeadingProps,
MenuSeparatorProps as SeparatorProps,
MenuCheckboxItemProps as CheckboxItemProps,
MenuCheckboxGroupProps as CheckboxGroupProps,
MenuRadioGroupProps as RadioGroupProps,
MenuRadioItemProps as RadioItemProps,
MenuSubProps as SubProps,
MenuSubTriggerProps as SubTriggerProps,
MenuSubContentProps as SubContentProps
} from '../dropdown-menu/types';

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save

Powered by TurnKey Linux.