Repository: melt-ui/melt-ui (ts)

Issue:
🐛Bug: Dropdown Menu - Focus on trigger button after page load
**Describe the bug**
Immediately after the page loads, it automatically focuses on the element that opens and closes the Dropdown menu.

**To Reproduce**
Steps to reproduce the behavior:
1. Go to https://melt-ui.com/docs/builders/dropdown-menu


**Expected behavior**
It should not be in focus immediately after the page loads

**Screenshots**
If applicable, add screenshots to help explain your problem.

**Desktop (please complete the following information):**
 - OS: [macOS Sonoma 14.2.1]
 - Browser [chrome]
 - Version [120.0.6099.129]

**Smartphone (please complete the following information):**
 - Device: [e.g. iPhone6]
 - OS: [e.g. iOS8.1]
 - Browser [e.g. stock browser, safari]
 - Version [e.g. 22]

**Additional context**
Add any other context about the problem here.

To reproduce using SvelteLab, use the [Melt UI template](https://www.sveltelab.dev/?t=melt_ui) and modify the `src/routes/+page.svelte` file.


Pull request:
fix(menu): focus on the trigger when rootActiveTrigger is defined
fix #867.

When the page is first loaded, there is no need to focus on the trigger even if $rootOpen is false.

However, when rootActiveTrigger is set at the time it is mounted, the effect will be executed and the trigger will be focused.

Since we only need to pay attention to whether the menu is closed or not, we did not need to monitor the rootActiveTrigger.

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/.changeset/seven-pillows-turn.md b/.changeset/seven-pillows-turn.md
new file mode 100644
index 00000000..7843dc8f
--- /dev/null
+++ b/.changeset/seven-pillows-turn.md
@@ -0,0 +1,5 @@
+---
+"@melt-ui/svelte": patch
+---
+
+fix(menu): focus on the trigger when rootActiveTrigger is defined
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/builders/menu/create.ts b/src/lib/builders/menu/create.ts
index a393915a..1fb3b168 100644
--- a/src/lib/builders/menu/create.ts
+++ b/src/lib/builders/menu/create.ts
@@ -1138,55 +1138,50 @@ export function createMenuBuilder(opts: _MenuBuilderOptions) {
 		}
 	});
 
