Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: DateRangePicker not updating value under certain conditions #1195

Merged
merged 2 commits into from
Feb 17, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/eleven-lamps-relate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"bits-ui": patch
---

fix: `DateRangePicker` not allowing range selection under certain value conditions
5 changes: 1 addition & 4 deletions docs/src/lib/components/demos/date-range-picker-demo.svelte
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
<script lang="ts">
import { type DateRange, DateRangePicker } from "bits-ui";
import { DateRangePicker } from "bits-ui";
import CalendarBlank from "phosphor-svelte/lib/CalendarBlank";
import CaretLeft from "phosphor-svelte/lib/CaretLeft";
import CaretRight from "phosphor-svelte/lib/CaretRight";
import { cn } from "$lib/utils/index.js";

let value: DateRange = $state({ start: undefined, end: undefined });
</script>

<DateRangePicker.Root
bind:value
weekdayFormat="short"
fixedWeeks={true}
class="flex w-full max-w-[340px] flex-col gap-1.5"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type { DateValue } from "@internationalized/date";
import { untrack } from "svelte";
import { box, onDestroyEffect, useRefById } from "svelte-toolbelt";
import { Context } from "runed";
import { Context, watch } from "runed";
import type { DateFieldRootState } from "../date-field/date-field.svelte.js";
import { DateFieldInputState, useDateFieldRoot } from "../date-field/date-field.svelte.js";
import type { ReadableBoxedValues, WritableBoxedValues } from "$lib/internal/box.svelte.js";
Expand Down Expand Up @@ -58,22 +57,6 @@ export class DateRangeFieldRootState {
startValueComplete = $derived.by(() => this.opts.startValue.current !== undefined);
endValueComplete = $derived.by(() => this.opts.endValue.current !== undefined);
rangeComplete = $derived(this.startValueComplete && this.endValueComplete);
mergedValues = $derived.by(() => {
if (
this.opts.startValue.current === undefined ||
this.opts.endValue.current === undefined
) {
return {
start: undefined,
end: undefined,
};
} else {
return {
start: this.opts.startValue.current,
end: this.opts.endValue.current,
};
}
});

constructor(readonly opts: DateRangeFieldRootStateProps) {
this.formatter = createFormatter(this.opts.locale.current);
Expand All @@ -94,53 +77,78 @@ export class DateRangeFieldRootState {
this.formatter.setLocale(this.opts.locale.current);
});

$effect(() => {
const startValue = this.opts.value.current.start;
untrack(() => {
if (startValue) this.opts.placeholder.current = startValue;
});
});

$effect(() => {
const endValue = this.opts.value.current.end;
untrack(() => {
if (endValue) this.opts.placeholder.current = endValue;
});
});
/**
* Synchronize the start and end values with the `value` in case
* it is updated externally.
*/
watch(
() => this.opts.value.current,
(value) => {
if (value.start && value.end) {
this.opts.startValue.current = value.start;
this.opts.endValue.current = value.end;
} else if (value.start) {
this.opts.startValue.current = value.start;
this.opts.endValue.current = undefined;
} else if (value.start === undefined && value.end === undefined) {
this.opts.startValue.current = undefined;
this.opts.endValue.current = undefined;
}
}
);

/**
* Sync values set programatically with the `startValue` and `endValue`
* Synchronize the placeholder value with the current start value
*/
$effect(() => {
const value = this.opts.value.current;
untrack(() => {
if (value.start !== undefined && value.start !== this.opts.startValue.current) {
this.#setStartValue(value.start);
watch(
() => this.opts.value.current,
(value) => {
const startValue = value.start;
if (startValue && this.opts.placeholder.current !== startValue) {
this.opts.placeholder.current = startValue;
}
if (value.end !== undefined && value.end !== this.opts.endValue.current) {
this.#setEndValue(value.end);
}
);

watch(
[() => this.opts.startValue.current, () => this.opts.endValue.current],
([startValue, endValue]) => {
if (
this.opts.value.current &&
this.opts.value.current.start === startValue &&
this.opts.value.current.end === endValue
) {
return;
}
});
});

// TODO: Handle description element

$effect(() => {
const placeholder = untrack(() => this.opts.placeholder.current);
const startValue = untrack(() => this.opts.startValue.current);

if (this.startValueComplete && placeholder !== startValue) {
untrack(() => {
if (startValue) {
this.opts.placeholder.current = startValue;
}
});
if (startValue && endValue) {
this.#updateValue((prev) => {
if (prev.start === startValue && prev.end === endValue) {
return prev;
}
if (isBefore(endValue, startValue)) {
const start = startValue;
const end = endValue;
this.#setStartValue(end);
this.#setEndValue(start);
return { start: endValue, end: startValue };
} else {
return {
start: startValue,
end: endValue,
};
}
});
} else if (
this.opts.value.current &&
this.opts.value.current.start &&
this.opts.value.current.end
) {
this.opts.value.current.start = undefined;
this.opts.value.current.end = undefined;
}
}
});

$effect(() => {
this.opts.value.current = this.mergedValues;
});
);
}

validationStatus = $derived.by(() => {
Expand Down Expand Up @@ -186,6 +194,12 @@ export class DateRangeFieldRootState {
return true;
});

#updateValue(cb: (value: DateRange) => DateRange) {
const value = this.opts.value.current;
const newValue = cb(value);
this.opts.value.current = newValue;
}

#setStartValue(value: DateValue | undefined) {
this.opts.startValue.current = value;
}
Expand Down
111 changes: 111 additions & 0 deletions tests/src/tests/date-range-picker/date-range-picker-test.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
<script lang="ts" module>
import {
DateRangePicker,
type DateRangePickerInputProps,
type WithoutChildrenOrChild,
} from "bits-ui";
export type DateRangePickerTestProps = WithoutChildrenOrChild<DateRangePicker.RootProps> & {
startProps?: Omit<DateRangePickerInputProps, "type">;
endProps?: Omit<DateRangePickerInputProps, "type">;
};
</script>

