Repository: melt-ui/melt-ui
Pull request #846 (2023-12-21 19:37:59)

DateField/DateRangeField: add `readonlySegments` prop
Given segments can be set as readonly to control them only through the value store

changeset-bot[bot]: ###  🦋  Changeset detected

Latest commit: 0e8f17a35ec82714851fe7a8542f17d7c8882fd4

**The changes in this PR will be included in the next version bump.**

<details><summary>This PR includes changesets to release 1 package</summary>

  | Name            | Type  |
| --------------- | ----- |
| @melt-ui/svelte | Patch |

</details>

Not sure what this means? [Click here  to learn what changesets are](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add another changeset to this PR](https://github.com/ksmoore17/melt-ui/new/date-field-readonly-segments?filename=.changeset/tiny-buses-roll.md&value=---%0A%22%40melt-ui%2Fsvelte%22%3A%20patch%0A---%0A%0ADateField%2FDateRangeField%3A%20add%20%60readonlySegments%60%20prop%0A)


ksmoore17: I just force pushed to change my mentions of `SegmentPart` in the docs into `EditableSegmentPart`
ksmoore17: Any idea how to solve that eslint error in the css previews? I know I could use classes instead of the `[data-melt-datefield-*]` selectors, but it's the same error then with the `[data-invalid]` selectors. I see that the pagination css example  [uses `:global`](https://github.com/melt-ui/melt-ui/blob/013a3b7dddaf9a66d69c1e0086e8e1d22ee485fd/src/docs/previews/pagination/main/css/index.svelte#L68) to get around the same issue. Should I just remove the css examples?

Interestingly, the file has no lint errors for any selector (even a truly unused class) if I don't use the preprocessor for just one of the elements:
```
<div>
	<span {...$label} use:label class="label">Appointment Date</span>
	<div use:melt={$field} class="field">
		{#each $segmentContents as seg, i (i)}
			<div use:melt={$segment(seg.part)}>
				{seg.value}
			</div>
		{/each}
	</div>
</div>

<style>
	[data-melt-datefield-label] {
		...
	}
	[data-melt-datefield-label][data-invalid] {
		...
	}
	[data-melt-datefield-field] {
		...
	}
	[data-melt-datefield-field][data-invalid] {
		...
	}
	...
</style>
```
huntabyte: Try adding `lang="postcss"` to the `<style>` tag.
huntabyte: We don't want to expose the `readonlySegments` props to the `DatePicker` & `DateRangePicker` components. It doesn't make much sense there considering they could be overwritten by the calendar, so I've omitted them from those exposed props for those builders.

Patch:
diff --git a/.changeset/green-berries-fly.md b/.changeset/green-berries-fly.md
new file mode 100644
index 00000000..a2e35780
--- /dev/null
+++ b/.changeset/green-berries-fly.md
@@ -0,0 +1,5 @@
+---
+'@melt-ui/svelte': patch
+---
+
+DateField & DateRangeField: add `readonlySegments` prop
diff --git a/src/docs/content/builders/date-field.md b/src/docs/content/builders/date-field.md
index 8349ca91..c3ba250d 100644
--- a/src/docs/content/builders/date-field.md
+++ b/src/docs/content/builders/date-field.md
@@ -543,6 +543,36 @@ Here's an example showcasing a few different locales:
 Notice that they all have the same `defaultPlaceholder`, yet the segments are formatted differently
 depending on the locale, and all it took was changing the `locale` prop. Pretty cool, right?
 
+### Readonly Segments
+
+The entire Date Field builder can be set as readonly using the `readonly` prop. But in some
+situations, you may want a user to be able to edit the time but not the date, or the month but not
+the year.
+
+The `readonlySegments` prop can be used for this.
+
+```svelte showLineNumbers {9-10}
+<script lang="ts">
+	import { createDateField, melt } from '$lib'
+	import { CalendarDate } from '@internationalized/date'
+
+	const {
+		elements: { field, segment, label },
+		states: { segmentContents }
+	} = createDateField({
+		defaultValue: new CalendarDate(2023, 10, 11),
+		readonlySegments: ['year']
+	})
+</script>
+```
+
+In this example, the `year` segment is readonly, but the `month` and `day` segments are focusable
+and editable.
+
+<Preview code={snippets.readonlySegments} variant="dark" size="sm">
+	<svelte:component this={previews.readonlySegments} />
+</Preview>
+
 ## API Reference
 
 <APIReference {schemas} />
diff --git a/src/docs/content/builders/date-range-field.md b/src/docs/content/builders/date-range-field.md
index 6a73ca94..e34492ab 100644
--- a/src/docs/content/builders/date-range-field.md
+++ b/src/docs/content/builders/date-range-field.md
@@ -563,6 +563,44 @@ Here's an example showcasing a few different locales:
 Notice that they all have the same `defaultPlaceholder`, yet the segments are formatted differently
 depending on the locale, and all it took was changing the `locale` prop. Pretty cool, right?
 
+### Readonly Segments
+
+Like the Date Field builder, Date Range Fields can be set as readonly using the `readonly` prop. But
+in some situations, you may want a user to be able to edit the time but not the date, or the month
+but not the year. You can configure which segments are readonly for the start and end fields
+separately using the `readonlySegments` prop.
+
+```svelte showLineNumbers {13-16}
+<script lang="ts">
+	import { createDateRangeField, melt } from '$lib'
+	import { CalendarDate } from '@internationalized/date'
+
+	const {
+		elements: { field, startSegment, endSegment, label },
+		states: { segmentContents }
+	} = createDateRangeField({
+		defaultValue: {
+			start: new CalendarDate(2023, 10, 11),
+			end: new CalendarDate(2023, 10, 12)
+		},
+		readonlySegments: {
+			start: ['month', 'year'],
+			end: ['year']
+		}
+	})
+</script>
+```
+
+In this example, the `year` segment is readonly on both start and end fields, `month` is also
+readonly on start, but the `day` segment is focusable and editable on both.
+
+<Preview code={snippets.readonlySegments} variant="dark" size="sm">
+	<svelte:component this={previews.readonlySegments} />
+</Preview>
+
+This could be used to ensure the end year can not be set different from the start year, using a
+controlled value prop, for example.
+
 ## API Reference
 
 <APIReference {schemas} />
diff --git a/src/docs/data/builders/date-field.ts b/src/docs/data/builders/date-field.ts
index 88674d36..fe56888a 100644
--- a/src/docs/data/builders/date-field.ts
+++ b/src/docs/data/builders/date-field.ts
@@ -71,6 +71,11 @@ const dateFieldProps = [
 		default: 'false',
 		description: 'Whether the date field is readonly.',
 	},
+	{
+		name: 'readonlySegments',
+		type: 'EditableSegmentPart[]',
+		description: 'The set of segments that are readonly.',
+	},
 	{
 		name: 'hourCycle',
 		type: 'HourCycle',
diff --git a/src/docs/data/builders/date-range-field.ts b/src/docs/data/builders/date-range-field.ts
index 0f20b6a6..9d770555 100644
--- a/src/docs/data/builders/date-range-field.ts
+++ b/src/docs/data/builders/date-range-field.ts
@@ -70,6 +70,11 @@ const dateRangeFieldProps = [
 		default: 'false',
 		description: 'Whether the date field is readonly.',
 	},
+	{
+		name: 'readonlySegments',
+		type: '{ start: EditableSegmentPart[], end: EditableSegmentPart[] }',
+		description: 'The sets of segments that are readonly on the start and end fields.',
+	},
 	{
 		name: 'hourCycle',
 		type: 'HourCycle',
diff --git a/src/docs/previews/date-field/readonlySegments/css/index.svelte b/src/docs/previews/date-field/readonlySegments/css/index.svelte
new file mode 100644
index 00000000..5093640f
--- /dev/null
+++ b/src/docs/previews/date-field/readonlySegments/css/index.svelte
@@ -0,0 +1,69 @@
+<script lang="ts">
+	import { createDateField, melt } from '$lib';
+	import { CalendarDate } from '@internationalized/date';
+
+	const {
+		elements: { field, segment, label },
+		states: { segmentContents },
+	} = createDateField({
+		defaultValue: new CalendarDate(2023, 10, 11),
+		readonlySegments: ['year'],
+	});
+</script>
+
+<div>
+	<div>
+		<span use:melt={$label}>Appointment Date</span>
+		<div use:melt={$field}>
+			{#each $segmentContents as seg, i (i)}
+				<div use:melt={$segment(seg.part)}>
+					{seg.value}
+				</div>
+			{/each}
+		</div>
+	</div>
+</div>
+
+<style lang="postcss">
+	[data-melt-datefield-label] {
+		user-select: none;
+		font-weight: 500;
+		color: rgb(var(--color-white) / 1);
+	}
+
+	[data-melt-datefield-label][data-invalid] {
+		color: rgb(var(--color-magnum-500) / 1);
+	}
+
+	[data-melt-datefield-field] {
+		width: 100%;
+		min-width: 200px;
+		margin-top: 0.375rem;
+		padding: 0.375rem;
+
+		display: flex;
+		align-items: center;
+
+		background-color: rgb(var(--color-neutral-800) / 0.8);
+		color: rgb(var(--color-magnum-400) / 1);
+		border: 1px solid rgb(var(--color-magnum-400) / 0.6);
+		border-radius: 0.5rem;
+	}
+
+	[data-melt-datefield-field][data-invalid] {
+		border: 1px solid rgb(var(--color-magnum-400) / 0.6);
+	}
+
+	[data-melt-datefield-segment][data-invalid] {
+		color: rgb(var(--color-magnum-500) / 1);
+	}
+
+	[data-melt-datefield-segment]:not([data-segment='literal']) {
+		padding: 0 0.125rem;
+	}
+
+	[data-melt-datefield-validation] {
+		align-self: self-start;
+		color: rgb(var(--color-magnum-500) / 1);
+	}
+</style>
diff --git a/src/docs/previews/date-field/readonlySegments/tailwind/index.svelte b/src/docs/previews/date-field/readonlySegments/tailwind/index.svelte
new file mode 100644
index 00000000..294b66d8
--- /dev/null
+++ b/src/docs/previews/date-field/readonlySegments/tailwind/index.svelte
@@ -0,0 +1,55 @@
+<script lang="ts">
+	import { createDateField, melt } from '$lib';
+	import { CalendarDate } from '@internationalized/date';
+
+	const {
+		elements: { field, segment, label },
+		states: { segmentContents },
+	} = createDateField({
+		defaultValue: new CalendarDate(2023, 10, 11),
+		readonlySegments: ['year'],
+	});
+</script>
+
+<div>
+	<div>
+		<span use:melt={$label}>Appointment Date</span>
+		<div use:melt={$field}>
+			{#each $segmentContents as seg, i (i)}
+				<div use:melt={$segment(seg.part)}>
+					{seg.value}
+				</div>
+			{/each}
+		</div>
+	</div>
+</div>
+
+<style lang="postcss">
+	[data-melt-datefield-label] {
+		@apply select-none font-medium text-white;
+	}
+
+	[data-melt-datefield-label][data-invalid] {
+		@apply text-red-500;
+	}
+
+	[data-melt-datefield-field] {
+		@apply mt-1.5 flex w-full min-w-[200px] items-center rounded-lg border border-magnum-400/60 bg-neutral-800/80 p-1.5 text-magnum-400;
+	}
+
+	[data-melt-datefield-field][data-invalid] {
+		@apply border-red-400;
+	}
+
+	[data-melt-datefield-segment][data-invalid] {
+		@apply text-red-500;
+	}
+
+	[data-melt-datefield-segment]:not([data-segment='literal']) {
+		@apply px-0.5;
+	}
+
+	[data-melt-datefield-validation] {
+		@apply self-start text-red-500;
+	}
+</style>
diff --git a/src/docs/previews/date-range-field/readonlySegments/css/index.svelte b/src/docs/previews/date-range-field/readonlySegments/css/index.svelte
new file mode 100644
index 00000000..9d76e4ac
--- /dev/null
+++ b/src/docs/previews/date-range-field/readonlySegments/css/index.svelte
@@ -0,0 +1,88 @@
+<script lang="ts">
+	import { createDateRangeField, melt } from '$lib';
+	import { CalendarDate } from '@internationalized/date';
+
+	const {
+		elements: { field, startSegment, endSegment, label },
+		states: { segmentContents },
+	} = createDateRangeField({
+		defaultValue: {
+			start: new CalendarDate(2023, 10, 11),
+			end: new CalendarDate(2023, 10, 12),
+		},
+		readonlySegments: {
+			start: ['month', 'year'],
+			end: ['year'],
+		},
+	});
+</script>
+
+<span use:melt={$label}>Trip Dates</span>
+<div use:melt={$field}>
+	<div>
+		{#each $segmentContents.start as seg, i (i)}
+			<div use:melt={$startSegment(seg.part)}>
+				{seg.value}
+			</div>
+		{/each}
+	</div>
+	<div aria-hidden="true">-</div>
+	<div>
+		{#each $segmentContents.end as seg, i (i)}
+			<div use:melt={$endSegment(seg.part)}>
+				{seg.value}
+			</div>
+		{/each}
+	</div>
+</div>
+
+<style lang="postcss">
+	[data-melt-datefield-label] {
+		user-select: none;
+		font-weight: 500;
+		color: rgb(var(--color-white) / 1);
+	}
+
+	[data-melt-datefield-label][data-invalid] {
+		color: rgb(var(--color-magnum-500) / 1);
+	}
+
+	[data-melt-datefield-field] {
+		width: 100%;
+		min-width: 300px;
+		margin-top: 0.375rem;
+		padding: 0.375rem;
+
+		display: flex;
+		align-items: center;
+		justify-content: center;
+		gap: 0.5rem;
+
+		background-color: rgb(var(--color-neutral-800) / 0.8);
+		color: rgb(var(--color-magnum-400) / 1);
+		border: 1px solid rgb(var(--color-magnum-400) / 0.6);
+		border-radius: 0.5rem;
+	}
+
+	[data-melt-datefield-field] > div {
+		display: flex;
+		align-items: center;
+	}
+
+	[data-melt-datefield-field][data-invalid] {
+		border: 1px solid rgb(var(--color-magnum-400) / 0.6);
+	}
+
+	[data-melt-datefield-segment][data-invalid] {
+		color: rgb(var(--color-magnum-500) / 1);
+	}
+
+	[data-melt-datefield-segment]:not([data-segment='literal']) {
+		padding: 0 0.125rem;
+	}
+
+	[data-melt-datefield-validation] {
+		align-self: self-start;
+		color: rgb(var(--color-magnum-500) / 1);
+	}
+</style>
diff --git a/src/docs/previews/date-range-field/readonlySegments/tailwind/index.svelte b/src/docs/previews/date-range-field/readonlySegments/tailwind/index.svelte
new file mode 100644
index 00000000..862fb01c
--- /dev/null
+++ b/src/docs/previews/date-range-field/readonlySegments/tailwind/index.svelte
@@ -0,0 +1,71 @@
+<script lang="ts">
+	import { createDateRangeField, melt } from '$lib';
+	import { CalendarDate } from '@internationalized/date';
+
+	const {
+		elements: { field, startSegment, endSegment, label },
+		states: { segmentContents },
+	} = createDateRangeField({
+		defaultValue: {
+			start: new CalendarDate(2023, 10, 11),
+			end: new CalendarDate(2023, 10, 12),
+		},
+		readonlySegments: {
+			start: ['month', 'year'],
+			end: ['year'],
+		},
+	});
+</script>
+
+<span use:melt={$label}>Trip Dates</span>
+<div use:melt={$field}>
+	<div>
+		{#each $segmentContents.start as seg, i (i)}
+			<div use:melt={$startSegment(seg.part)}>
+				{seg.value}
+			</div>
+		{/each}
+	</div>
+	<div aria-hidden="true">-</div>
+	<div>
+		{#each $segmentContents.end as seg, i (i)}
+			<div use:melt={$endSegment(seg.part)}>
+				{seg.value}
+			</div>
+		{/each}
+	</div>
+</div>
+
+<style lang="postcss">
+	[data-melt-datefield-label] {
+		@apply select-none font-medium text-white;
+	}
+
+	[data-melt-datefield-label][data-invalid] {
+		@apply text-red-500;
+	}
+
+	[data-melt-datefield-field] {
+		@apply mt-1.5 flex w-full min-w-[300px] items-center justify-center gap-2 rounded-lg border border-magnum-400/60 bg-neutral-800/80 p-1.5 text-magnum-400;
+
+		& > div {
+			@apply flex items-center;
+		}
+	}
+
+	[data-melt-datefield-field][data-invalid] {
+		@apply border-red-400;
+	}
+
+	[data-melt-datefield-segment][data-invalid] {
+		@apply text-red-500;
+	}
+
+	[data-melt-datefield-segment]:not([data-segment='literal']) {
+		@apply px-0.5;
+	}
+
+	[data-melt-datefield-validation] {
+		@apply self-start text-red-500;
+	}
+</style>
diff --git a/src/lib/builders/date-field/create.ts b/src/lib/builders/date-field/create.ts
index 2b9f03bb..5e5b0a22 100644
--- a/src/lib/builders/date-field/create.ts
+++ b/src/lib/builders/date-field/create.ts
@@ -73,6 +73,7 @@ const defaults = {
 	hideTimeZone: false,
 	disabled: false,
 	readonly: false,
+	readonlySegments: undefined,
 	name: undefined,
 	required: false,
 	minValue: undefined,
@@ -111,6 +112,7 @@ export function createDateField(props?: CreateDateFieldProps) {
 		isDateUnavailable,
 		disabled,
 		readonly,
+		readonlySegments,
 		name: nameStore,
 		required,
 		minValue,
@@ -168,6 +170,11 @@ export function createDateField(props?: CreateDateFieldProps) {
 	 */
 	const updatingDayPeriod = writable<DayPeriod>(null);
 
+	const readonlySegmentsSet = derived(
+		readonlySegments,
+		($readonlySegments) => new Set<SegmentPart>($readonlySegments)
+	);
+
 	const ids = toWritableStores({ ...generateIds(dateFieldIdParts), ...withDefaults.ids });
 
 	const idValues = derived(
@@ -424,6 +431,7 @@ export function createDateField(props?: CreateDateFieldProps) {
 			isInvalid,
 			disabled,
 			readonly,
+			readonlySegmentsSet,
 			idValues,
 			locale,
 		],
@@ -435,6 +443,7 @@ export function createDateField(props?: CreateDateFieldProps) {
 			$isInvalid,
 			$disabled,
 			$readonly,
+			$readonlySegmentsSet,
 			$idValues,
 			_,
 		]) => {
@@ -445,11 +454,12 @@ export function createDateField(props?: CreateDateFieldProps) {
 				ids: $idValues,
 			};
 			return (part: SegmentPart) => {
+				const inReadonlySegments = $readonlySegmentsSet.has(part);
 				const defaultAttrs = {
 					...getSegmentAttrs(part, props),
 					'aria-invalid': $isInvalid ? ('true' as const) : undefined,
 					'aria-disabled': $disabled ? ('true' as const) : undefined,
-					'aria-readonly': $readonly ? ('true' as const) : undefined,
+					'aria-readonly': $readonly || inReadonlySegments ? ('true' as const) : undefined,
 					'data-invalid': $isInvalid ? '' : undefined,
 					'data-disabled': $disabled ? '' : undefined,
 					'data-segment': `${part}`,
@@ -467,7 +477,7 @@ export function createDateField(props?: CreateDateFieldProps) {
 					...defaultAttrs,
 					id: $idValues[part],
 					'aria-labelledby': getLabelledBy(part),
-					contentEditable: $readonly || $disabled ? false : true,
+					contenteditable: $readonly || inReadonlySegments || $disabled ? false : true,
 					'aria-describedby': describedBy,
 					tabindex: $disabled ? undefined : 0,
 				};
@@ -485,7 +495,7 @@ export function createDateField(props?: CreateDateFieldProps) {
 			? Updater<TimeSegmentObj[T]>
 			: Updater<DateAndTimeSegmentObj[T]>
 	) {
-		if (get(disabled) || get(readonly)) return;
+		if (get(disabled) || get(readonly) || get(readonlySegmentsSet).has(part)) return;
 		segmentValues.update((prev) => {
 			const dateRef = get(placeholder);
 			if (isDateAndTimeSegmentObj(prev)) {
diff --git a/src/lib/builders/date-field/types.ts b/src/lib/builders/date-field/types.ts
index 6d94a0c1..161a0b9a 100644
--- a/src/lib/builders/date-field/types.ts
+++ b/src/lib/builders/date-field/types.ts
@@ -3,6 +3,7 @@ import type { ChangeFn, IdObj } from '$lib/internal/helpers/index.js';
 import type { DateFieldIdParts, createDateField } from './create.js';
 import type { DateValue } from '@internationalized/date';
 import type { Granularity, Matcher } from '$lib/index.js';
+import type { EditableSegmentPart } from '$lib/shared/index.js';
 
 export type DateFieldProps = {
 	/**
@@ -116,6 +117,14 @@ export type DateFieldProps = {
 	 */
 	readonly?: boolean;
 
+	/**
+	 *
+	 * List of segment names that will be readonly.
+	 *
+	 * @default undefined
+	 */
+	readonlySegments?: EditableSegmentPart[];
+
 	/**
 	 * The format to use for displaying the time in the input.
 	 * If using a 12 hour clock, ensure you also include the
diff --git a/src/lib/builders/date-picker/types.ts b/src/lib/builders/date-picker/types.ts
index 02a20402..c69771a2 100644
--- a/src/lib/builders/date-picker/types.ts
+++ b/src/lib/builders/date-picker/types.ts
@@ -125,7 +125,11 @@ export type DatePickerProps = {
 	locale?: string;
 };
 
-type ModifiedDateFieldProps = RenameProperties<CreateDateFieldProps, { ids: 'dateFieldIds' }>;
+type ModifiedDateFieldProps = Omit<
+	RenameProperties<CreateDateFieldProps, { ids: 'dateFieldIds' }>,
+	'readonlySegments'
+>;
+
 type ModifiedCalendarProps = RenameProperties<CreateCalendarProps, { ids: 'calendarIds' }>;
 type ModifiedPopoverProps = Omit<
 	RenameProperties<CreatePopoverProps, { ids: 'popoverIds' }>,
diff --git a/src/lib/builders/date-range-field/create.ts b/src/lib/builders/date-range-field/create.ts
index 174bd634..d0f21f76 100644
--- a/src/lib/builders/date-range-field/create.ts
+++ b/src/lib/builders/date-range-field/create.ts
@@ -40,6 +40,7 @@ const defaults = {
 	endName: undefined,
 	disabled: false,
 	readonly: false,
+	readonlySegments: undefined,
 	minValue: undefined,
 	maxValue: undefined,
 } satisfies CreateDateRangeFieldProps;
@@ -86,9 +87,17 @@ export function createDateRangeField(props?: CreateDateRangeFieldProps) {
 	);
 
 	const startField = createDateField({
-		...omit(withDefaults, 'defaultValue', 'onValueChange', 'startName', 'endName'),
+		...omit(
+			withDefaults,
+			'defaultValue',
+			'onValueChange',
+			'startName',
+			'endName',
+			'readonlySegments'
+		),
 		value: startValue,
 		name: withDefaults.startName,
+		readonlySegments: withDefaults.readonlySegments?.start,
 		ids: {
 			...generatedIds,
 			...withDefaults.ids,
@@ -97,9 +106,17 @@ export function createDateRangeField(props?: CreateDateRangeFieldProps) {
 	});
 
 	const endField = createDateField({
-		...omit(withDefaults, 'defaultValue', 'onValueChange', 'endName', 'startName'),
+		...omit(
+			withDefaults,
+			'defaultValue',
+			'onValueChange',
+			'endName',
+			'startName',
+			'readonlySegments'
+		),
 		value: endValue,
 		name: withDefaults.endName,
+		readonlySegments: withDefaults.readonlySegments?.end,
 		ids: {
 			...generatedIds,
 			...withDefaults.ids,
@@ -315,6 +332,10 @@ export function createDateRangeField(props?: CreateDateRangeFieldProps) {
 		startField.options.readonly.set($readonly);
 		endField.options.readonly.set($readonly);
 	});
+	effect([options.readonlySegments], ([$readonlySegments]) => {
+		startField.options.readonlySegments.set($readonlySegments?.start);
+		endField.options.readonlySegments.set($readonlySegments?.end);
+	});
 	effect([options.minValue], ([$minValue]) => {
 		startField.options.minValue.set($minValue);
 		endField.options.minValue.set($minValue);
diff --git a/src/lib/builders/date-range-field/types.ts b/src/lib/builders/date-range-field/types.ts
index 8ffe1948..e7fd3ea8 100644
--- a/src/lib/builders/date-range-field/types.ts
+++ b/src/lib/builders/date-range-field/types.ts
@@ -3,6 +3,7 @@ import type { ChangeFn, IdObj } from '$lib/internal/helpers/index.js';
 import type { DateRangeFieldIdParts, createDateRangeField } from './create.js';
 import type { DateValue } from '@internationalized/date';
 import type { CreateDateFieldProps, DateFieldIdParts, DateRange, Matcher } from '$lib/index.js';
+import type { EditableSegmentPart } from '$lib/shared/index.js';
 
 export type DateRangeFieldProps = {
 	/**
@@ -99,6 +100,14 @@ export type DateRangeFieldProps = {
 	 */
 	endName?: string;
 
+	/**
+	 *
+	 * Lists of segment names that will be readonly on the start and end fields.
+	 *
+	 * @default undefined
+	 */
+	readonlySegments?: { start: EditableSegmentPart[]; end: EditableSegmentPart[] };
+
 	/**
 	 * Override any of the element IDs set by the builder.
 	 *
@@ -114,6 +123,9 @@ export type DateRangeFieldProps = {
 
 export type CreateDateRangeFieldProps = Expand<
 	DateRangeFieldProps &
-		Omit<CreateDateFieldProps, 'value' | 'defaultValue' | 'onValueChange' | 'ids' | 'name'>
+		Omit<
+			CreateDateFieldProps,
+			'value' | 'defaultValue' | 'onValueChange' | 'ids' | 'name' | 'readonlySegments'
+		>
 >;
 export type DateRangeField = ReturnType<typeof createDateRangeField>;
diff --git a/src/lib/builders/date-range-picker/types.ts b/src/lib/builders/date-range-picker/types.ts
index 35fa6734..c224bff3 100644
--- a/src/lib/builders/date-range-picker/types.ts
+++ b/src/lib/builders/date-range-picker/types.ts
@@ -8,7 +8,7 @@ import type { RenameProperties } from '$lib/internal/types.js';
 import type { CreateDateRangeFieldProps } from '../date-range-field/types.js';
 import type { CreateRangeCalendarProps } from '../range-calendar/types.js';
 
-export type DateRangePickerProps = {
+type DateRangePickerProps = {
 	/**
 	 * The default value for the date field. When provided,
 	 * the `placeholder` will also assume this value.
@@ -125,7 +125,10 @@ export type DateRangePickerProps = {
 	locale?: string;
 };
 
-type ModifiedDateFieldProps = RenameProperties<CreateDateRangeFieldProps, { ids: 'dateFieldIds' }>;
+type ModifiedDateFieldProps = Omit<
+	RenameProperties<CreateDateRangeFieldProps, { ids: 'dateFieldIds' }>,
+	'readonlySegments'
+>;
 type ModifiedCalendarProps = RenameProperties<CreateRangeCalendarProps, { ids: 'calendarIds' }>;
 type ModifiedPopoverProps = Omit<
 	RenameProperties<CreatePopoverProps, { ids: 'popoverIds' }>,
diff --git a/src/lib/shared/index.ts b/src/lib/shared/index.ts
index b9513b12..f9b788d1 100644
--- a/src/lib/shared/index.ts
+++ b/src/lib/shared/index.ts
@@ -4,8 +4,17 @@
  * use, they should be exported from this file to prevent conflicts
  * with other builders that may export the same types or functions.
  */
-import type { SegmentPart } from '$lib/builders/date-field/_internal/types.js';
+import type { SegmentPart, EditableSegmentPart } from '$lib/builders/date-field/_internal/types.js';
 import type { Granularity, Matcher, DateRange, Month } from '$lib/internal/helpers/date/index.js';
 import type { FocusProp, FocusTarget } from '$lib/internal/helpers/index.js';
 
-export type { Granularity, FocusProp, FocusTarget, Matcher, DateRange, Month, SegmentPart };
+export type {
+	Granularity,
+	FocusProp,
+	FocusTarget,
+	Matcher,
+	DateRange,
+	Month,
+	SegmentPart,
+	EditableSegmentPart,
+};
diff --git a/src/stories/DateField/DateField.svelte b/src/stories/DateField/DateField.svelte
index 900a6ae7..214850f3 100644
--- a/src/stories/DateField/DateField.svelte
+++ b/src/stories/DateField/DateField.svelte
@@ -11,6 +11,7 @@
 	export let isDateUnavailable: CreateDateFieldProps['isDateUnavailable'] = undefined;
 	export let disabled: CreateDateFieldProps['disabled'] = undefined;
 	export let readonly: CreateDateFieldProps['readonly'] = undefined;
+	export let readonlySegments: CreateDateFieldProps['readonlySegments'] = undefined;
 	export let hourCycle: CreateDateFieldProps['hourCycle'] = undefined;
 	export let locale: CreateDateFieldProps['locale'] = 'en';
 	export let hideTimeZone: CreateDateFieldProps['hideTimeZone'] = undefined;
@@ -29,6 +30,7 @@
 		isDateUnavailable,
 		disabled,
 		readonly,
+		readonlySegments,
 		hourCycle,
 		locale,
 		hideTimeZone,


Test patch:
diff --git a/src/tests/date-field/DateField.spec.ts b/src/tests/date-field/DateField.spec.ts
index 1c998349..63da56dd 100644
--- a/src/tests/date-field/DateField.spec.ts
+++ b/src/tests/date-field/DateField.spec.ts
@@ -258,6 +258,29 @@ describe('DateField', () => {
 		}
 	});
 
+	test('readonlySegments prop prevents modifying given segments', async () => {
+		const { getByTestId, user } = setup({
+			readonlySegments: ['month'],
+			defaultValue: calendarDateOther,
+		});
+
+		// month should not change
+		const monthSegment = getByTestId('month');
+		expect(monthSegment).toHaveTextContent(String(calendarDateOther['month']));
+		await user.click(monthSegment);
+		expect(monthSegment).toHaveFocus();
+		await user.keyboard(kbd.ARROW_UP);
+		expect(monthSegment).toHaveTextContent(String(calendarDateOther['month']));
+
+		// day should change
+		const daySegment = getByTestId('day');
+		expect(daySegment).toHaveTextContent(String(calendarDateOther['day']));
+		await user.click(daySegment);
+		expect(daySegment).toHaveFocus();
+		await user.keyboard(kbd.ARROW_UP);
+		expect(daySegment).toHaveTextContent(String(calendarDateOther['day'] + 1));
+	});
+
 	test('if selected date unavailable, mark field as invalid', async () => {
 		const { getByTestId, user } = setup({
 			granularity: 'day',
diff --git a/src/tests/date-field/DateFieldTest.svelte b/src/tests/date-field/DateFieldTest.svelte
index 82db773f..32bbb35f 100644
--- a/src/tests/date-field/DateFieldTest.svelte
+++ b/src/tests/date-field/DateFieldTest.svelte
@@ -1,6 +1,7 @@
 <script lang="ts">
 	import { createDateField, type CreateDateFieldProps } from '$lib/builders';
 	import { melt } from '$lib';
+	import { removeUndefined } from '../utils';
 
 	export let value: CreateDateFieldProps['value'] = undefined;
 	export let defaultValue: CreateDateFieldProps['defaultValue'] = undefined;
@@ -10,6 +11,7 @@
 	export let isDateUnavailable: CreateDateFieldProps['isDateUnavailable'] = undefined;
 	export let disabled: CreateDateFieldProps['disabled'] = undefined;
 	export let readonly: CreateDateFieldProps['readonly'] = undefined;
+	export let readonlySegments: CreateDateFieldProps['readonlySegments'] = undefined;
 	export let hourCycle: CreateDateFieldProps['hourCycle'] = undefined;
 	export let locale: CreateDateFieldProps['locale'] = 'en';
 	export let hideTimeZone: CreateDateFieldProps['hideTimeZone'] = undefined;
@@ -19,21 +21,24 @@
 	const {
 		elements: { field, segment, label, validation },
 		states: { value: insideValue, segmentContents },
-	} = createDateField({
-		value,
-		defaultValue,
-		defaultPlaceholder,
-		onValueChange,
-		onPlaceholderChange,
-		isDateUnavailable,
-		disabled,
-		readonly,
-		hourCycle,
-		locale,
-		hideTimeZone,
-		ids,
-		granularity,
-	});
+	} = createDateField(
+		removeUndefined({
+			value,
+			defaultValue,
+			defaultPlaceholder,
+			onValueChange,
+			onPlaceholderChange,
+			isDateUnavailable,
+			disabled,
+			readonly,
+			readonlySegments,
+			hourCycle,
+			locale,
+			hideTimeZone,
+			ids,
+			granularity,
+		})
+	);
 </script>
 
 <main>
diff --git a/src/tests/date-range-field/DateRangeField.spec.ts b/src/tests/date-range-field/DateRangeField.spec.ts
index e59405f2..107c3784 100644
--- a/src/tests/date-range-field/DateRangeField.spec.ts
+++ b/src/tests/date-range-field/DateRangeField.spec.ts
@@ -258,4 +258,68 @@ describe('DateField', () => {
 		expect(getByTestId('start-value')).toHaveTextContent(overrideDay.start.toString());
 		expect(getByTestId('end-value')).toHaveTextContent(overrideDay.end.toString());
 	});
+
+	test('readonlySegments prop prevents modifying given segments', async () => {
+		const { getByTestId, user } = setup({
+			defaultValue: exampleDate,
+			readonlySegments: {
+				start: ['month'],
+				end: ['month', 'year'],
+			},
+		});
+
+		// start field
+		{
+			// start month should not change
+			const monthSegment = getByTestId('start-month');
+			expect(monthSegment).toHaveTextContent(String(exampleDate.start['month']));
+			await user.click(monthSegment);
+			expect(monthSegment).toHaveFocus();
+			await user.keyboard(kbd.ARROW_UP);
+			expect(monthSegment).toHaveTextContent(String(exampleDate.start['month']));
+
+			// start day should change
+			const daySegment = getByTestId('start-day');
+			expect(daySegment).toHaveTextContent(String(exampleDate.start['day']));
+			await user.click(daySegment);
+			expect(daySegment).toHaveFocus();
+			await user.keyboard(kbd.ARROW_UP);
+			expect(daySegment).toHaveTextContent(String(exampleDate.start['day'] + 1));
+
+			// start year should change
+			const yearSegment = getByTestId('start-year');
+			expect(yearSegment).toHaveTextContent(String(exampleDate.start['year']));
+			await user.click(yearSegment);
+			expect(yearSegment).toHaveFocus();
+			await user.keyboard(kbd.ARROW_UP);
+			expect(yearSegment).toHaveTextContent(String(exampleDate.start['year'] + 1));
+		}
+
+		// end field
+		{
+			// end month should not change
+			const monthSegment = getByTestId('end-month');
+			expect(monthSegment).toHaveTextContent(String(exampleDate.end['month']));
+			await user.click(monthSegment);
+			expect(monthSegment).toHaveFocus();
+			await user.keyboard(kbd.ARROW_UP);
+			expect(monthSegment).toHaveTextContent(String(exampleDate.end['month']));
+
+			// end day should change
+			const daySegment = getByTestId('end-day');
+			expect(daySegment).toHaveTextContent(String(exampleDate.end['day']));
+			await user.click(daySegment);
+			expect(daySegment).toHaveFocus();
+			await user.keyboard(kbd.ARROW_UP);
+			expect(daySegment).toHaveTextContent(String(exampleDate.end['day'] + 1));
+
+			// end year should not change
+			const yearSegment = getByTestId('end-year');
+			expect(yearSegment).toHaveTextContent(String(exampleDate.end['year']));
+			await user.click(yearSegment);
+			expect(yearSegment).toHaveFocus();
+			await user.keyboard(kbd.ARROW_UP);
+			expect(yearSegment).toHaveTextContent(String(exampleDate.end['year']));
+		}
+	});
 });
diff --git a/src/tests/date-range-field/DateRangeFieldTest.svelte b/src/tests/date-range-field/DateRangeFieldTest.svelte
index cefd943b..b7aca709 100644
--- a/src/tests/date-range-field/DateRangeFieldTest.svelte
+++ b/src/tests/date-range-field/DateRangeFieldTest.svelte
@@ -14,6 +14,7 @@
 	export let isDateUnavailable: CreateDateRangeFieldProps['isDateUnavailable'] = undefined;
 	export let disabled: CreateDateRangeFieldProps['disabled'] = undefined;
 	export let readonly: CreateDateRangeFieldProps['readonly'] = undefined;
+	export let readonlySegments: CreateDateRangeFieldProps['readonlySegments'] = undefined;
 	export let hourCycle: CreateDateRangeFieldProps['hourCycle'] = undefined;
 	export let locale: CreateDateRangeFieldProps['locale'] = 'en';
 	export let hideTimeZone: CreateDateRangeFieldProps['hideTimeZone'] = undefined;
@@ -35,6 +36,7 @@
 			isDateUnavailable,
 			disabled,
 			readonly,
+			readonlySegments,
 			hourCycle,
 			locale,
 			hideTimeZone,