-	effect(
-		[rootOpen, rootActiveTrigger, preventScroll],
-		([$rootOpen, $rootActiveTrigger, $preventScroll]) => {
-			if (!isBrowser) return;
-
-			const unsubs: Array<() => void> = [];
+	effect([rootOpen], ([$rootOpen]) => {
+		if (!isBrowser) return;
+		if (!$rootOpen) {
+			const $rootActiveTrigger = get(rootActiveTrigger);
+			if (!$rootActiveTrigger) return;
+			const $closeFocus = get(closeFocus);
 
-			if (opts.removeScroll && $rootOpen && $preventScroll) {
-				unsubs.push(removeScroll());
+			if (!$rootOpen && $rootActiveTrigger) {
+				handleFocus({ prop: $closeFocus, defaultEl: $rootActiveTrigger });
 			}
+		}
+	});
 
-			const $closeFocus = get(closeFocus);
+	effect([rootOpen, preventScroll], ([$rootOpen, $preventScroll]) => {
+		if (!isBrowser) return;
 
-			if (!$rootOpen) {
-				if ($rootActiveTrigger) {
-					// If we already have a reference to the trigger, focus it
-					handleFocus({ prop: $closeFocus, defaultEl: $rootActiveTrigger });
-				} else {
-					// otherwise we'll get the trigger el and focus it
-					handleFocus({
-						prop: $closeFocus,
-						defaultEl: document.getElementById(get(rootIds.trigger)),
-					});
-				}
-			}
+		const unsubs: Array<() => void> = [];
 
-			// if the menu is open, we'll sleep for a sec so the menu can render
-			// before we focus on either the first item or the menu itself.
-			sleep(1).then(() => {
-				const menuEl = document.getElementById(get(rootIds.menu));
-				if (menuEl && $rootOpen && get(isUsingKeyboard)) {
-					if (get(disableFocusFirstItem)) {
-						handleRovingFocus(menuEl);
-						return;
-					}
-					// Get menu items belonging to the root menu
-					const menuItems = getMenuItems(menuEl);
-					if (!menuItems.length) return;
+		if (opts.removeScroll && $rootOpen && $preventScroll) {
+			unsubs.push(removeScroll());
+		}
 
-					// Focus on first menu item
-					handleRovingFocus(menuItems[0]);
+		// if the menu is open, we'll sleep for a sec so the menu can render
+		// before we focus on either the first item or the menu itself.
+		sleep(1).then(() => {
+			const menuEl = document.getElementById(get(rootIds.menu));
+			if (menuEl && $rootOpen && get(isUsingKeyboard)) {
+				if (get(disableFocusFirstItem)) {
+					handleRovingFocus(menuEl);
+					return;
 				}
-			});
+				// Get menu items belonging to the root menu
+				const menuItems = getMenuItems(menuEl);
+				if (!menuItems.length) return;
 
-			return () => {
-				unsubs.forEach((unsub) => unsub());
-			};
-		}
-	);
+				// Focus on first menu item
+				handleRovingFocus(menuItems[0]);
+			}
+		});
+
+		return () => {
+			unsubs.forEach((unsub) => unsub());
+		};
+	});
 
 	effect(rootOpen, ($rootOpen) => {
 		if (!isBrowser) return;
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,
diff --git a/src/tests/dropdown-menu/DropdownMenu.spec.ts b/src/tests/dropdown-menu/DropdownMenu.spec.ts
index 70ea94ac..5c539c6f 100644
--- a/src/tests/dropdown-menu/DropdownMenu.spec.ts
+++ b/src/tests/dropdown-menu/DropdownMenu.spec.ts
@@ -7,6 +7,7 @@ import DropdownMenuTest from './DropdownMenuTest.svelte';
 import DropdownMenuForceVisible from './DropdownMenuForceVisibleTest.svelte';
 import type { CreateDropdownMenuProps } from '$lib';
 import { sleep } from '$lib/internal/helpers/sleep.js';
+import { tick } from 'svelte';
 
 const OPEN_KEYS = [kbd.ENTER, kbd.ARROW_DOWN, kbd.SPACE];
 
@@ -45,6 +46,19 @@ describe('Dropdown Menu (Default)', () => {
 		expect(menu).not.toBeVisible();
 	});
 
+	test('Refocuses the trigger when menu closed with keyboard', async () => {
+		const { user, trigger, getByTestId } = setup();
+		const menu = getByTestId('menu');
+
+		expect(menu).not.toBeVisible();
+		await user.click(trigger);
+		expect(menu).toBeVisible();
+
+		await user.keyboard(kbd.ESCAPE);
+		expect(menu).not.toBeVisible();
+		expect(trigger).toHaveFocus();
+	});
+
 	test.each(OPEN_KEYS)('Opens when %s is pressed', async (key) => {
 		const { user, trigger, getByTestId } = setup();
 		const menu = getByTestId('menu');
@@ -303,6 +317,21 @@ describe('Dropdown Menu (forceVisible)', () => {
 		await waitFor(() => expect(queryByTestId('menu')).toBeNull());
 	});
 
+	test('Refocuses the trigger when menu closed with keyboard', async () => {
+		const { queryByTestId, user, trigger } = setupForceVis();
+
+		expect(queryByTestId('menu')).toBeNull();
+		await user.click(trigger);
+		await waitFor(() => expect(queryByTestId('menu')).not.toBeNull());
+		await user.keyboard(kbd.ARROW_DOWN);
+
+		expect(trigger).not.toHaveFocus();
+		const spy = vi.spyOn(trigger, 'focus');
+		await user.keyboard(kbd.ESCAPE);
+		await waitFor(() => expect(queryByTestId('menu')).toBeNull());
+		expect(spy).toHaveBeenCalled();
+	});
+
 	test.each(OPEN_KEYS)('Opens when %s is pressed', async (key) => {
 		const { queryByTestId, user, trigger } = setupForceVis();
 		expect(queryByTestId('menu')).toBeNull();