fix(primitives): eslint/tsconfig migration, asChild refactor, type fixes

- Migrate to eslint flat config + composite tsconfig.
- Complete the asChild→as="template" refactor (remove asChild prop + :as-child
  bindings across components, matching Primitive's slot model).
- Fix test type errors and source type-safety (useGraceArea hull/point math,
  FocusScope/util ref typing).

Note: ~53 vue-tsc errors remain (HTML attr/event passthrough typing on
transparent wrapper components + a couple of duplicate-export naming
collisions) — not gated by CI (build/lint/test green); pending a
component-attribute-typing design decision.
This commit is contained in:
2026-06-07 16:29:56 +07:00
parent c7644ade69
commit 626fbc70d8
408 changed files with 27367 additions and 154 deletions
@@ -0,0 +1,43 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface CalendarCellProps extends PrimitiveProps {
/** The date this cell represents. */
date: Date;
}
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { Primitive } from '../primitive';
import { useCalendarGridContext, useCalendarRootContext } from './context';
import { isSameDay, isSameMonth } from './utils';
const { as = 'td', date } = defineProps<CalendarCellProps>();
const ctx = useCalendarRootContext();
const gridCtx = useCalendarGridContext();
const isSelected = computed(() => ctx.isDateSelected(date));
const isDisabled = computed(() => ctx.isDateDisabled(date));
const isUnavailable = computed(() => ctx.isDateUnavailable(date));
const isOutsideView = computed(() => !isSameMonth(date, gridCtx.month.value));
const isToday = computed(() => isSameDay(date, new Date()));
</script>
<template>
<Primitive
:as="as"
role="gridcell"
:aria-selected="isSelected ? true : undefined"
:aria-disabled="(isDisabled || isUnavailable) ? true : undefined"
:data-primitives-calendar-cell="''"
:data-selected="isSelected ? '' : undefined"
:data-disabled="isDisabled ? '' : undefined"
:data-unavailable="isUnavailable ? '' : undefined"
:data-outside-view="isOutsideView ? '' : undefined"
:data-today="isToday ? '' : undefined"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,198 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface CalendarCellTriggerProps extends PrimitiveProps {
/** The day this trigger represents. */
day: Date;
/** The month this trigger's cell belongs to. Defaults to grid context. */
month?: Date;
}
export interface CalendarCellTriggerSlotProps {
dayValue: string;
disabled: boolean;
selected: boolean;
today: boolean;
outsideView: boolean;
unavailable: boolean;
}
</script>
<script setup lang="ts">
import { useForwardExpose } from '@robonen/vue';
import { computed, nextTick } from 'vue';
import { Primitive } from '../primitive';
import { useCalendarGridContext, useCalendarRootContext } from './context';
import { addDays, addMonths, addYears, formatFullDate, isSameDay, isSameMonth } from './utils';
const { as = 'div', day, month } = defineProps<CalendarCellTriggerProps>();
defineSlots<{
default?: (props: CalendarCellTriggerSlotProps) => unknown;
}>();
const ctx = useCalendarRootContext();
const gridCtx = useCalendarGridContext();
const { forwardRef, currentElement } = useForwardExpose();
const monthValue = computed(() => month ?? gridCtx.month.value);
const isOutsideView = computed(() => !isSameMonth(day, monthValue.value));
const isDisabled = computed(() => ctx.isDateDisabled(day));
const isUnavailable = computed(() => ctx.isDateUnavailable(day));
const isSelected = computed(() => ctx.isDateSelected(day));
const isToday = computed(() => isSameDay(day, new Date()));
const dayValue = computed(() => day.getDate().toLocaleString(ctx.locale.value));
const labelText = computed(() => formatFullDate(day, ctx.locale.value));
const isFocusedDate = computed(() => {
if (isOutsideView.value || isDisabled.value) return false;
if (ctx.focusedDate.value) return isSameDay(day, ctx.focusedDate.value);
// Fallback focusable: selected, else today (if in view), else first day of month.
if (ctx.modelValue.value && isSameMonth(ctx.modelValue.value, monthValue.value))
return isSameDay(day, ctx.modelValue.value);
const today = new Date();
if (isSameMonth(today, monthValue.value))
return isSameDay(day, today);
return day.getDate() === 1 && isSameMonth(day, monthValue.value);
});
function selectIfAllowed() {
if (ctx.readonly.value) return;
if (isDisabled.value || isUnavailable.value) return;
ctx.setDate(day);
ctx.focusedDate.value = day;
}
function handleClick() {
selectIfAllowed();
}
function focusByDataValue(target: Date) {
const parent = ctx.parentElement.value;
if (!parent) return false;
const el = parent.querySelector<HTMLElement>(
`[data-primitives-calendar-cell-trigger][data-value="${target.toISOString().slice(0, 10)}"]:not([data-outside-view])`,
);
if (el) {
el.focus();
return true;
}
return false;
}
function shiftFocus(target: Date) {
if (ctx.minValue.value && target < ctx.minValue.value) return;
if (ctx.maxValue.value && target > ctx.maxValue.value) return;
ctx.focusedDate.value = target;
if (focusByDataValue(target)) return;
// Crossed visible range — page placeholder and retry.
if (target > ctx.placeholder.value) {
if (ctx.isNextButtonDisabled()) return;
ctx.nextPage();
}
else {
if (ctx.isPrevButtonDisabled()) return;
ctx.prevPage();
}
nextTick(() => focusByDataValue(target));
}
function handleKeyDown(e: KeyboardEvent) {
if (isDisabled.value) return;
const rtl = ctx.dir.value === 'rtl' ? -1 : 1;
switch (e.key) {
case 'ArrowRight':
e.preventDefault();
shiftFocus(addDays(day, rtl));
break;
case 'ArrowLeft':
e.preventDefault();
shiftFocus(addDays(day, -rtl));
break;
case 'ArrowUp':
e.preventDefault();
shiftFocus(addDays(day, -7));
break;
case 'ArrowDown':
e.preventDefault();
shiftFocus(addDays(day, 7));
break;
case 'Home': {
e.preventDefault();
const dow = day.getDay();
const offset = (dow - ctx.weekStartsOn.value + 7) % 7;
shiftFocus(addDays(day, -offset));
break;
}
case 'End': {
e.preventDefault();
const dow = day.getDay();
const offset = (dow - ctx.weekStartsOn.value + 7) % 7;
shiftFocus(addDays(day, 6 - offset));
break;
}
case 'PageUp':
e.preventDefault();
shiftFocus(e.shiftKey ? addYears(day, -1) : addMonths(day, -1));
break;
case 'PageDown':
e.preventDefault();
shiftFocus(e.shiftKey ? addYears(day, 1) : addMonths(day, 1));
break;
case 'Enter':
case ' ':
e.preventDefault();
selectIfAllowed();
break;
}
}
function handleFocus() {
ctx.focusedDate.value = day;
}
const dataValue = computed(() => day.toISOString().slice(0, 10));
const tabindex = computed(() => {
if (isFocusedDate.value) return 0;
if (isOutsideView.value || isDisabled.value) return undefined;
return -1;
});
defineExpose({ currentElement });
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
role="button"
:aria-label="labelText"
:aria-disabled="(isDisabled || isUnavailable) ? true : undefined"
:aria-selected="isSelected ? true : undefined"
:tabindex="tabindex"
:data-primitives-calendar-cell-trigger="''"
:data-value="dataValue"
:data-selected="isSelected ? '' : undefined"
:data-disabled="isDisabled ? '' : undefined"
:data-unavailable="isUnavailable ? '' : undefined"
:data-outside-view="isOutsideView ? '' : undefined"
:data-today="isToday ? '' : undefined"
:data-focused="isFocusedDate ? '' : undefined"
@click="handleClick"
@focus="handleFocus"
@keydown="handleKeyDown"
>
<slot
:day-value="dayValue"
:disabled="isDisabled"
:selected="isSelected"
:today="isToday"
:outside-view="isOutsideView"
:unavailable="isUnavailable"
>
{{ dayValue }}
</slot>
</Primitive>
</template>
@@ -0,0 +1,40 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface CalendarGridProps extends PrimitiveProps {
/** The month this grid represents. Defaults to the root placeholder's month. */
month?: Date;
}
</script>
<script setup lang="ts">
import { computed, toRef } from 'vue';
import { Primitive } from '../primitive';
import { provideCalendarGridContext, useCalendarRootContext } from './context';
const { as = 'table', month } = defineProps<CalendarGridProps>();
const ctx = useCalendarRootContext();
const monthRef = toRef(() => month ?? ctx.placeholder.value);
provideCalendarGridContext({ month: monthRef });
const readonly = computed(() => ctx.readonly.value || undefined);
const disabled = computed(() => ctx.disabled.value || undefined);
</script>
<template>
<Primitive
:as="as"
role="grid"
tabindex="-1"
:aria-label="ctx.fullCalendarLabel.value"
:aria-readonly="readonly ? true : undefined"
:aria-disabled="disabled ? true : undefined"
:data-primitives-calendar-grid="''"
:data-readonly="readonly ? '' : undefined"
:data-disabled="disabled ? '' : undefined"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,17 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface CalendarGridBodyProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../primitive';
const { as = 'tbody' } = defineProps<CalendarGridBodyProps>();
</script>
<template>
<Primitive :as="as" :data-primitives-calendar-grid-body="''">
<slot />
</Primitive>
</template>
@@ -0,0 +1,17 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface CalendarGridHeadProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../primitive';
const { as = 'thead' } = defineProps<CalendarGridHeadProps>();
</script>
<template>
<Primitive :as="as" :data-primitives-calendar-grid-head="''">
<slot />
</Primitive>
</template>
@@ -0,0 +1,17 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface CalendarGridRowProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../primitive';
const { as = 'tr' } = defineProps<CalendarGridRowProps>();
</script>
<template>
<Primitive :as="as" :data-primitives-calendar-grid-row="''">
<slot />
</Primitive>
</template>
@@ -0,0 +1,31 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface CalendarHeadCellProps extends PrimitiveProps {
/** The day this header cell represents — used for `aria-label`. */
day?: Date;
}
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { Primitive } from '../primitive';
import { useCalendarRootContext } from './context';
import { formatWeekday } from './utils';
const { as = 'th', day } = defineProps<CalendarHeadCellProps>();
const ctx = useCalendarRootContext();
const longLabel = computed(() => (day ? formatWeekday(day, ctx.locale.value, 'long') : undefined));
</script>
<template>
<Primitive
:as="as"
scope="col"
:aria-label="longLabel"
:data-primitives-calendar-head-cell="''"
>
<slot />
</Primitive>
</template>
@@ -0,0 +1,17 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface CalendarHeaderProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../primitive';
const { as = 'div' } = defineProps<CalendarHeaderProps>();
</script>
<template>
<Primitive :as="as" :data-primitives-calendar-header="''">
<slot />
</Primitive>
</template>
@@ -0,0 +1,31 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface CalendarHeadingProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '../primitive';
import { useCalendarRootContext } from './context';
const { as = 'div' } = defineProps<CalendarHeadingProps>();
defineSlots<{
default?: (props: { headingValue: string }) => unknown;
}>();
const ctx = useCalendarRootContext();
</script>
<template>
<Primitive
:as="as"
aria-hidden="true"
:data-primitives-calendar-heading="''"
:data-disabled="ctx.disabled.value ? '' : undefined"
>
<slot :heading-value="ctx.headingValue.value">
{{ ctx.headingValue.value }}
</slot>
</Primitive>
</template>
@@ -0,0 +1,45 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface CalendarNextProps extends PrimitiveProps {
/** Override the root's `nextPage` for just this button. */
nextPage?: (placeholder: Date) => Date;
}
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { Primitive } from '../primitive';
import { useCalendarRootContext } from './context';
const { as = 'button', nextPage: nextPageProp } = defineProps<CalendarNextProps>();
defineSlots<{
default?: (props: { disabled: boolean }) => unknown;
}>();
const ctx = useCalendarRootContext();
const disabled = computed(() => ctx.disabled.value || ctx.isNextButtonDisabled(nextPageProp));
function handleClick() {
if (disabled.value) return;
ctx.nextPage(nextPageProp);
}
</script>
<template>
<Primitive
:as="as"
:type="as === 'button' ? 'button' : undefined"
aria-label="Next"
:aria-disabled="disabled || undefined"
:data-primitives-calendar-next="''"
:data-disabled="disabled ? '' : undefined"
:disabled="as === 'button' ? disabled : undefined"
@click="handleClick"
>
<slot :disabled="disabled">
Next
</slot>
</Primitive>
</template>
@@ -0,0 +1,45 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
export interface CalendarPrevProps extends PrimitiveProps {
/** Override the root's `prevPage` for just this button. */
prevPage?: (placeholder: Date) => Date;
}
</script>
<script setup lang="ts">
import { computed } from 'vue';
import { Primitive } from '../primitive';
import { useCalendarRootContext } from './context';
const { as = 'button', prevPage: prevPageProp } = defineProps<CalendarPrevProps>();
defineSlots<{
default?: (props: { disabled: boolean }) => unknown;
}>();
const ctx = useCalendarRootContext();
const disabled = computed(() => ctx.disabled.value || ctx.isPrevButtonDisabled(prevPageProp));
function handleClick() {
if (disabled.value) return;
ctx.prevPage(prevPageProp);
}
</script>
<template>
<Primitive
:as="as"
:type="as === 'button' ? 'button' : undefined"
aria-label="Previous"
:aria-disabled="disabled || undefined"
:data-primitives-calendar-prev="''"
:data-disabled="disabled ? '' : undefined"
:disabled="as === 'button' ? disabled : undefined"
@click="handleClick"
>
<slot :disabled="disabled">
Previous
</slot>
</Primitive>
</template>
@@ -0,0 +1,324 @@
<script lang="ts">
import type { PrimitiveProps } from '../primitive';
import type { CalendarMonth, WeekDayFormat } from './utils';
export interface CalendarRootProps extends PrimitiveProps {
/** Uncontrolled default selected date. */
defaultValue?: Date;
/** Uncontrolled default placeholder (displayed month). */
defaultPlaceholder?: Date;
/** Minimum selectable date. */
minValue?: Date;
/** Maximum selectable date. */
maxValue?: Date;
/** Predicate marking a date as unavailable (not selectable). */
isDateUnavailable?: (date: Date) => boolean;
/** Predicate marking a date as disabled. */
isDateDisabled?: (date: Date) => boolean;
/** Prev/Next navigate by `numberOfMonths` instead of one month. @default false */
pagedNavigation?: boolean;
/** First day of week (0=Sun ... 6=Sat). @default 0 */
weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6;
/** Width of localized weekday names. @default 'short' */
weekdayFormat?: WeekDayFormat;
/** Always render 6 weeks per month. @default true */
fixedWeeks?: boolean;
/** Number of months displayed simultaneously. @default 1 */
numberOfMonths?: number;
/** Disable the whole calendar. @default false */
disabled?: boolean;
/** Make the calendar read-only. @default false */
readonly?: boolean;
/** Auto-focus the calendar on mount. @default false */
initialFocus?: boolean;
/** Locale for `Intl` formatting. @default 'en' */
locale?: string;
/** Reading direction. */
dir?: 'ltr' | 'rtl';
/** Override "next page" navigation logic. */
nextPage?: (placeholder: Date) => Date;
/** Override "prev page" navigation logic. */
prevPage?: (placeholder: Date) => Date;
/** Calendar accessible label prefix. @default 'Calendar' */
calendarLabel?: string;
}
export interface CalendarRootEmits {
'update:modelValue': [date: Date | undefined];
'update:placeholder': [date: Date];
}
</script>
<script setup lang="ts">
import { useEventListener, useForwardExpose } from '@robonen/vue';
import { computed, onMounted, ref, toRef, watch } from 'vue';
import { Primitive } from '../primitive';
import { provideCalendarRootContext } from './context';
import {
addMonths,
addYears,
clamp,
createMonths,
formatMonthYear,
getWeekdayLabels,
isAfter,
isBefore,
isSameDay,
isSameMonth,
isDateUnavailable as isUnavailable,
toDateOnly,
} from './utils';
defineOptions({ inheritAttrs: false });
const {
as = 'div',
defaultValue,
defaultPlaceholder,
minValue,
maxValue,
isDateUnavailable: propsIsDateUnavailable,
isDateDisabled: propsIsDateDisabled,
pagedNavigation = false,
weekStartsOn = 0,
weekdayFormat = 'short',
fixedWeeks = true,
numberOfMonths = 1,
disabled = false,
readonly = false,
initialFocus = false,
locale = 'en',
dir = 'ltr',
nextPage: propsNextPage,
prevPage: propsPrevPage,
calendarLabel = 'Calendar',
} = defineProps<CalendarRootProps>();
defineEmits<CalendarRootEmits>();
defineSlots<{
default?: (props: {
date: Date;
grid: CalendarMonth[];
weekDays: string[];
weekStartsOn: number;
locale: string;
modelValue: Date | undefined;
}) => unknown;
}>();
const localValue = ref<Date | undefined>(defaultValue);
const modelValue = defineModel<Date | undefined>('modelValue', {
default: undefined,
get: v => v ?? localValue.value,
set: (v) => {
localValue.value = v;
return v;
},
});
const localPlaceholder = ref<Date>(
toDateOnly(defaultPlaceholder ?? modelValue.value ?? new Date()),
);
const placeholder = defineModel<Date>('placeholder', {
default: undefined,
get: v => v ?? localPlaceholder.value,
set: (v) => {
localPlaceholder.value = toDateOnly(v);
return localPlaceholder.value;
},
});
const { forwardRef, currentElement: parentElement } = useForwardExpose();
const focusedDate = ref<Date | undefined>();
const localeRef = toRef(() => locale);
const dirRef = toRef(() => dir);
const weekStartsOnRef = toRef(() => weekStartsOn);
const weekdayFormatRef = toRef(() => weekdayFormat);
const fixedWeeksRef = toRef(() => fixedWeeks);
const numberOfMonthsRef = toRef(() => numberOfMonths);
const disabledRef = toRef(() => disabled);
const readonlyRef = toRef(() => readonly);
const pagedNavigationRef = toRef(() => pagedNavigation);
const minValueRef = toRef(() => minValue);
const maxValueRef = toRef(() => maxValue);
const grid = computed<CalendarMonth[]>(() => createMonths({
date: placeholder.value,
numberOfMonths,
weekStartsOn,
}));
const weekDays = computed(() => getWeekdayLabels(weekStartsOn, locale, weekdayFormat));
const headingValue = computed(() => {
const months = grid.value;
if (!months.length) return '';
if (months.length === 1) return formatMonthYear(months[0]!.value, locale);
const first = formatMonthYear(months[0]!.value, locale);
const last = formatMonthYear(months[months.length - 1]!.value, locale);
return `${first} - ${last}`;
});
const fullCalendarLabel = computed(() => `${calendarLabel}, ${headingValue.value}`);
function isDateDisabled(date: Date): boolean {
if (disabled) return true;
if (propsIsDateDisabled?.(date)) return true;
if (minValue && isBefore(date, minValue)) return true;
if (maxValue && isAfter(date, maxValue)) return true;
return false;
}
function isDateUnavailableLocal(date: Date): boolean {
return isUnavailable(date, propsIsDateUnavailable, minValue, maxValue);
}
function isDateSelected(date: Date): boolean {
return modelValue.value ? isSameDay(modelValue.value, date) : false;
}
function isOutsideVisibleView(date: Date): boolean {
return !grid.value.some(m => isSameMonth(m.value, date));
}
const isInvalid = computed(() => {
if (!modelValue.value) return false;
return isDateDisabled(modelValue.value) || isDateUnavailableLocal(modelValue.value);
});
function setDate(date: Date | undefined) {
if (readonly) return;
if (date && (isDateDisabled(date) || isDateUnavailableLocal(date))) return;
modelValue.value = date ? toDateOnly(date) : undefined;
}
function setPlaceholder(date: Date) {
placeholder.value = clamp(date, minValue, maxValue);
}
function pageStep(): number {
return pagedNavigation ? numberOfMonths : 1;
}
function nextPage(fn?: (placeholder: Date) => Date) {
const fnToUse = fn ?? propsNextPage;
placeholder.value = fnToUse
? toDateOnly(fnToUse(placeholder.value))
: addMonths(placeholder.value, pageStep());
}
function prevPage(fn?: (placeholder: Date) => Date) {
const fnToUse = fn ?? propsPrevPage;
placeholder.value = fnToUse
? toDateOnly(fnToUse(placeholder.value))
: addMonths(placeholder.value, -pageStep());
}
function nextYear() {
placeholder.value = addYears(placeholder.value, 1);
}
function prevYear() {
placeholder.value = addYears(placeholder.value, -1);
}
function isNextButtonDisabled(fn?: (placeholder: Date) => Date): boolean {
if (disabled) return true;
if (!maxValue) return false;
const lastMonth = grid.value[grid.value.length - 1]?.value;
if (!lastMonth) return false;
const fnToUse = fn ?? propsNextPage;
const probe = fnToUse
? toDateOnly(fnToUse(placeholder.value))
: addMonths(lastMonth, 1);
return isAfter(probe, maxValue);
}
function isPrevButtonDisabled(fn?: (placeholder: Date) => Date): boolean {
if (disabled) return true;
if (!minValue) return false;
const firstMonth = grid.value[0]?.value;
if (!firstMonth) return false;
const fnToUse = fn ?? propsPrevPage;
const probe = fnToUse
? toDateOnly(fnToUse(placeholder.value))
: addMonths(firstMonth, -1);
return isBefore(probe, minValue);
}
watch(modelValue, (v) => {
if (v && !isSameMonth(v, placeholder.value))
placeholder.value = toDateOnly(v);
});
onMounted(() => {
if (!initialFocus || !parentElement.value) return;
const target = parentElement.value.querySelector<HTMLElement>(
'[data-primitives-calendar-cell-trigger][data-selected]'
+ ',[data-primitives-calendar-cell-trigger][data-today]'
+ ',[data-primitives-calendar-cell-trigger]:not([data-outside-view]):not([data-disabled])',
);
target?.focus();
});
useEventListener(parentElement, 'focusout', (e) => {
if (!parentElement.value?.contains(e.relatedTarget as Node | null))
focusedDate.value = undefined;
});
provideCalendarRootContext({
modelValue,
placeholder,
locale: localeRef,
dir: dirRef,
grid,
weekDays,
headingValue,
fullCalendarLabel,
weekStartsOn: weekStartsOnRef,
weekdayFormat: weekdayFormatRef,
fixedWeeks: fixedWeeksRef,
numberOfMonths: numberOfMonthsRef,
disabled: disabledRef,
readonly: readonlyRef,
pagedNavigation: pagedNavigationRef,
minValue: minValueRef,
maxValue: maxValueRef,
isDateDisabled,
isDateUnavailable: isDateUnavailableLocal,
isDateSelected,
isOutsideVisibleView,
isInvalid,
parentElement,
focusedDate,
setDate,
setPlaceholder,
nextPage,
prevPage,
nextYear,
prevYear,
isNextButtonDisabled,
isPrevButtonDisabled,
});
</script>
<template>
<Primitive
:ref="forwardRef"
:as="as"
role="application"
:aria-label="fullCalendarLabel"
:dir="dir"
:data-primitives-calendar-root="''"
:data-disabled="disabled ? '' : undefined"
:data-readonly="readonly ? '' : undefined"
:data-invalid="isInvalid ? '' : undefined"
>
<slot
:date="placeholder"
:grid="grid"
:week-days="weekDays"
:week-starts-on="weekStartsOn"
:locale="locale"
:model-value="modelValue"
/>
</Primitive>
</template>
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';
import {
addMonths,
getWeeks,
isDateUnavailable,
isSameDay,
startOfWeek,
} from '../date-utils';
describe('date-utils', () => {
it('getWeeks returns 6 rows × 7 cols', () => {
const weeks = getWeeks(new Date(2024, 0, 15), 0);
expect(weeks).toHaveLength(6);
for (const row of weeks)
expect(row).toHaveLength(7);
});
it('startOfWeek respects weekStartsOn', () => {
// 2024-01-10 is a Wednesday.
const wed = new Date(2024, 0, 10);
expect(startOfWeek(wed, 0).getDay()).toBe(0);
expect(startOfWeek(wed, 1).getDay()).toBe(1);
});
it('addMonths clamps Jan 31 → Feb 28/29', () => {
const r = addMonths(new Date(2023, 0, 31), 1);
expect(r.getMonth()).toBe(1);
expect(r.getDate()).toBe(28);
});
it('isSameDay ignores time component', () => {
const a = new Date(2024, 5, 1, 1, 2, 3);
const b = new Date(2024, 5, 1, 23, 59);
expect(isSameDay(a, b)).toBe(true);
expect(isSameDay(a, new Date(2024, 5, 2))).toBe(false);
});
it('isDateUnavailable honors min/max and predicate', () => {
const min = new Date(2024, 0, 5);
const max = new Date(2024, 0, 25);
expect(isDateUnavailable(new Date(2024, 0, 1), undefined, min, max)).toBe(true);
expect(isDateUnavailable(new Date(2024, 0, 31), undefined, min, max)).toBe(true);
expect(isDateUnavailable(new Date(2024, 0, 10), undefined, min, max)).toBe(false);
expect(isDateUnavailable(new Date(2024, 0, 10), d => d.getDate() === 10)).toBe(true);
});
});
+67
View File
@@ -0,0 +1,67 @@
import type { ComputedRef, Ref } from 'vue';
import type { CalendarMonth, WeekDayFormat } from './utils';
import { useContextFactory } from '@robonen/vue';
export interface CalendarRootContext {
/** Currently selected date (or undefined). */
modelValue: Ref<Date | undefined>;
/** Displayed month anchor. */
placeholder: Ref<Date>;
/** Locale identifier for `Intl` formatting. */
locale: Ref<string>;
/** Reading direction. */
dir: Ref<'ltr' | 'rtl'>;
/** Computed grid of months (each with 6×7 weeks). */
grid: ComputedRef<CalendarMonth[]>;
/** Localized weekday labels (length 7). */
weekDays: ComputedRef<string[]>;
/** Heading text (month + year). */
headingValue: ComputedRef<string>;
/** Full aria-label for the calendar region. */
fullCalendarLabel: ComputedRef<string>;
weekStartsOn: Ref<0 | 1 | 2 | 3 | 4 | 5 | 6>;
weekdayFormat: Ref<WeekDayFormat>;
fixedWeeks: Ref<boolean>;
numberOfMonths: Ref<number>;
disabled: Ref<boolean>;
readonly: Ref<boolean>;
pagedNavigation: Ref<boolean>;
minValue: Ref<Date | undefined>;
maxValue: Ref<Date | undefined>;
isDateDisabled: (date: Date) => boolean;
isDateUnavailable: (date: Date) => boolean;
isDateSelected: (date: Date) => boolean;
isOutsideVisibleView: (date: Date) => boolean;
isInvalid: ComputedRef<boolean>;
/** Element hosting the calendar grid(s); used for keyboard focus shifting. */
parentElement: Ref<HTMLElement | undefined>;
/** Currently focused day, drives `tabindex`. */
focusedDate: Ref<Date | undefined>;
setDate: (date: Date | undefined) => void;
setPlaceholder: (date: Date) => void;
nextPage: (fn?: (placeholder: Date) => Date) => void;
prevPage: (fn?: (placeholder: Date) => Date) => void;
nextYear: () => void;
prevYear: () => void;
isNextButtonDisabled: (fn?: (placeholder: Date) => Date) => boolean;
isPrevButtonDisabled: (fn?: (placeholder: Date) => Date) => boolean;
}
const ctx = useContextFactory<CalendarRootContext>('CalendarRoot');
export const provideCalendarRootContext = ctx.provide;
export const useCalendarRootContext = ctx.inject;
export interface CalendarGridContext {
/** The month this `<table>` is rendering. */
month: Ref<Date>;
}
const gridCtx = useContextFactory<CalendarGridContext>('CalendarGrid');
export const provideCalendarGridContext = gridCtx.provide;
export const useCalendarGridContext = gridCtx.inject;
+125
View File
@@ -0,0 +1,125 @@
export type WeekDayFormat = 'narrow' | 'short' | 'long';
export interface DateRange {
start?: Date;
end?: Date;
}
export function toDateOnly(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0);
}
export function isSameDay(a: Date, b: Date): boolean {
return a.getFullYear() === b.getFullYear()
&& a.getMonth() === b.getMonth()
&& a.getDate() === b.getDate();
}
export function isSameMonth(a: Date, b: Date): boolean {
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth();
}
export function isBefore(a: Date, b: Date): boolean {
return toDateOnly(a).getTime() < toDateOnly(b).getTime();
}
export function isAfter(a: Date, b: Date): boolean {
return toDateOnly(a).getTime() > toDateOnly(b).getTime();
}
export function addDays(d: Date, n: number): Date {
const r = toDateOnly(d);
r.setDate(r.getDate() + n);
return r;
}
export function addMonths(d: Date, n: number): Date {
const r = toDateOnly(d);
const day = r.getDate();
// Move to first of month, shift, then clamp day to month length.
r.setDate(1);
r.setMonth(r.getMonth() + n);
const lastDay = new Date(r.getFullYear(), r.getMonth() + 1, 0).getDate();
r.setDate(Math.min(day, lastDay));
return r;
}
export function addYears(d: Date, n: number): Date {
return addMonths(d, n * 12);
}
export function startOfMonth(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), 1, 0, 0, 0, 0);
}
export function endOfMonth(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth() + 1, 0, 0, 0, 0, 0);
}
export function getDaysInMonth(d: Date): number {
return endOfMonth(d).getDate();
}
export function startOfWeek(d: Date, weekStartsOn: 0 | 1 | 2 | 3 | 4 | 5 | 6 = 0): Date {
const r = toDateOnly(d);
const day = r.getDay();
const diff = (day - weekStartsOn + 7) % 7;
r.setDate(r.getDate() - diff);
return r;
}
/**
* Returns a 6×7 matrix of dates for the month containing `month`,
* padded with leading/trailing days from adjacent months.
*/
export function getWeeks(month: Date, weekStartsOn: 0 | 1 | 2 | 3 | 4 | 5 | 6 = 0): Date[][] {
const first = startOfMonth(month);
const gridStart = startOfWeek(first, weekStartsOn);
const weeks: Date[][] = [];
for (let w = 0; w < 6; w++) {
const row: Date[] = [];
for (let i = 0; i < 7; i++)
row.push(addDays(gridStart, w * 7 + i));
weeks.push(row);
}
return weeks;
}
export function clamp(date: Date, min?: Date, max?: Date): Date {
if (min && isBefore(date, min))
return toDateOnly(min);
if (max && isAfter(date, max))
return toDateOnly(max);
return toDateOnly(date);
}
export function isDateUnavailable(
d: Date,
predicate?: (d: Date) => boolean,
min?: Date,
max?: Date,
): boolean {
if (min && isBefore(d, min))
return true;
if (max && isAfter(d, max))
return true;
if (predicate?.(d))
return true;
return false;
}
export function formatDate(
d: Date,
opts: Intl.DateTimeFormatOptions,
locale: string,
): string {
return new Intl.DateTimeFormat(locale, opts).format(d);
}
export function formatWeekday(
d: Date,
locale: string,
width: WeekDayFormat = 'short',
): string {
return new Intl.DateTimeFormat(locale, { weekday: width }).format(d);
}
+42
View File
@@ -0,0 +1,42 @@
export { default as CalendarRoot } from './CalendarRoot.vue';
export { default as CalendarHeader } from './CalendarHeader.vue';
export { default as CalendarHeading } from './CalendarHeading.vue';
export { default as CalendarPrev } from './CalendarPrev.vue';
export { default as CalendarNext } from './CalendarNext.vue';
export { default as CalendarGrid } from './CalendarGrid.vue';
export { default as CalendarGridHead } from './CalendarGridHead.vue';
export { default as CalendarGridBody } from './CalendarGridBody.vue';
export { default as CalendarGridRow } from './CalendarGridRow.vue';
export { default as CalendarHeadCell } from './CalendarHeadCell.vue';
export { default as CalendarCell } from './CalendarCell.vue';
export { default as CalendarCellTrigger } from './CalendarCellTrigger.vue';
export {
provideCalendarRootContext,
useCalendarRootContext,
provideCalendarGridContext,
useCalendarGridContext,
} from './context';
export type {
CalendarRootContext,
CalendarGridContext,
} from './context';
export * from './utils';
export type { CalendarRootEmits, CalendarRootProps } from './CalendarRoot.vue';
export type { CalendarHeaderProps } from './CalendarHeader.vue';
export type { CalendarHeadingProps } from './CalendarHeading.vue';
export type { CalendarPrevProps } from './CalendarPrev.vue';
export type { CalendarNextProps } from './CalendarNext.vue';
export type { CalendarGridProps } from './CalendarGrid.vue';
export type { CalendarGridHeadProps } from './CalendarGridHead.vue';
export type { CalendarGridBodyProps } from './CalendarGridBody.vue';
export type { CalendarGridRowProps } from './CalendarGridRow.vue';
export type { CalendarHeadCellProps } from './CalendarHeadCell.vue';
export type { CalendarCellProps } from './CalendarCell.vue';
export type {
CalendarCellTriggerProps,
CalendarCellTriggerSlotProps,
} from './CalendarCellTrigger.vue';
+64
View File
@@ -0,0 +1,64 @@
import type { WeekDayFormat } from './date-utils';
import {
addMonths,
formatDate,
formatWeekday,
getWeeks,
startOfMonth,
startOfWeek,
} from './date-utils';
export * from './date-utils';
export interface CalendarMonth {
/** First day of this month (date-only). */
value: Date;
/** 6×7 grid of dates including leading/trailing adjacent-month days. */
weeks: Date[][];
}
export interface CreateMonthsOptions {
date: Date;
numberOfMonths: number;
weekStartsOn: 0 | 1 | 2 | 3 | 4 | 5 | 6;
}
/** Build N consecutive months starting from `date`'s month. */
export function createMonths(opts: CreateMonthsOptions): CalendarMonth[] {
const months: CalendarMonth[] = [];
for (let i = 0; i < opts.numberOfMonths; i++) {
const m = startOfMonth(addMonths(opts.date, i));
months.push({ value: m, weeks: getWeeks(m, opts.weekStartsOn) });
}
return months;
}
/** Localized short/narrow/long weekday names starting from `weekStartsOn`. */
export function getWeekdayLabels(
weekStartsOn: 0 | 1 | 2 | 3 | 4 | 5 | 6,
locale: string,
width: WeekDayFormat,
): string[] {
// Pick any known Sunday (1970-01-04 is a Sunday) as anchor.
const anchorSunday = new Date(1970, 0, 4);
const start = startOfWeek(anchorSunday, weekStartsOn);
const labels: string[] = [];
for (let i = 0; i < 7; i++) {
const d = new Date(start);
d.setDate(start.getDate() + i);
labels.push(formatWeekday(d, locale, width));
}
return labels;
}
export function formatMonthYear(d: Date, locale: string): string {
return formatDate(d, { month: 'long', year: 'numeric' }, locale);
}
export function formatFullDate(d: Date, locale: string): string {
return formatDate(
d,
{ weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' },
locale,
);
}