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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user