<script lang="ts">
let {
placeholder,
value,
open = false,
startProps,
endProps,
...restProps
}: DateRangePickerTestProps = $props();

function clear() {
value = {
start: undefined,
end: undefined,
};
}
</script>

<main>
<div data-testid="value">{value}</div>
<div data-testid="open">{open}</div>
<div data-testid="start-value">{String(value?.start)}</div>
<div data-testid="end-value">{String(value?.end)}</div>
<button onclick={clear}>clear</button>
<button onclick={() => (open = !open)}>toggle open</button>
<DateRangePicker.Root bind:value bind:placeholder bind:open {...restProps}>
<DateRangePicker.Label data-testid="label">Rental Days</DateRangePicker.Label>
{#each ["start", "end"] as const as type}
{@const inputProps = type === "start" ? startProps : endProps}
<DateRangePicker.Input {type} data-testid="{type}-input" {...inputProps}>
{#snippet children({ segments })}
{#each segments as { part, value }}
<DateRangePicker.Segment
{part}
data-testid={part === "literal" ? undefined : `${type}-${part}`}
>
{value}
</DateRangePicker.Segment>
{/each}
{/snippet}
</DateRangePicker.Input>
{/each}

<DateRangePicker.Trigger data-testid="trigger">Open</DateRangePicker.Trigger>
<DateRangePicker.Content data-testid="content">
<DateRangePicker.Calendar data-testid="calendar">
{#snippet children({ months, weekdays })}
<DateRangePicker.Header data-testid="header">
<DateRangePicker.PrevButton data-testid="prev-button"
>Prev</DateRangePicker.PrevButton
>
<DateRangePicker.Heading data-testid="heading" />
<DateRangePicker.NextButton data-testid="next-button"
>Next</DateRangePicker.NextButton
>
</DateRangePicker.Header>
<div>
{#each months as month}
{@const m = month.value.month}
<DateRangePicker.Grid data-testid="grid-{m}">
<DateRangePicker.GridHead data-testid="grid-head-{m}">
<DateRangePicker.GridRow data-testid="grid-row-{m}">
{#each weekdays as day, i}
<DateRangePicker.HeadCell data-testid="weekday-{m}-{i}">
{day}
</DateRangePicker.HeadCell>
{/each}
</DateRangePicker.GridRow>
</DateRangePicker.GridHead>
<DateRangePicker.GridBody data-testid="grid-body-{m}">
{#each month.weeks as weekDates, i}
<DateRangePicker.GridRow
data-testid="grid-row-{m}-{i}"
data-week
>
{#each weekDates as date, d}
<DateRangePicker.Cell
{date}
month={month.value}
data-testid="cell-{date.month}-{d}"
>
<DateRangePicker.Day
data-testid="date-{date.month}-{date.day}"
>
{date.day}
</DateRangePicker.Day>
</DateRangePicker.Cell>
{/each}
</DateRangePicker.GridRow>
{/each}
</DateRangePicker.GridBody>
</DateRangePicker.Grid>
{/each}
</div>
{/snippet}
</DateRangePicker.Calendar>
</DateRangePicker.Content>
</DateRangePicker.Root>
</main>
Loading