| ← Back to Timezone Handling | ← Back to JavaScript Development |
10.3.2 Timezone Input controller
app/assets/js/controllers/timezone-input-controller.js registers as
timezone-input. It is an opt-in form enhancement that displays stored UTC
instants in one IANA timezone and submits hidden ISO UTC values.
Use it only when the endpoint has an explicit client-UTC submission contract.
Ordinary forms can instead render local values with the PHP Timezone helper
and convert them with PHP TimezoneHelper::toUtc().
Targets and values
| Name | Kind | Required | Contract |
|---|---|---|---|
datetimeInput |
target | One or more for useful behavior | datetime-local input; optional data-utc-value supplies the stored instant |
notice |
target | No | Receives “Times shown in …” text |
timezone |
String value | No | IANA zone; falls back to browser detection |
showNotice |
Boolean value | No | Defaults to true |
Complete form
<?php
$timezone = TimezoneHelper::getGatheringTimezone(
$gathering,
$this->getRequest()->getAttribute('identity'),
);
?>
<?= $this->Form->create($gathering, [
'data-controller' => 'timezone-input',
'data-timezone-input-timezone-value' => $timezone,
'data-timezone-input-show-notice-value' => 'true',
]) ?>
<?= $this->Form->control('start_date', [
'type' => 'datetime-local',
'data-timezone-input-target' => 'datetimeInput',
'data-utc-value' => $gathering->start_date?->toIso8601String() ?? '',
]) ?>
<?= $this->Form->control('end_date', [
'type' => 'datetime-local',
'data-timezone-input-target' => 'datetimeInput',
'data-utc-value' => $gathering->end_date?->toIso8601String() ?? '',
]) ?>
<p class="form-text" data-timezone-input-target="notice"></p>
<?= $this->Form->button(__('Save')) ?>
<?= $this->Form->end() ?>
Resolve the zone on the server. Do not let a client value select a tenant or override an event’s authorized timezone.
Lifecycle
Connect
connect():
- selects the Stimulus
timezonevalue orKMP_Timezone.detectTimezone(); - converts every non-empty
data-utc-valuewithtoLocalInput(); - records
data-original-utcanddata-local-value; - fills notice targets when enabled; and
- attaches bound
submitandresetlisteners to the controller element.
The controller element should be the form. Attaching it to a wrapper means a submit event must bubble through that wrapper and hidden fields will be appended there rather than directly to the form.
Submit
For each non-empty target, handleSubmit():
- calls
KMP_Timezone.toUTC(input.value, timezone); - saves the local value in
data-submitted-local; - removes a previous generated hidden input for the same field;
- creates a same-name hidden input with
data-timezone-converted="true"; - disables the visible input; and
- appends the hidden input to the controller element.
If conversion returns an empty string, the visible input stays enabled and gets
data-timezone-conversion-failed="true". The current controller does not
cancel submission or announce that failure, so server validation is mandatory.
A workflow that requires client-side blocking must implement and test accessible
error handling rather than silently dropping the value.
Repeated submits skip an already-disabled visible input and do not create duplicate hidden fields.
Reset
handleReset() removes generated hidden fields, re-enables target inputs,
clears the failure marker, and restores each initial converted local value on the
next task with setTimeout(..., 0).
Disconnect
disconnect() removes the exact bound submit and reset listeners. Preserve this
cleanup because Turbo Frame replacement can reconnect the controller many
times.
Public methods
| Method | Contract |
|---|---|
convertUtcToLocal() |
Rebuild target values from each target’s original data-utc-value |
updateNotice() |
Replace notice content with a clock icon and zone/abbreviation text |
handleSubmit(event) |
Perform the hidden-field submission conversion |
handleReset(event) |
Restore the connected state |
updateTimezone(newTimezone) |
Set the controller’s internal zone, reconvert original UTC values, and refresh notices |
getTimezone() |
Return the current internal zone |
updateTimezone() expects a timezone string, not a DOM event. Call it from
coordinating JavaScript or an outlet; do not wire it directly as a Stimulus
action without an adapter that extracts and validates the selected value.
Data flow
stored UTC instant
↓ data-utc-value
connect → UTC instant to local datetime-local text
↓ user edits
submit → local text to hidden UTC ISO value
↓
server validates tenant/context/timezone and persists UTC
The visible input’s name is reused by the hidden input. Every target therefore
needs a non-empty, unique form field name.
DST and accessibility
- Use an IANA zone, not
CST,CDT, or a fixed offset. - Test ambiguous and nonexistent local times around DST changes.
- Associate each input with a label, help text, and validation error.
- Keep the notice visible and meaningful when users may not expect the selected zone.
- Announce conversion errors if client-side validation blocks submission.
- Preserve visible focus and avoid replacing the focused control during an asynchronous interaction.
Verification
Focused tests are in
app/tests/js/controllers/timezone-input-controller.test.js. Run:
cd app
npm run test:js -- timezone-input-controller.test.js
npm run dev
Use Playwright for a real form round trip, DST behavior, Turbo reconnection, and validation/focus announcements.