@melodicdev/components 1.6.2 → 1.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -231,6 +231,12 @@ type ThemeMode = 'light' | 'dark' | 'system';
231
231
 
232
232
  ---
233
233
 
234
+ ## Working with timezones
235
+
236
+ For datetime fields that need to round-trip a real UTC instant — multi-tenant SaaS, calendar/scheduling apps, anything stored as UTC server-side — use [`<ml-date-time-picker>`](./docs/components/forms.md#ml-date-time-picker) with its `timezone` attribute. The picker anchors the wall-clock the user types to the named IANA zone, surfaces a `valueUtc` field on the `ml:change` event detail, and renders a trailing zone label so users know what zone they're entering. Without `timezone`, the component is a plain naive datetime input — no behavior change for existing consumers. Elsewhere in the app, the right primitive for displaying the same instant in another zone is `Intl.DateTimeFormat(..., { timeZone })`.
237
+
238
+ ---
239
+
234
240
  ## Theme System
235
241
 
236
242
  Override tokens globally via CSS:
@@ -1,23 +1,61 @@
1
1
  import type { IElementRef, OnCreate, OnDestroy } from '@melodicdev/core';
2
+ import type { TimezoneLabelFormat } from './tz-utils.js';
2
3
  import '../date-picker/date-picker.component.js';
3
4
  import '../time-picker/time-picker.component.js';
4
5
  /**
5
- * ml-date-time-picker - Combined date and time picker
6
+ * ml-date-time-picker - Combined date and time picker with optional timezone support.
6
7
  *
7
- * Composes ml-date-picker and ml-time-picker into a single control.
8
- * Value format: ISO datetime string (YYYY-MM-DDTHH:mm or YYYY-MM-DDTHH:mm:ss)
8
+ * Composes `ml-date-picker` and `ml-time-picker` into a single control.
9
9
  *
10
- * @example
10
+ * **Without `timezone`** (default): treats `value` as a naive wall-clock
11
+ * string `YYYY-MM-DDTHH:mm`. Behavior is byte-identical to a plain date+time
12
+ * input — no UTC conversion happens.
13
+ *
14
+ * **With `timezone`** (IANA name, e.g. `America/Detroit`): the picker
15
+ * anchors the wall-clock the user sees to the named zone. If the incoming
16
+ * `value` is a UTC ISO string (`...Z` or `±HH:MM` offset), it is parsed as a
17
+ * real instant and rendered as the equivalent wall-clock in `timezone`. If
18
+ * the incoming value is naive, it is treated as wall-clock in `timezone`.
19
+ * The `ml:change` event detail then includes a `valueUtc` field so consumers
20
+ * can store/round-trip the real UTC instant without zone drift.
21
+ *
22
+ * @example Naive (current behavior — unchanged)
11
23
  * ```html
12
24
  * <ml-date-time-picker label="Event start" value="2026-02-08T09:30"></ml-date-time-picker>
13
- * <ml-date-time-picker label="Meeting" use-12-hour></ml-date-time-picker>
14
25
  * ```
15
26
  *
16
- * @fires ml:change - Emitted when value changes. Detail: { value: string, date: string, time: string }
27
+ * @example Timezone-anchored
28
+ * ```html
29
+ * <ml-date-time-picker
30
+ * label="Event start"
31
+ * timezone="America/Detroit"
32
+ * value="2026-04-27T13:00:00Z"
33
+ * timezone-label="short"
34
+ * viewer-hint
35
+ * ></ml-date-time-picker>
36
+ * ```
37
+ *
38
+ * Then in the consumer:
39
+ * ```ts
40
+ * picker.addEventListener('ml:change', (e) => {
41
+ * // e.detail.value → naive wall-clock in the picker's zone
42
+ * // e.detail.valueUtc → '2026-04-27T13:00:00.000Z' — POST this to your API
43
+ * // e.detail.timezone → 'America/Detroit'
44
+ * fetch('/api/events', { method: 'POST', body: JSON.stringify({ startsAt: e.detail.valueUtc }) });
45
+ * });
46
+ * ```
47
+ *
48
+ * @fires ml:change - Detail: `{ value: string, date: string, time: string, valueUtc?: string, timezone?: string }`
17
49
  */
18
50
  export declare class DateTimePickerComponent implements IElementRef, OnCreate, OnDestroy {
19
51
  elementRef: HTMLElement;
20
- /** Selected datetime in ISO format (YYYY-MM-DDTHH:mm) */
52
+ /**
53
+ * Selected datetime.
54
+ *
55
+ * - When `timezone` is unset: a naive `YYYY-MM-DDTHH:mm` string.
56
+ * - When `timezone` is set: either a UTC ISO string (`...Z`/`±HH:MM`)
57
+ * or a naive `YYYY-MM-DDTHH:mm` interpreted as wall-clock in `timezone`.
58
+ */
21
59
  value: string;
22
60
  /** Placeholder text */
23
61
  placeholder: string;
@@ -45,15 +83,49 @@ export declare class DateTimePickerComponent implements IElementRef, OnCreate, O
45
83
  step: number;
46
84
  /** Use 12-hour format (default: true) */
47
85
  twelveHour: boolean;
48
- /** Internal date portion */
86
+ /**
87
+ * IANA timezone name (e.g. `America/Detroit`). When set, the picker
88
+ * interprets/emits values anchored to this zone. When unset (default),
89
+ * values are naive wall-clock strings.
90
+ */
91
+ timezone: string;
92
+ /**
93
+ * How to render the trailing timezone label.
94
+ *
95
+ * - `short` → `EDT`
96
+ * - `long` → `Eastern Daylight Time`
97
+ * - `offset` → `GMT-4`
98
+ * - `none` → don't render a label
99
+ */
100
+ timezoneLabel: TimezoneLabelFormat;
101
+ /**
102
+ * When true AND the browser's UTC offset differs from `timezone` at the
103
+ * current value's instant, render a small subdued line below the input
104
+ * showing the equivalent wall-clock in the viewer's local zone.
105
+ */
106
+ viewerHint: boolean;
107
+ /** Internal date portion (always naive YYYY-MM-DD in the picker's zone) */
49
108
  dateValue: string;
50
- /** Internal time portion */
109
+ /** Internal time portion (always naive HH:mm in the picker's zone) */
51
110
  timeValue: string;
52
111
  private _listeners;
112
+ private _lastSyncedValue;
53
113
  get use12Hour(): boolean;
54
114
  get displayValue(): string;
115
+ /** The naive wall-clock string the user sees, regardless of input format. */
116
+ get naiveValue(): string;
117
+ /** Derived UTC ISO for the current naive value. Empty string when no timezone. */
118
+ get valueUtc(): string;
119
+ /** Trailing timezone label text (e.g. `EDT`). Empty when no timezone or label='none'. */
120
+ get tzLabelText(): string;
121
+ /** Whether the viewer-hint line should be shown right now. */
122
+ get showViewerHint(): boolean;
123
+ /** Pre-rendered viewer hint text (e.g. `(6:00 AM PDT your time)`). */
124
+ get viewerHintText(): string;
55
125
  onCreate(): void;
56
126
  onDestroy(): void;
127
+ onPropertyChange(name: string, _oldVal: unknown, _newVal: unknown): void;
128
+ private currentInstant;
57
129
  private syncFromValue;
58
130
  private attachChildListeners;
59
131
  private emitChange;
@@ -1 +1 @@
1
- {"version":3,"file":"date-time-picker.component.d.ts","sourceRoot":"","sources":["../../../../src/components/forms/date-time-picker/date-time-picker.component.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAczE,OAAO,yCAAyC,CAAC;AACjD,OAAO,yCAAyC,CAAC;AAiCjD;;;;;;;;;;;;;GAaG;AACH,qBAMa,uBAAwB,YAAW,WAAW,EAAE,QAAQ,EAAE,SAAS;IACxE,UAAU,EAAG,WAAW,CAAC;IAEhC,yDAAyD;IAClD,KAAK,SAAM;IAElB,uBAAuB;IAChB,WAAW,SAA0B;IAE5C,kBAAkB;IACX,KAAK,SAAM;IAElB,gBAAgB;IACT,IAAI,SAAM;IAEjB,oBAAoB;IACb,KAAK,SAAM;IAElB,iBAAiB;IACV,IAAI,EAAE,IAAI,GAAG,IAAI,GAAG,IAAI,CAAQ;IAEvC,qBAAqB;IACd,QAAQ,UAAS;IAExB,qBAAqB;IACd,QAAQ,UAAS;IAExB,2CAA2C;IACpC,OAAO,SAAM;IAEpB,2CAA2C;IACpC,OAAO,SAAM;IAEpB,sCAAsC;IAC/B,OAAO,SAAM;IAEpB,sCAAsC;IAC/B,OAAO,SAAM;IAEpB,2BAA2B;IACpB,IAAI,SAAM;IAEjB,yCAAyC;IAClC,UAAU,UAAQ;IAEzB,4BAA4B;IACrB,SAAS,SAAM;IAEtB,4BAA4B;IACrB,SAAS,SAAM;IAEtB,OAAO,CAAC,UAAU,CAAyB;IAE3C,IAAW,SAAS,IAAI,OAAO,CAE9B;IAED,IAAW,YAAY,IAAI,MAAM,CAEhC;IAEM,QAAQ,IAAI,IAAI;IAKhB,SAAS,IAAI,IAAI;IAKxB,OAAO,CAAC,aAAa;IAOrB,OAAO,CAAC,oBAAoB;IA+E5B,OAAO,CAAC,UAAU;CAqBlB"}
1
+ {"version":3,"file":"date-time-picker.component.d.ts","sourceRoot":"","sources":["../../../../src/components/forms/date-time-picker/date-time-picker.component.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAYzE,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,eAAe,CAAC;AAWzD,OAAO,yCAAyC,CAAC;AACjD,OAAO,yCAAyC,CAAC;AAiCjD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4CG;AACH,qBAMa,uBAAwB,YAAW,WAAW,EAAE,QAAQ,EAAE,SAAS;IACxE,UAAU,EAAG,WAAW,CAAC;IAEhC;;;;;;OAMG;IACI,KAAK,SAAM;IAElB,uBAAuB;IAChB,WAAW,SAA0B;IAE5C,kBAAkB;IACX,KAAK,SAAM;IAElB,gBAAgB;IACT,IAAI,SAAM;IAEjB,oBAAoB;IACb,KAAK,SAAM;IAElB,iBAAiB;IACV,IAAI,EAAE,IAAI,GAAG,IAAI,GAAG,IAAI,CAAQ;IAEvC,qBAAqB;IACd,QAAQ,UAAS;IAExB,qBAAqB;IACd,QAAQ,UAAS;IAExB,2CAA2C;IACpC,OAAO,SAAM;IAEpB,2CAA2C;IACpC,OAAO,SAAM;IAEpB,sCAAsC;IAC/B,OAAO,SAAM;IAEpB,sCAAsC;IAC/B,OAAO,SAAM;IAEpB,2BAA2B;IACpB,IAAI,SAAM;IAEjB,yCAAyC;IAClC,UAAU,UAAQ;IAEzB;;;;OAIG;IACI,QAAQ,SAAM;IAErB;;;;;;;OAOG;IACI,aAAa,EAAE,mBAAmB,CAAW;IAEpD;;;;OAIG;IACI,UAAU,UAAS;IAE1B,2EAA2E;IACpE,SAAS,SAAM;IAEtB,sEAAsE;IAC/D,SAAS,SAAM;IAEtB,OAAO,CAAC,UAAU,CAAyB;IAC3C,OAAO,CAAC,gBAAgB,CAAM;IAE9B,IAAW,SAAS,IAAI,OAAO,CAE9B;IAED,IAAW,YAAY,IAAI,MAAM,CAEhC;IAED,6EAA6E;IAC7E,IAAW,UAAU,IAAI,MAAM,CAK9B;IAED,kFAAkF;IAClF,IAAW,QAAQ,IAAI,MAAM,CAK5B;IAED,yFAAyF;IACzF,IAAW,WAAW,IAAI,MAAM,CAI/B;IAED,8DAA8D;IAC9D,IAAW,cAAc,IAAI,OAAO,CAKnC;IAED,sEAAsE;IACtE,IAAW,cAAc,IAAI,MAAM,CAKlC;IAEM,QAAQ,IAAI,IAAI;IAKhB,SAAS,IAAI,IAAI;IAKjB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,GAAG,IAAI;IAQ/E,OAAO,CAAC,cAAc;IAQtB,OAAO,CAAC,aAAa;IA0BrB,OAAO,CAAC,oBAAoB;IA+E5B,OAAO,CAAC,UAAU;CA8BlB"}
@@ -8,6 +8,7 @@ import { MelodicComponent } from '@melodicdev/core';
8
8
  import { registerAdapter } from '@melodicdev/core/forms';
9
9
  import { dateTimePickerTemplate } from './date-time-picker.template.js';
10
10
  import { dateTimePickerStyles } from './date-time-picker.styles.js';
11
+ import { formatTimezoneLabel, formatViewerHint, isUtcIsoString, naiveToUtcIso, utcToNaive, viewerOffsetDiffersAt } from './tz-utils.js';
11
12
  registerAdapter((el) => el.tagName === 'ML-DATE-TIME-PICKER', {
12
13
  inputEvent: 'ml:change',
13
14
  blurEvent: 'focusout',
@@ -51,22 +52,59 @@ function formatDateTimeDisplay(value, use12Hour) {
51
52
  return display;
52
53
  }
53
54
  /**
54
- * ml-date-time-picker - Combined date and time picker
55
+ * ml-date-time-picker - Combined date and time picker with optional timezone support.
55
56
  *
56
- * Composes ml-date-picker and ml-time-picker into a single control.
57
- * Value format: ISO datetime string (YYYY-MM-DDTHH:mm or YYYY-MM-DDTHH:mm:ss)
57
+ * Composes `ml-date-picker` and `ml-time-picker` into a single control.
58
58
  *
59
- * @example
59
+ * **Without `timezone`** (default): treats `value` as a naive wall-clock
60
+ * string `YYYY-MM-DDTHH:mm`. Behavior is byte-identical to a plain date+time
61
+ * input — no UTC conversion happens.
62
+ *
63
+ * **With `timezone`** (IANA name, e.g. `America/Detroit`): the picker
64
+ * anchors the wall-clock the user sees to the named zone. If the incoming
65
+ * `value` is a UTC ISO string (`...Z` or `±HH:MM` offset), it is parsed as a
66
+ * real instant and rendered as the equivalent wall-clock in `timezone`. If
67
+ * the incoming value is naive, it is treated as wall-clock in `timezone`.
68
+ * The `ml:change` event detail then includes a `valueUtc` field so consumers
69
+ * can store/round-trip the real UTC instant without zone drift.
70
+ *
71
+ * @example Naive (current behavior — unchanged)
60
72
  * ```html
61
73
  * <ml-date-time-picker label="Event start" value="2026-02-08T09:30"></ml-date-time-picker>
62
- * <ml-date-time-picker label="Meeting" use-12-hour></ml-date-time-picker>
63
74
  * ```
64
75
  *
65
- * @fires ml:change - Emitted when value changes. Detail: { value: string, date: string, time: string }
76
+ * @example Timezone-anchored
77
+ * ```html
78
+ * <ml-date-time-picker
79
+ * label="Event start"
80
+ * timezone="America/Detroit"
81
+ * value="2026-04-27T13:00:00Z"
82
+ * timezone-label="short"
83
+ * viewer-hint
84
+ * ></ml-date-time-picker>
85
+ * ```
86
+ *
87
+ * Then in the consumer:
88
+ * ```ts
89
+ * picker.addEventListener('ml:change', (e) => {
90
+ * // e.detail.value → naive wall-clock in the picker's zone
91
+ * // e.detail.valueUtc → '2026-04-27T13:00:00.000Z' — POST this to your API
92
+ * // e.detail.timezone → 'America/Detroit'
93
+ * fetch('/api/events', { method: 'POST', body: JSON.stringify({ startsAt: e.detail.valueUtc }) });
94
+ * });
95
+ * ```
96
+ *
97
+ * @fires ml:change - Detail: `{ value: string, date: string, time: string, valueUtc?: string, timezone?: string }`
66
98
  */
67
99
  let DateTimePickerComponent = class DateTimePickerComponent {
68
100
  constructor() {
69
- /** Selected datetime in ISO format (YYYY-MM-DDTHH:mm) */
101
+ /**
102
+ * Selected datetime.
103
+ *
104
+ * - When `timezone` is unset: a naive `YYYY-MM-DDTHH:mm` string.
105
+ * - When `timezone` is set: either a UTC ISO string (`...Z`/`±HH:MM`)
106
+ * or a naive `YYYY-MM-DDTHH:mm` interpreted as wall-clock in `timezone`.
107
+ */
70
108
  this.value = '';
71
109
  /** Placeholder text */
72
110
  this.placeholder = 'Select date and time';
@@ -94,17 +132,82 @@ let DateTimePickerComponent = class DateTimePickerComponent {
94
132
  this.step = 15;
95
133
  /** Use 12-hour format (default: true) */
96
134
  this.twelveHour = true;
97
- /** Internal date portion */
135
+ /**
136
+ * IANA timezone name (e.g. `America/Detroit`). When set, the picker
137
+ * interprets/emits values anchored to this zone. When unset (default),
138
+ * values are naive wall-clock strings.
139
+ */
140
+ this.timezone = '';
141
+ /**
142
+ * How to render the trailing timezone label.
143
+ *
144
+ * - `short` → `EDT`
145
+ * - `long` → `Eastern Daylight Time`
146
+ * - `offset` → `GMT-4`
147
+ * - `none` → don't render a label
148
+ */
149
+ this.timezoneLabel = 'short';
150
+ /**
151
+ * When true AND the browser's UTC offset differs from `timezone` at the
152
+ * current value's instant, render a small subdued line below the input
153
+ * showing the equivalent wall-clock in the viewer's local zone.
154
+ */
155
+ this.viewerHint = false;
156
+ /** Internal date portion (always naive YYYY-MM-DD in the picker's zone) */
98
157
  this.dateValue = '';
99
- /** Internal time portion */
158
+ /** Internal time portion (always naive HH:mm in the picker's zone) */
100
159
  this.timeValue = '';
101
160
  this._listeners = [];
161
+ this._lastSyncedValue = '';
102
162
  }
103
163
  get use12Hour() {
104
164
  return this.twelveHour;
105
165
  }
106
166
  get displayValue() {
107
- return formatDateTimeDisplay(this.value, this.use12Hour);
167
+ return formatDateTimeDisplay(this.naiveValue, this.use12Hour);
168
+ }
169
+ /** The naive wall-clock string the user sees, regardless of input format. */
170
+ get naiveValue() {
171
+ if (this.dateValue && this.timeValue)
172
+ return `${this.dateValue}T${this.timeValue}`;
173
+ if (this.dateValue)
174
+ return this.dateValue;
175
+ if (this.timeValue)
176
+ return this.timeValue;
177
+ return '';
178
+ }
179
+ /** Derived UTC ISO for the current naive value. Empty string when no timezone. */
180
+ get valueUtc() {
181
+ if (!this.timezone)
182
+ return '';
183
+ const naive = this.naiveValue;
184
+ if (!naive || !naive.includes('T'))
185
+ return '';
186
+ return naiveToUtcIso(naive, this.timezone);
187
+ }
188
+ /** Trailing timezone label text (e.g. `EDT`). Empty when no timezone or label='none'. */
189
+ get tzLabelText() {
190
+ if (!this.timezone || this.timezoneLabel === 'none')
191
+ return '';
192
+ const instant = this.currentInstant() ?? new Date();
193
+ return formatTimezoneLabel(instant, this.timezone, this.timezoneLabel);
194
+ }
195
+ /** Whether the viewer-hint line should be shown right now. */
196
+ get showViewerHint() {
197
+ if (!this.viewerHint || !this.timezone)
198
+ return false;
199
+ const instant = this.currentInstant();
200
+ if (!instant)
201
+ return false;
202
+ return viewerOffsetDiffersAt(instant, this.timezone);
203
+ }
204
+ /** Pre-rendered viewer hint text (e.g. `(6:00 AM PDT your time)`). */
205
+ get viewerHintText() {
206
+ const instant = this.currentInstant();
207
+ if (!instant)
208
+ return '';
209
+ const { wallClock, label } = formatViewerHint(instant, 'short');
210
+ return label ? `(${wallClock} ${label} your time)` : `(${wallClock} your time)`;
108
211
  }
109
212
  onCreate() {
110
213
  this.syncFromValue();
@@ -115,10 +218,44 @@ let DateTimePickerComponent = class DateTimePickerComponent {
115
218
  cleanup();
116
219
  this._listeners = [];
117
220
  }
221
+ onPropertyChange(name, _oldVal, _newVal) {
222
+ if (name === 'value' || name === 'timezone') {
223
+ // Re-derive internal date/time when the inbound value (or its
224
+ // anchor zone) changes from outside the component.
225
+ queueMicrotask(() => this.syncFromValue());
226
+ }
227
+ }
228
+ currentInstant() {
229
+ if (!this.timezone)
230
+ return null;
231
+ const utcIso = this.valueUtc;
232
+ if (!utcIso)
233
+ return null;
234
+ const d = new Date(utcIso);
235
+ return Number.isNaN(d.getTime()) ? null : d;
236
+ }
118
237
  syncFromValue() {
119
- if (!this.value)
238
+ const incoming = this.value ?? '';
239
+ if (incoming === this._lastSyncedValue)
240
+ return;
241
+ this._lastSyncedValue = incoming;
242
+ if (!incoming) {
243
+ this.dateValue = '';
244
+ this.timeValue = '';
120
245
  return;
121
- const [datePart, timePart] = this.value.split('T');
246
+ }
247
+ // With a timezone AND a UTC ISO input, project onto the zone's wall-clock.
248
+ if (this.timezone && isUtcIsoString(incoming)) {
249
+ const naive = utcToNaive(incoming, this.timezone);
250
+ const [datePart, timePart] = naive.split('T');
251
+ if (datePart)
252
+ this.dateValue = datePart;
253
+ if (timePart)
254
+ this.timeValue = timePart;
255
+ return;
256
+ }
257
+ // Otherwise treat incoming as naive (zoned or unzoned — both render the same).
258
+ const [datePart, timePart] = incoming.split('T');
122
259
  if (datePart)
123
260
  this.dateValue = datePart;
124
261
  if (timePart)
@@ -197,23 +334,23 @@ let DateTimePickerComponent = class DateTimePickerComponent {
197
334
  }
198
335
  }
199
336
  emitChange() {
200
- if (this.dateValue && this.timeValue) {
201
- this.value = `${this.dateValue}T${this.timeValue}`;
202
- }
203
- else if (this.dateValue) {
204
- this.value = this.dateValue;
205
- }
206
- else if (this.timeValue) {
207
- this.value = this.timeValue;
337
+ this.value = this.naiveValue;
338
+ this._lastSyncedValue = this.value;
339
+ const detail = {
340
+ value: this.value,
341
+ date: this.dateValue,
342
+ time: this.timeValue
343
+ };
344
+ if (this.timezone) {
345
+ detail.timezone = this.timezone;
346
+ const utc = this.valueUtc;
347
+ if (utc)
348
+ detail.valueUtc = utc;
208
349
  }
209
350
  this.elementRef.dispatchEvent(new CustomEvent('ml:change', {
210
351
  bubbles: true,
211
352
  composed: true,
212
- detail: {
213
- value: this.value,
214
- date: this.dateValue,
215
- time: this.timeValue
216
- }
353
+ detail
217
354
  }));
218
355
  }
219
356
  };
@@ -222,7 +359,7 @@ DateTimePickerComponent = __decorate([
222
359
  selector: 'ml-date-time-picker',
223
360
  template: dateTimePickerTemplate,
224
361
  styles: dateTimePickerStyles,
225
- attributes: ['value', 'placeholder', 'label', 'hint', 'error', 'size', 'disabled', 'required', 'min-date', 'max-date', 'min-time', 'max-time', 'step', 'twelve-hour']
362
+ attributes: ['value', 'placeholder', 'label', 'hint', 'error', 'size', 'disabled', 'required', 'min-date', 'max-date', 'min-time', 'max-time', 'step', 'twelve-hour', 'timezone', 'timezone-label', 'viewer-hint']
226
363
  })
227
364
  ], DateTimePickerComponent);
228
365
  export { DateTimePickerComponent };
@@ -1 +1 @@
1
- {"version":3,"file":"date-time-picker.styles.d.ts","sourceRoot":"","sources":["../../../../src/components/forms/date-time-picker/date-time-picker.styles.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,oBAAoB,iDAgIhC,CAAC"}
1
+ {"version":3,"file":"date-time-picker.styles.d.ts","sourceRoot":"","sources":["../../../../src/components/forms/date-time-picker/date-time-picker.styles.ts"],"names":[],"mappings":"AAEA,eAAO,MAAM,oBAAoB,iDAgKhC,CAAC"}
@@ -39,6 +39,17 @@ export const dateTimePickerStyles = () => css `
39
39
  --ml-date-time-picker-hint-color: var(--ml-color-text-muted);
40
40
  --ml-date-time-picker-hint-font-size: var(--ml-text-sm);
41
41
 
42
+ /* --- Timezone label --- */
43
+ --ml-date-time-picker-tz-label-color: var(--ml-color-text-muted);
44
+ --ml-date-time-picker-tz-label-font-size: var(--ml-text-sm);
45
+ --ml-date-time-picker-tz-label-font-weight: var(--ml-font-medium);
46
+ --ml-date-time-picker-tz-label-padding-x: var(--ml-space-2-5);
47
+
48
+ /* --- Viewer hint (subdued line below input) --- */
49
+ --ml-date-time-picker-viewer-hint-color: var(--ml-color-text-muted);
50
+ --ml-date-time-picker-viewer-hint-font-size: var(--ml-text-xs);
51
+ --ml-date-time-picker-viewer-hint-margin-top: var(--ml-space-1);
52
+
42
53
  /* --- Transition --- */
43
54
  --ml-date-time-picker-transition-duration: var(--ml-duration-150);
44
55
  --ml-date-time-picker-transition-easing: var(--ml-ease-in-out);
@@ -104,6 +115,27 @@ export const dateTimePickerStyles = () => css `
104
115
  flex-shrink: 0;
105
116
  }
106
117
 
118
+ /* Timezone label (e.g. EDT) trailing the inputs */
119
+ .ml-date-time-picker__tz-label {
120
+ display: inline-flex;
121
+ align-items: center;
122
+ padding: 0 var(--ml-date-time-picker-tz-label-padding-x);
123
+ font-size: var(--ml-date-time-picker-tz-label-font-size);
124
+ font-weight: var(--ml-date-time-picker-tz-label-font-weight);
125
+ color: var(--ml-date-time-picker-tz-label-color);
126
+ flex-shrink: 0;
127
+ user-select: none;
128
+ }
129
+
130
+ /* Viewer-hint line: shows the same instant in the viewer's local zone */
131
+ .ml-date-time-picker__viewer-hint {
132
+ display: block;
133
+ margin-top: var(--ml-date-time-picker-viewer-hint-margin-top);
134
+ font-size: var(--ml-date-time-picker-viewer-hint-font-size);
135
+ color: var(--ml-date-time-picker-viewer-hint-color);
136
+ line-height: var(--ml-date-time-picker-label-line-height);
137
+ }
138
+
107
139
  /* Disabled */
108
140
  .ml-date-time-picker--disabled .ml-date-time-picker__row {
109
141
  opacity: var(--ml-date-time-picker-disabled-opacity);
@@ -1 +1 @@
1
- {"version":3,"file":"date-time-picker.template.d.ts","sourceRoot":"","sources":["../../../../src/components/forms/date-time-picker/date-time-picker.template.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,iCAAiC,CAAC;AAE/E,wBAAgB,sBAAsB,CAAC,CAAC,EAAE,uBAAuB,6CA+ChE"}
1
+ {"version":3,"file":"date-time-picker.template.d.ts","sourceRoot":"","sources":["../../../../src/components/forms/date-time-picker/date-time-picker.template.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,iCAAiC,CAAC;AAE/E,wBAAgB,sBAAsB,CAAC,CAAC,EAAE,uBAAuB,6CAuDhE"}
@@ -37,8 +37,16 @@ export function dateTimePickerTemplate(c) {
37
37
  .twelveHour=${c.use12Hour}
38
38
  placeholder="Select time"
39
39
  ></ml-time-picker>
40
+
41
+ ${when(!!c.tzLabelText, () => html `
42
+ <span class="ml-date-time-picker__tz-label" aria-label="Timezone">${c.tzLabelText}</span>
43
+ `)}
40
44
  </div>
41
45
 
46
+ ${when(c.showViewerHint, () => html `
47
+ <span class="ml-date-time-picker__viewer-hint">${c.viewerHintText}</span>
48
+ `)}
49
+
42
50
  ${when(!!c.error, () => html `<span class="ml-date-time-picker__error">${c.error}</span>`, () => html `${when(!!c.hint, () => html `<span class="ml-date-time-picker__hint">${c.hint}</span>`)}`)}
43
51
  </div>
44
52
  `;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Timezone helpers for ml-date-time-picker.
3
+ *
4
+ * These functions translate between a naive wall-clock string
5
+ * (`YYYY-MM-DDTHH:mm[:ss]`) and a real UTC instant, anchored to an IANA
6
+ * timezone name (e.g. `America/Detroit`). All conversions are done via
7
+ * `Intl.DateTimeFormat` offset reflection — there are no hardcoded zone
8
+ * tables.
9
+ *
10
+ * DST policy:
11
+ * - Spring-forward gap (e.g. 2026-03-08 02:30 in America/Detroit doesn't
12
+ * exist) resolves to the post-jump wall clock, i.e. the input is
13
+ * interpreted one hour forward (03:30 EDT in this example).
14
+ * - Fall-back ambiguity (e.g. 2026-11-01 01:30 happens twice) resolves
15
+ * to the FIRST occurrence (still-DST / EDT). This matches RFC 5545
16
+ * VTIMEZONE behavior and the default in major calendar apps.
17
+ */
18
+ export type TimezoneLabelFormat = 'short' | 'long' | 'offset' | 'none';
19
+ export declare function isUtcIsoString(value: string): boolean;
20
+ export declare function isNaiveDateTime(value: string): boolean;
21
+ /** Offset of `timeZone` relative to UTC at `date`, in milliseconds (positive = ahead of UTC). */
22
+ export declare function getZoneOffsetMs(date: Date, timeZone: string): number;
23
+ /**
24
+ * Convert a naive wall-clock string to a real UTC instant, treating the
25
+ * naive value as a wall clock in `timeZone`.
26
+ *
27
+ * Returns the UTC ISO 8601 string with `Z` suffix.
28
+ */
29
+ export declare function naiveToUtcIso(naive: string, timeZone: string): string;
30
+ /**
31
+ * Convert a UTC instant (Date or ISO string) to a naive wall-clock string
32
+ * in `timeZone`. Output format: `YYYY-MM-DDTHH:mm`.
33
+ */
34
+ export declare function utcToNaive(instant: Date | string, timeZone: string): string;
35
+ /**
36
+ * Format a timezone name/abbreviation/offset for display next to the input.
37
+ *
38
+ * - `short` → `EDT`
39
+ * - `long` → `Eastern Daylight Time`
40
+ * - `offset` → `GMT-4`
41
+ * - `none` → `''`
42
+ */
43
+ export declare function formatTimezoneLabel(instant: Date, timeZone: string, format: TimezoneLabelFormat): string;
44
+ /** Resolves the browser's local IANA timezone, falling back to UTC. */
45
+ export declare function getLocalTimeZone(): string;
46
+ /**
47
+ * True iff `timeZone` produces a different UTC offset from the viewer's
48
+ * local zone at the given instant. Compares offsets, not zone names —
49
+ * `America/Detroit` and `America/New_York` won't trigger this because
50
+ * they share an offset year-round.
51
+ */
52
+ export declare function viewerOffsetDiffersAt(instant: Date, timeZone: string): boolean;
53
+ /**
54
+ * Render the viewer's local wall-clock + tz label for the given UTC instant.
55
+ * Used by the optional `viewer-hint` line.
56
+ */
57
+ export declare function formatViewerHint(instant: Date, format?: TimezoneLabelFormat): {
58
+ wallClock: string;
59
+ label: string;
60
+ };
61
+ //# sourceMappingURL=tz-utils.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tz-utils.d.ts","sourceRoot":"","sources":["../../../../src/components/forms/date-time-picker/tz-utils.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,MAAM,MAAM,mBAAmB,GAAG,OAAO,GAAG,MAAM,GAAG,QAAQ,GAAG,MAAM,CAAC;AAKvE,wBAAgB,cAAc,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAErD;AAED,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAEtD;AAoCD,iGAAiG;AACjG,wBAAgB,eAAe,CAAC,IAAI,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAIpE;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAQrE;AAED;;;GAGG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,IAAI,GAAG,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAM3E;AAED;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CAClC,OAAO,EAAE,IAAI,EACb,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,mBAAmB,GACzB,MAAM,CAWR;AAED,uEAAuE;AACvE,wBAAgB,gBAAgB,IAAI,MAAM,CAMzC;AAED;;;;;GAKG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAI9E;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,IAAI,EAAE,MAAM,GAAE,mBAA6B,GAAG;IACvF,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;CACd,CAYA"}
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Timezone helpers for ml-date-time-picker.
3
+ *
4
+ * These functions translate between a naive wall-clock string
5
+ * (`YYYY-MM-DDTHH:mm[:ss]`) and a real UTC instant, anchored to an IANA
6
+ * timezone name (e.g. `America/Detroit`). All conversions are done via
7
+ * `Intl.DateTimeFormat` offset reflection — there are no hardcoded zone
8
+ * tables.
9
+ *
10
+ * DST policy:
11
+ * - Spring-forward gap (e.g. 2026-03-08 02:30 in America/Detroit doesn't
12
+ * exist) resolves to the post-jump wall clock, i.e. the input is
13
+ * interpreted one hour forward (03:30 EDT in this example).
14
+ * - Fall-back ambiguity (e.g. 2026-11-01 01:30 happens twice) resolves
15
+ * to the FIRST occurrence (still-DST / EDT). This matches RFC 5545
16
+ * VTIMEZONE behavior and the default in major calendar apps.
17
+ */
18
+ const NAIVE_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?$/;
19
+ const UTC_RE = /(?:Z|[+-]\d{2}:?\d{2})$/;
20
+ export function isUtcIsoString(value) {
21
+ return !!value && UTC_RE.test(value);
22
+ }
23
+ export function isNaiveDateTime(value) {
24
+ return !!value && NAIVE_RE.test(value);
25
+ }
26
+ function getZonedParts(date, timeZone) {
27
+ const fmt = new Intl.DateTimeFormat('en-US', {
28
+ timeZone,
29
+ hourCycle: 'h23',
30
+ year: 'numeric',
31
+ month: '2-digit',
32
+ day: '2-digit',
33
+ hour: '2-digit',
34
+ minute: '2-digit',
35
+ second: '2-digit'
36
+ });
37
+ const parts = {};
38
+ for (const p of fmt.formatToParts(date)) {
39
+ if (p.type !== 'literal')
40
+ parts[p.type] = p.value;
41
+ }
42
+ return {
43
+ year: Number(parts.year),
44
+ month: Number(parts.month),
45
+ day: Number(parts.day),
46
+ hour: Number(parts.hour) === 24 ? 0 : Number(parts.hour),
47
+ minute: Number(parts.minute),
48
+ second: Number(parts.second)
49
+ };
50
+ }
51
+ /** Offset of `timeZone` relative to UTC at `date`, in milliseconds (positive = ahead of UTC). */
52
+ export function getZoneOffsetMs(date, timeZone) {
53
+ const p = getZonedParts(date, timeZone);
54
+ const asUtc = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second);
55
+ return asUtc - date.getTime();
56
+ }
57
+ /**
58
+ * Convert a naive wall-clock string to a real UTC instant, treating the
59
+ * naive value as a wall clock in `timeZone`.
60
+ *
61
+ * Returns the UTC ISO 8601 string with `Z` suffix.
62
+ */
63
+ export function naiveToUtcIso(naive, timeZone) {
64
+ if (!naive)
65
+ return '';
66
+ const padded = naive.length === 16 ? `${naive}:00` : naive;
67
+ const naiveAsUtc = new Date(`${padded}Z`);
68
+ if (Number.isNaN(naiveAsUtc.getTime()))
69
+ return '';
70
+ const offset = getZoneOffsetMs(naiveAsUtc, timeZone);
71
+ const real = new Date(naiveAsUtc.getTime() - offset);
72
+ return real.toISOString();
73
+ }
74
+ /**
75
+ * Convert a UTC instant (Date or ISO string) to a naive wall-clock string
76
+ * in `timeZone`. Output format: `YYYY-MM-DDTHH:mm`.
77
+ */
78
+ export function utcToNaive(instant, timeZone) {
79
+ const date = typeof instant === 'string' ? new Date(instant) : instant;
80
+ if (Number.isNaN(date.getTime()))
81
+ return '';
82
+ const p = getZonedParts(date, timeZone);
83
+ const pad = (n) => String(n).padStart(2, '0');
84
+ return `${p.year}-${pad(p.month)}-${pad(p.day)}T${pad(p.hour)}:${pad(p.minute)}`;
85
+ }
86
+ /**
87
+ * Format a timezone name/abbreviation/offset for display next to the input.
88
+ *
89
+ * - `short` → `EDT`
90
+ * - `long` → `Eastern Daylight Time`
91
+ * - `offset` → `GMT-4`
92
+ * - `none` → `''`
93
+ */
94
+ export function formatTimezoneLabel(instant, timeZone, format) {
95
+ if (format === 'none' || !timeZone)
96
+ return '';
97
+ const timeZoneName = format === 'long' ? 'long' : format === 'offset' ? 'shortOffset' : 'short';
98
+ try {
99
+ const fmt = new Intl.DateTimeFormat('en-US', { timeZone, timeZoneName });
100
+ const part = fmt.formatToParts(instant).find((p) => p.type === 'timeZoneName');
101
+ return part?.value ?? '';
102
+ }
103
+ catch {
104
+ return '';
105
+ }
106
+ }
107
+ /** Resolves the browser's local IANA timezone, falling back to UTC. */
108
+ export function getLocalTimeZone() {
109
+ try {
110
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
111
+ }
112
+ catch {
113
+ return 'UTC';
114
+ }
115
+ }
116
+ /**
117
+ * True iff `timeZone` produces a different UTC offset from the viewer's
118
+ * local zone at the given instant. Compares offsets, not zone names —
119
+ * `America/Detroit` and `America/New_York` won't trigger this because
120
+ * they share an offset year-round.
121
+ */
122
+ export function viewerOffsetDiffersAt(instant, timeZone) {
123
+ const local = getLocalTimeZone();
124
+ if (!timeZone || local === timeZone)
125
+ return false;
126
+ return getZoneOffsetMs(instant, local) !== getZoneOffsetMs(instant, timeZone);
127
+ }
128
+ /**
129
+ * Render the viewer's local wall-clock + tz label for the given UTC instant.
130
+ * Used by the optional `viewer-hint` line.
131
+ */
132
+ export function formatViewerHint(instant, format = 'short') {
133
+ const local = getLocalTimeZone();
134
+ const naive = utcToNaive(instant, local);
135
+ const [, time] = naive.split('T');
136
+ const [hStr, mStr] = (time ?? '').split(':');
137
+ const h24 = Number(hStr);
138
+ const period = h24 >= 12 ? 'PM' : 'AM';
139
+ let h12 = h24 % 12;
140
+ if (h12 === 0)
141
+ h12 = 12;
142
+ const wallClock = `${h12}:${mStr} ${period}`;
143
+ const label = formatTimezoneLabel(instant, local, format);
144
+ return { wallClock, label };
145
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@melodicdev/components",
3
- "version": "1.6.2",
3
+ "version": "1.6.3",
4
4
  "description": "Themeable UI component library built on the Melodic Framework",
5
5
  "license": "MIT",
6
6
  "author": "Melodic Development",