Skip to content
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
4 changes: 4 additions & 0 deletions packages/dataviews/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@

If a combined field relied on a same-id field's `isValid` rules being applied to the group, move those rules to the child fields.

### Enhancements

- DataForm: Communicate the timezone a `datetime` value is edited in. When the site timezone differs from the visitor's, the control renders help text under the input naming the site timezone: the zone name (e.g. `(CEST) Europe/Madrid`) or the UTC offset for sites pinned to one ([#82291](https://github.com/WordPress/gutenberg/pull/82291)).

### Internal

- Remove unused dependency `@wordpress/primitives` ([#82103](https://github.com/WordPress/gutenberg/pull/82103)).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import toCalendarDate from './utils/to-calendar-date';
import useDisabledDateMatchers from './utils/use-disabled-date-matchers';
import getCustomValidity from './utils/get-custom-validity';
import getCalendarLocale from './utils/get-calendar-locale';
import getTimezoneDescription from './utils/get-timezone-description';
import parseDateTime from '../../field-types/utils/parse-date-time';

const formatDateTime = ( value?: string ): string => {
Expand Down Expand Up @@ -179,6 +180,7 @@ function CalendarDateTimeControl< Item >( {
value={ formatDateTime( value ) }
onValueChange={ handleManualDateTimeChange }
disabled={ disabled }
description={ getTimezoneDescription() }
min={
minConstraint
? formatDateTime( minConstraint )
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -290,4 +290,91 @@ describe( 'DateTime control', () => {
} )
).toBeInTheDocument();
} );

describe( 'timezone help', () => {
it( 'is not rendered when the site and browser timezones match', () => {
render(
<DateTimeHarness initialValue="2026-08-15T12:30:00.000Z" />
);

expect(
screen.getByLabelText( 'Date time' )
).not.toHaveAccessibleDescription();
} );

it( 'names the timezone for a site with a named timezone', () => {
setSettings( {
...originalSettings,
timezone: {
offset: -6,
offsetFormatted: '-6',
string: 'America/Costa_Rica',
abbr: 'CST',
},
} );

render(
<DateTimeHarness initialValue="2026-08-15T12:30:00.000Z" />
);

// Underscores in the zone name are replaced for display.
expect(
screen.getByLabelText( 'Date time' )
).toHaveAccessibleDescription(
'Timezone: (CST) America/Costa Rica'
);
} );

// The manual offset reaches Calendar as a raw offset identifier,
// which Node 20 rejects — see `supportsOffsetTimeZones`.
describeWithOffsetTimeZones( 'on a site with a manual offset', () => {
it( 'falls back to the UTC offset', () => {
setSiteOffset( 14 );

render(
<DateTimeHarness initialValue="2026-08-15T12:30:00.000Z" />
);

expect(
screen.getByLabelText( 'Date time' )
).toHaveAccessibleDescription( 'Timezone: UTC+14' );
} );
} );

it( 'spells out a UTC site timezone for a non-UTC visitor', () => {
setSettings( {
...originalSettings,
timezone: {
offset: 0,
offsetFormatted: '0',
string: 'UTC',
abbr: 'UTC',
},
} );
// Jest pins the browser to UTC, so this mismatch is created from
// the browser side: UTC+1, i.e. an offset of -60 minutes.
const offsetSpy = jest
.spyOn( Date.prototype, 'getTimezoneOffset' )
.mockReturnValue( -60 );

try {
render(
<DateTime
data={ { published: '2026-08-15T12:30:00.000Z' } }
field={ field }
onChange={ noop }
config={ { compact: true } }
/>
);

expect(
screen.getByLabelText( 'Date time' )
).toHaveAccessibleDescription(
'Timezone: Coordinated Universal Time'
);
} finally {
offsetSpy.mockRestore();
}
} );
} );
} );
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { getSettings } from '@wordpress/date';
import { __, sprintf } from '@wordpress/i18n';

/**
* Help text communicating the timezone a datetime value is edited in.
* Returns `undefined` when the site timezone matches the visitor's, where
* spelling out the frame would be noise.
*/
export default function getTimezoneDescription(): string | undefined {
const { timezone } = getSettings();

const userTimezoneOffset = -1 * ( new Date().getTimezoneOffset() / 60 );
// Compare as numbers because the site offset comes over as a string.
if ( Number( timezone.offset ) === userTimezoneOffset ) {
return undefined;
}

const offsetSymbol = Number( timezone.offset ) >= 0 ? '+' : '';
const zoneAbbr =
'' !== timezone.abbr && isNaN( Number( timezone.abbr ) )
? timezone.abbr
: `UTC${ offsetSymbol }${ timezone.offsetFormatted }`;

// Replace underscores with spaces in strings like `America/Costa_Rica`.
// A site set to a manual UTC offset has no zone name, and is described by
// the offset alone.
const prettyTimezoneString = timezone.string.replaceAll( '_', ' ' );

let timezoneDetail = zoneAbbr;
if ( 'UTC' === timezone.string ) {
timezoneDetail = __( 'Coordinated Universal Time' );
} else if ( prettyTimezoneString.trim().length > 0 ) {
timezoneDetail = `(${ zoneAbbr }) ${ prettyTimezoneString }`;
}

return sprintf(
/* translators: %s: timezone detail, e.g. "(CEST) Europe/Madrid" or "UTC+3". */
__( 'Timezone: %s' ),
timezoneDetail
);
}
Loading