@kubex/zinc 1.1.91 → 1.1.94

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.
Files changed (30) hide show
  1. package/dist/custom-elements.json +1738 -110
  2. package/dist/vscode.html-custom-data.json +136 -4
  3. package/dist/web-types.json +307 -4
  4. package/dist/zn.d.ts +298 -2
  5. package/dist/zn.min.js +708 -516
  6. package/docs/pages/components/page-builder.md +22 -0
  7. package/docs/pages/components/schedule-builder.md +345 -0
  8. package/package.json +1 -1
  9. package/src/components/alert/alert.scss +9 -13
  10. package/src/components/chip/chip.scss +1 -1
  11. package/src/components/content-block/content-block.component.ts +3 -2
  12. package/src/components/icon-picker/icon-picker.component.ts +1 -1
  13. package/src/components/linked-select/linked-select.component.ts +22 -5
  14. package/src/components/page/page.scss +13 -7
  15. package/src/components/page-builder/page-builder.component.ts +52 -7
  16. package/src/components/page-builder/page-builder.scss +166 -9
  17. package/src/components/page-builder/page-builder.test.ts +134 -0
  18. package/src/components/page-builder/page.types.ts +23 -3
  19. package/src/components/page-nav/page-nav.scss +9 -1
  20. package/src/components/panel/panel.component.ts +5 -1
  21. package/src/components/priority-list/priority-list.component.ts +1 -0
  22. package/src/components/priority-list/priority-list.scss +2 -1
  23. package/src/components/schedule-builder/index.ts +12 -0
  24. package/src/components/schedule-builder/schedule-builder.component.ts +1543 -0
  25. package/src/components/schedule-builder/schedule-builder.scss +448 -0
  26. package/src/components/schedule-builder/schedule-builder.test.ts +344 -0
  27. package/src/components/toggle/toggle.component.ts +2 -1
  28. package/src/zinc.ts +1 -0
  29. package/docs/superpowers/plans/2026-08-03-theme-editor.md +0 -1536
  30. package/docs/superpowers/specs/2026-08-03-theme-editor-design.md +0 -327
@@ -0,0 +1,1543 @@
1
+ import {classMap} from 'lit/directives/class-map.js';
2
+ import {type CSSResultGroup, html, nothing, unsafeCSS} from 'lit';
3
+ import {defaultValue} from '../../internal/default-value';
4
+ import {FormControlController, validValidityState} from '../../internal/form';
5
+ import {HasSlotController} from '../../internal/slot';
6
+ import {LocalizeController} from '../../utilities/localize';
7
+ import {property, query, state} from 'lit/decorators.js';
8
+ import {styleMap} from 'lit/directives/style-map.js';
9
+ import {watch} from '../../internal/watch';
10
+ import ZincElement from '../../internal/zinc-element';
11
+ import ZnIcon from '../icon';
12
+ import ZnInput from '../input';
13
+ import ZnOption from '../option';
14
+ import ZnSelect from '../select';
15
+ import type {ZincFormControl} from '../../internal/zinc-element';
16
+
17
+ import styles from './schedule-builder.scss';
18
+
19
+ /** The seven weekday keys used throughout the schedule. */
20
+ export type ScheduleDay = 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat' | 'sun';
21
+
22
+ /** A single opening period within a day. Times are `HH:MM` in the schedule's own timezone. */
23
+ export interface ScheduleRange {
24
+ start: string;
25
+ end: string;
26
+ }
27
+
28
+ /**
29
+ * A dated deviation from the weekly pattern. Exceptions are never edited by the builder, they only
30
+ * annotate it — the surrounding application owns them.
31
+ */
32
+ export interface ScheduleException {
33
+ id?: string;
34
+ /** Human readable name, e.g. `Christmas Eve — early close`. */
35
+ label?: string;
36
+ /** A single calendar date (`YYYY-MM-DD`). */
37
+ date?: string;
38
+ /** Inclusive start of a multi-day exception (`YYYY-MM-DD`). */
39
+ from?: string;
40
+ /** Inclusive end of a multi-day exception (`YYYY-MM-DD`). */
41
+ to?: string;
42
+ /** Weekdays the exception applies to. Defaults to every weekday inside the date window. */
43
+ days?: ScheduleDay[];
44
+ /** When true the affected days close outright and `ranges` is ignored. */
45
+ closed?: boolean;
46
+ /** Replacement opening hours for the affected days. */
47
+ ranges?: ScheduleRange[];
48
+ }
49
+
50
+ export type ScheduleDayMap = Record<ScheduleDay, ScheduleRange[]>;
51
+
52
+ /** The shape serialised into the form value. */
53
+ export interface ScheduleValue {
54
+ timezone?: string;
55
+ days: ScheduleDayMap;
56
+ exceptions: ScheduleException[];
57
+ }
58
+
59
+ export type ScheduleView = 'calendar' | 'form';
60
+
61
+ const DAY_KEYS: ScheduleDay[] = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
62
+
63
+ const DAY_LABELS: Record<ScheduleDay, { short: string; long: string }> = {
64
+ mon: {short: 'Mon', long: 'Monday'},
65
+ tue: {short: 'Tue', long: 'Tuesday'},
66
+ wed: {short: 'Wed', long: 'Wednesday'},
67
+ thu: {short: 'Thu', long: 'Thursday'},
68
+ fri: {short: 'Fri', long: 'Friday'},
69
+ sat: {short: 'Sat', long: 'Saturday'},
70
+ sun: {short: 'Sun', long: 'Sunday'}
71
+ };
72
+
73
+ const MINUTES_IN_DAY = 24 * 60;
74
+
75
+ function emptyDays(): ScheduleDayMap {
76
+ return {mon: [], tue: [], wed: [], thu: [], fri: [], sat: [], sun: []};
77
+ }
78
+
79
+ /** Parses `HH:MM`, `H:MM` or `HH:MM:SS` into minutes past midnight. `24:00` is accepted as end of day. */
80
+ function parseTime(value: unknown): number | null {
81
+ if (typeof value === 'number' && Number.isFinite(value)) {
82
+ return Math.min(Math.max(Math.round(value), 0), MINUTES_IN_DAY);
83
+ }
84
+
85
+ if (typeof value !== 'string') return null;
86
+
87
+ const match = /^(\d{1,2}):(\d{2})(?::\d{2})?$/.exec(value.trim());
88
+ if (!match) return null;
89
+
90
+ const hours = Number(match[1]);
91
+ const minutes = Number(match[2]);
92
+ if (hours > 24 || minutes > 59) return null;
93
+
94
+ const total = hours * 60 + minutes;
95
+ return total > MINUTES_IN_DAY ? null : total;
96
+ }
97
+
98
+ function formatMinutes(minutes: number): string {
99
+ const clamped = Math.min(Math.max(Math.round(minutes), 0), MINUTES_IN_DAY);
100
+ const hours = Math.floor(clamped / 60);
101
+ const mins = clamped % 60;
102
+ return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`;
103
+ }
104
+
105
+ /** Sorts, drops zero-length/invalid periods and merges overlapping or touching ones. */
106
+ function normaliseRanges(ranges: ScheduleRange[]): ScheduleRange[] {
107
+ const spans: [number, number][] = [];
108
+
109
+ ranges.forEach(range => {
110
+ const start = parseTime(range?.start);
111
+ const end = parseTime(range?.end);
112
+ if (start === null || end === null || end <= start) return;
113
+ spans.push([start, end]);
114
+ });
115
+
116
+ spans.sort((a, b) => a[0] - b[0]);
117
+
118
+ const merged: [number, number][] = [];
119
+ spans.forEach(span => {
120
+ const last = merged[merged.length - 1];
121
+ if (last && span[0] <= last[1]) {
122
+ last[1] = Math.max(last[1], span[1]);
123
+ return;
124
+ }
125
+ merged.push([span[0], span[1]]);
126
+ });
127
+
128
+ return merged.map(([start, end]) => ({start: formatMinutes(start), end: formatMinutes(end)}));
129
+ }
130
+
131
+ /** Accepts `{start, end}` objects as well as the `"08:00-18:00"` shorthand. */
132
+ function coerceRanges(input: unknown): ScheduleRange[] {
133
+ if (!Array.isArray(input)) return [];
134
+
135
+ const ranges: ScheduleRange[] = [];
136
+ input.forEach(entry => {
137
+ if (typeof entry === 'string') {
138
+ const [start, end] = entry.split(/\s*[-–—]\s*/);
139
+ if (start && end) ranges.push({start, end});
140
+ return;
141
+ }
142
+
143
+ if (entry && typeof entry === 'object') {
144
+ const range = entry as Partial<ScheduleRange>;
145
+ if (range.start && range.end) ranges.push({start: String(range.start), end: String(range.end)});
146
+ }
147
+ });
148
+
149
+ return normaliseRanges(ranges);
150
+ }
151
+
152
+ function unionSpan(ranges: ScheduleRange[], start: number, end: number): ScheduleRange[] {
153
+ return normaliseRanges([...ranges, {start: formatMinutes(start), end: formatMinutes(end)}]);
154
+ }
155
+
156
+ function subtractSpan(ranges: ScheduleRange[], start: number, end: number): ScheduleRange[] {
157
+ const remaining: ScheduleRange[] = [];
158
+
159
+ ranges.forEach(range => {
160
+ const rangeStart = parseTime(range.start);
161
+ const rangeEnd = parseTime(range.end);
162
+ if (rangeStart === null || rangeEnd === null) return;
163
+
164
+ if (rangeEnd <= start || rangeStart >= end) {
165
+ remaining.push(range);
166
+ return;
167
+ }
168
+
169
+ if (rangeStart < start) remaining.push({start: formatMinutes(rangeStart), end: formatMinutes(start)});
170
+ if (rangeEnd > end) remaining.push({start: formatMinutes(end), end: formatMinutes(rangeEnd)});
171
+ });
172
+
173
+ return normaliseRanges(remaining);
174
+ }
175
+
176
+ function totalMinutes(ranges: ScheduleRange[]): number {
177
+ return ranges.reduce((total, range) => {
178
+ const start = parseTime(range.start);
179
+ const end = parseTime(range.end);
180
+ return start === null || end === null ? total : total + (end - start);
181
+ }, 0);
182
+ }
183
+
184
+ function parseDate(value: string | undefined): Date | null {
185
+ if (!value) return null;
186
+
187
+ const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(value.trim());
188
+ if (!match) return null;
189
+
190
+ const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
191
+ return Number.isNaN(date.getTime()) ? null : date;
192
+ }
193
+
194
+ function exceptionStart(exception: ScheduleException): Date | null {
195
+ return parseDate(exception.date ?? exception.from);
196
+ }
197
+
198
+ function exceptionEnd(exception: ScheduleException): Date | null {
199
+ return parseDate(exception.to ?? exception.date ?? exception.from);
200
+ }
201
+
202
+ /**
203
+ * The weekdays an exception changes. The builder edits a repeating weekly pattern, so only
204
+ * exceptions that describe a change to that pattern are drawn against it: ones that name their
205
+ * `days`, and ones whose date window is at least a full week. A one-off date is a single occurrence,
206
+ * not a pattern, so it rides along in the value but is never painted onto the week.
207
+ */
208
+ function exceptionWeekdays(exception: ScheduleException): ScheduleDay[] {
209
+ if (exception.days?.length) {
210
+ return exception.days.filter(day => DAY_KEYS.includes(day));
211
+ }
212
+
213
+ const start = exceptionStart(exception);
214
+ const end = exceptionEnd(exception);
215
+ if (!start || !end) return [];
216
+
217
+ // Compared as UTC midnights so daylight saving shifts can't skew the day count.
218
+ const startDay = Date.UTC(start.getFullYear(), start.getMonth(), start.getDate());
219
+ const endDay = Date.UTC(end.getFullYear(), end.getMonth(), end.getDate());
220
+ const span = Math.round((endDay - startDay) / 86400000);
221
+
222
+ return span >= DAY_KEYS.length - 1 ? [...DAY_KEYS] : [];
223
+ }
224
+
225
+ /**
226
+ * The hours an exception leaves open. `null` means the exception carries no hours of its own and is
227
+ * purely informational, so the weekly pattern stands.
228
+ */
229
+ function exceptionRanges(exception: ScheduleException): ScheduleRange[] | null {
230
+ if (exception.closed) return [];
231
+ if (!exception.ranges) return null;
232
+ return coerceRanges(exception.ranges);
233
+ }
234
+
235
+ /**
236
+ * A half-open `[start, end)` span of week minutes, where 0 is Monday 00:00 and 10080 is the end of
237
+ * Sunday. Timezone conversion is a rotation of the whole week, so spans — not per-day ranges — are
238
+ * the representation everything is converted in.
239
+ */
240
+ type WeekSpan = [number, number];
241
+
242
+ const WEEK_MINUTES = 7 * MINUTES_IN_DAY;
243
+
244
+ function normaliseSpans(spans: WeekSpan[]): WeekSpan[] {
245
+ const sorted = spans.filter(([start, end]) => end > start).sort((a, b) => a[0] - b[0]);
246
+ const merged: WeekSpan[] = [];
247
+
248
+ sorted.forEach(span => {
249
+ const last = merged[merged.length - 1];
250
+ if (last && span[0] <= last[1]) {
251
+ last[1] = Math.max(last[1], span[1]);
252
+ return;
253
+ }
254
+ merged.push([span[0], span[1]]);
255
+ });
256
+
257
+ return merged;
258
+ }
259
+
260
+ function daysToSpans(days: ScheduleDayMap): WeekSpan[] {
261
+ const spans: WeekSpan[] = [];
262
+
263
+ DAY_KEYS.forEach((day, index) => {
264
+ const offset = index * MINUTES_IN_DAY;
265
+
266
+ days[day].forEach(range => {
267
+ const start = parseTime(range.start);
268
+ const end = parseTime(range.end);
269
+ if (start === null || end === null || end <= start) return;
270
+ spans.push([offset + start, offset + end]);
271
+ });
272
+ });
273
+
274
+ return normaliseSpans(spans);
275
+ }
276
+
277
+ function spansToDays(spans: WeekSpan[]): ScheduleDayMap {
278
+ const days = emptyDays();
279
+
280
+ normaliseSpans(spans).forEach(([start, end]) => {
281
+ let cursor = start;
282
+
283
+ // A span can straddle midnight, so slice it at every day boundary it crosses.
284
+ while (cursor < end) {
285
+ const index = Math.min(Math.floor(cursor / MINUTES_IN_DAY), DAY_KEYS.length - 1);
286
+ const offset = index * MINUTES_IN_DAY;
287
+ const sliceEnd = Math.min(end, offset + MINUTES_IN_DAY);
288
+
289
+ days[DAY_KEYS[index]].push({
290
+ start: formatMinutes(cursor - offset),
291
+ end: formatMinutes(sliceEnd - offset)
292
+ });
293
+
294
+ cursor = sliceEnd;
295
+ }
296
+ });
297
+
298
+ DAY_KEYS.forEach(day => {
299
+ days[day] = normaliseRanges(days[day]);
300
+ });
301
+
302
+ return days;
303
+ }
304
+
305
+ /** Rotates spans around the week, splitting any that wrap past Sunday midnight. */
306
+ function shiftSpans(spans: WeekSpan[], delta: number): WeekSpan[] {
307
+ if (!delta) return spans.map(span => [span[0], span[1]] as WeekSpan);
308
+
309
+ const shifted: WeekSpan[] = [];
310
+
311
+ spans.forEach(([start, end]) => {
312
+ const length = Math.min(end - start, WEEK_MINUTES);
313
+ const from = (((start + delta) % WEEK_MINUTES) + WEEK_MINUTES) % WEEK_MINUTES;
314
+ const to = from + length;
315
+
316
+ if (to <= WEEK_MINUTES) {
317
+ shifted.push([from, to]);
318
+ return;
319
+ }
320
+
321
+ shifted.push([from, WEEK_MINUTES]);
322
+ shifted.push([0, to - WEEK_MINUTES]);
323
+ });
324
+
325
+ return normaliseSpans(shifted);
326
+ }
327
+
328
+ function shiftDays(days: ScheduleDayMap, delta: number): ScheduleDayMap {
329
+ return delta ? spansToDays(shiftSpans(daysToSpans(days), delta)) : days;
330
+ }
331
+
332
+ function subtractSpans(spans: WeekSpan[], remove: WeekSpan[]): WeekSpan[] {
333
+ let remaining = normaliseSpans(spans);
334
+
335
+ normaliseSpans(remove).forEach(([start, end]) => {
336
+ const next: WeekSpan[] = [];
337
+
338
+ remaining.forEach(([spanStart, spanEnd]) => {
339
+ if (spanEnd <= start || spanStart >= end) {
340
+ next.push([spanStart, spanEnd]);
341
+ return;
342
+ }
343
+
344
+ if (spanStart < start) next.push([spanStart, start]);
345
+ if (spanEnd > end) next.push([end, spanEnd]);
346
+ });
347
+
348
+ remaining = next;
349
+ });
350
+
351
+ return remaining;
352
+ }
353
+
354
+ function spansCover(spans: WeekSpan[], minute: number): boolean {
355
+ return spans.some(([start, end]) => minute >= start && minute < end);
356
+ }
357
+
358
+ /**
359
+ * The offset of a timezone from UTC in minutes at a given moment, positive east of Greenwich.
360
+ * Returns 0 for an empty or unrecognised zone so a typo degrades to "no conversion".
361
+ */
362
+ function timezoneOffset(timeZone: string, reference: Date): number {
363
+ if (!timeZone) return 0;
364
+
365
+ try {
366
+ const parts = new Intl.DateTimeFormat('en-US', {
367
+ timeZone,
368
+ hour12: false,
369
+ year: 'numeric',
370
+ month: '2-digit',
371
+ day: '2-digit',
372
+ hour: '2-digit',
373
+ minute: '2-digit',
374
+ second: '2-digit'
375
+ }).formatToParts(reference);
376
+
377
+ const read = (type: Intl.DateTimeFormatPartTypes) => Number(parts.find(part => part.type === type)?.value);
378
+
379
+ // `hour12: false` reports midnight as hour 24 in some engines.
380
+ const hour = read('hour') % 24;
381
+ const asUtc = Date.UTC(read('year'), read('month') - 1, read('day'), hour, read('minute'), read('second'));
382
+
383
+ return Math.round((asUtc - reference.getTime()) / 60000);
384
+ } catch {
385
+ return 0;
386
+ }
387
+ }
388
+
389
+ function formatOffset(minutes: number): string {
390
+ const sign = minutes < 0 ? '-' : '+';
391
+ const absolute = Math.abs(minutes);
392
+ return `UTC${sign}${String(Math.floor(absolute / 60)).padStart(2, '0')}:${String(absolute % 60).padStart(2, '0')}`;
393
+ }
394
+
395
+ /** A zone offered by the picker, optionally under a friendlier name than its IANA one. */
396
+ interface TimezoneOption {
397
+ zone: string;
398
+ label?: string;
399
+ }
400
+
401
+ /** `Europe/London (UTC+01:00)`, or just `UTC` for the zone that needs no explaining. */
402
+ function formatZone(option: TimezoneOption, reference: Date): string {
403
+ if (!option.label && option.zone === 'UTC') return 'UTC';
404
+ const name = option.label ?? option.zone.replace(/_/g, ' ');
405
+ return `${name} (${formatOffset(timezoneOffset(option.zone, reference))})`;
406
+ }
407
+
408
+ function localTimezone(): string {
409
+ try {
410
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
411
+ } catch {
412
+ return 'UTC';
413
+ }
414
+ }
415
+
416
+ /** The named sets accepted by `timezones`, alongside explicit IANA names. */
417
+ export type ScheduleTimezoneSet = 'en' | 'offsets' | 'common' | 'all';
418
+
419
+ /**
420
+ * The zones an English-speaking audience picks from, under the names they know them by rather than
421
+ * their IANA ones. Deliberately short: the four US zones, the UK and Australia.
422
+ */
423
+ const EN_TIMEZONES: TimezoneOption[] = [
424
+ {zone: 'America/New_York', label: 'US Eastern'},
425
+ {zone: 'America/Chicago', label: 'US Central'},
426
+ {zone: 'America/Denver', label: 'US Mountain'},
427
+ {zone: 'America/Los_Angeles', label: 'US Pacific'},
428
+ {zone: 'Europe/London', label: 'UK'},
429
+ {zone: 'Australia/Sydney', label: 'Australia'}
430
+ ];
431
+
432
+ /** One zone per UTC offset — enough to read a schedule anywhere, without a long list. */
433
+ const OFFSET_TIMEZONES = [
434
+ 'UTC',
435
+ 'Pacific/Auckland', 'Australia/Sydney', 'Australia/Brisbane', 'Australia/Adelaide', 'Australia/Perth',
436
+ 'Asia/Tokyo', 'Asia/Seoul', 'Asia/Shanghai', 'Asia/Hong_Kong', 'Asia/Singapore', 'Asia/Bangkok',
437
+ 'Asia/Jakarta', 'Asia/Kolkata', 'Asia/Karachi', 'Asia/Dubai', 'Europe/Moscow', 'Africa/Nairobi',
438
+ 'Europe/Istanbul', 'Europe/Athens', 'Africa/Johannesburg', 'Europe/Berlin', 'Europe/Paris',
439
+ 'Europe/Madrid', 'Europe/Amsterdam', 'Europe/Dublin', 'Europe/London', 'Europe/Lisbon',
440
+ 'Atlantic/Reykjavik', 'America/Sao_Paulo', 'America/Argentina/Buenos_Aires', 'America/Halifax',
441
+ 'America/New_York', 'America/Toronto', 'America/Chicago', 'America/Mexico_City', 'America/Denver',
442
+ 'America/Phoenix', 'America/Los_Angeles', 'America/Vancouver', 'America/Anchorage', 'Pacific/Honolulu'
443
+ ];
444
+
445
+ /**
446
+ * The zones people actually name when asked where they are: every offset in use, plus the business
447
+ * and population centres that share one. A superset of `OFFSET_TIMEZONES` in offset coverage, short
448
+ * enough to scan and searchable when it isn't.
449
+ */
450
+ const COMMON_TIMEZONES = [
451
+ // Americas
452
+ 'Pacific/Midway', 'Pacific/Honolulu', 'Pacific/Marquesas', 'America/Anchorage',
453
+ 'America/Los_Angeles', 'America/Vancouver', 'America/Tijuana',
454
+ 'America/Denver', 'America/Phoenix', 'America/Edmonton',
455
+ 'America/Chicago', 'America/Mexico_City', 'America/Winnipeg', 'America/Guatemala',
456
+ 'America/New_York', 'America/Toronto', 'America/Bogota', 'America/Lima', 'America/Panama',
457
+ 'America/Halifax', 'America/Puerto_Rico', 'America/Santiago', 'America/St_Johns',
458
+ 'America/Sao_Paulo', 'America/Argentina/Buenos_Aires', 'America/Montevideo',
459
+ 'America/Noronha', 'America/Nuuk', 'Atlantic/Azores', 'Atlantic/Cape_Verde',
460
+ // Europe and Africa
461
+ 'UTC',
462
+ 'Europe/London', 'Europe/Dublin', 'Europe/Lisbon', 'Atlantic/Reykjavik', 'Africa/Abidjan',
463
+ 'Africa/Casablanca', 'Africa/Lagos', 'Africa/Algiers',
464
+ 'Europe/Paris', 'Europe/Berlin', 'Europe/Madrid', 'Europe/Rome', 'Europe/Amsterdam',
465
+ 'Europe/Brussels', 'Europe/Zurich', 'Europe/Vienna', 'Europe/Prague', 'Europe/Warsaw',
466
+ 'Europe/Stockholm', 'Europe/Oslo', 'Europe/Copenhagen', 'Europe/Budapest',
467
+ 'Europe/Athens', 'Europe/Helsinki', 'Europe/Bucharest', 'Europe/Kyiv',
468
+ 'Africa/Cairo', 'Africa/Johannesburg', 'Asia/Jerusalem',
469
+ 'Europe/Istanbul', 'Europe/Moscow', 'Africa/Nairobi', 'Asia/Riyadh', 'Asia/Baghdad',
470
+ // Middle East and Asia
471
+ 'Asia/Tehran', 'Asia/Dubai', 'Asia/Baku', 'Asia/Tbilisi', 'Asia/Kabul',
472
+ 'Asia/Karachi', 'Asia/Tashkent', 'Asia/Kolkata', 'Asia/Colombo', 'Asia/Kathmandu',
473
+ 'Asia/Dhaka', 'Asia/Almaty', 'Asia/Yangon',
474
+ 'Asia/Bangkok', 'Asia/Jakarta', 'Asia/Ho_Chi_Minh',
475
+ 'Asia/Shanghai', 'Asia/Hong_Kong', 'Asia/Singapore', 'Asia/Kuala_Lumpur', 'Asia/Taipei',
476
+ 'Asia/Manila', 'Asia/Tokyo', 'Asia/Seoul',
477
+ // Oceania
478
+ 'Australia/Perth', 'Australia/Eucla', 'Australia/Darwin', 'Australia/Adelaide',
479
+ 'Australia/Brisbane', 'Australia/Sydney', 'Australia/Melbourne', 'Australia/Hobart',
480
+ 'Pacific/Guam', 'Pacific/Noumea', 'Pacific/Auckland', 'Pacific/Fiji', 'Pacific/Chatham',
481
+ 'Pacific/Tongatapu', 'Pacific/Kiritimati'
482
+ ];
483
+
484
+ function allTimezones(): string[] {
485
+ return typeof Intl.supportedValuesOf === 'function' ? Intl.supportedValuesOf('timeZone') : COMMON_TIMEZONES;
486
+ }
487
+
488
+ /** Expands a named set into its zones; anything else is taken as an IANA name. */
489
+ function resolveTimezoneSet(entry: string): TimezoneOption[] {
490
+ switch (entry.toLowerCase()) {
491
+ case 'en':
492
+ return EN_TIMEZONES;
493
+ case 'offsets':
494
+ return OFFSET_TIMEZONES.map(zone => ({zone}));
495
+ case 'common':
496
+ return COMMON_TIMEZONES.map(zone => ({zone}));
497
+ case 'all':
498
+ return allTimezones().map(zone => ({zone}));
499
+ default:
500
+ return [{zone: entry}];
501
+ }
502
+ }
503
+
504
+ type SlotState = 'closed' | 'open' | 'reduced';
505
+
506
+ /**
507
+ * @summary Builds a weekly opening-hours schedule as a drag-to-paint calendar or a compact list of
508
+ * time ranges, and posts the result as JSON.
509
+ * @documentation https://zinc.style/components/schedule-builder
510
+ * @status experimental
511
+ * @since 1.0
512
+ *
513
+ * @dependency zn-icon
514
+ * @dependency zn-input
515
+ * @dependency zn-option
516
+ * @dependency zn-select
517
+ *
518
+ * @event zn-change - Emitted when the schedule changes.
519
+ *
520
+ * @slot label - The schedule's label. Alternatively, use the `label` attribute.
521
+ * @slot help-text - Text that describes how to use the schedule. Alternatively, use the `help-text` attribute.
522
+ *
523
+ * @csspart form-control - The form control that wraps the builder, label and help text.
524
+ * @csspart base - The component's base wrapper.
525
+ * @csspart toolbar - The row above the builder holding the hint, legend and view toggle.
526
+ * @csspart calendar - The calendar view wrapper.
527
+ * @csspart list - The form (list) view wrapper.
528
+ * @csspart summary - The summary panel beside the calendar.
529
+ *
530
+ * @cssproperty --slot-height - The height of a single time slot in the calendar. Defaults to `18px`.
531
+ * @cssproperty --gutter-width - The width of the calendar's time gutter. Defaults to `64px`.
532
+ * @cssproperty --open-color - The fill used for open hours.
533
+ * @cssproperty --reduced-color - The fill used for hours an exception removes.
534
+ */
535
+ export default class ZnScheduleBuilder extends ZincElement implements ZincFormControl {
536
+ static styles: CSSResultGroup = unsafeCSS(styles);
537
+
538
+ static formAssociated = true;
539
+
540
+ static dependencies = {
541
+ 'zn-icon': ZnIcon,
542
+ 'zn-input': ZnInput,
543
+ 'zn-option': ZnOption,
544
+ 'zn-select': ZnSelect,
545
+ };
546
+
547
+ private readonly formControlController = new FormControlController(this, {
548
+ assumeInteractionOn: ['zn-change']
549
+ });
550
+
551
+ private readonly hasSlotController = new HasSlotController(this, 'help-text', 'label');
552
+ private readonly localize = new LocalizeController(this);
553
+ private readonly internals: ElementInternals | null;
554
+
555
+ @query('.schedule-builder__canvas') private canvas: HTMLElement;
556
+
557
+ @state() private _days: ScheduleDayMap = emptyDays();
558
+ @state() private _exceptions: ScheduleException[] = [];
559
+ @state() private _dragPreview: ScheduleDayMap | null = null;
560
+ @state() private _editing: { day: ScheduleDay; index: number } | null = null;
561
+
562
+ private _dragMode: 'open' | 'close' = 'open';
563
+ private _dragAnchor: { col: number; row: number } | null = null;
564
+ private _dragPointerId: number | null = null;
565
+
566
+ /** The name of the form control, submitted as a name/value pair with form data. */
567
+ @property({reflect: true}) name: string;
568
+
569
+ /** The schedule as a JSON string. This is what gets posted with the form. */
570
+ @property() value: string = '';
571
+
572
+ /** The default value, used when resetting the containing form. */
573
+ @defaultValue() defaultValue: string = '';
574
+
575
+ /** The schedule's label. If you need to display HTML, use the `label` slot instead. */
576
+ @property() label: string = '';
577
+
578
+ /** The schedule's help text. If you need to display HTML, use the `help-text` slot instead. */
579
+ @property({attribute: 'help-text'}) helpText: string = '';
580
+
581
+ /** Which view is showing. */
582
+ @property({reflect: true}) view: ScheduleView = 'calendar';
583
+
584
+ /** The word used for hours the schedule covers, in the legend and in labels. */
585
+ @property({attribute: 'open-label'}) openLabel: string = 'Available';
586
+
587
+ /** The word used for hours the schedule doesn't cover, in the legend and against empty days. */
588
+ @property({attribute: 'closed-label'}) closedLabel: string = 'Closed';
589
+
590
+ /** Hides the calendar/form view toggle. */
591
+ @property({attribute: 'no-toggle', type: Boolean}) noToggle: boolean = false;
592
+
593
+ /** Hides the summary panel beside the calendar. */
594
+ @property({attribute: 'hide-summary', type: Boolean}) hideSummary: boolean = false;
595
+
596
+ /** The first hour shown in the calendar. */
597
+ @property({attribute: 'start-hour', type: Number}) startHour: number = 6;
598
+
599
+ /** The last hour shown in the calendar. */
600
+ @property({attribute: 'end-hour', type: Number}) endHour: number = 22;
601
+
602
+ /** The granularity of the calendar grid and the time inputs, in minutes. */
603
+ @property({type: Number}) interval: number = 30;
604
+
605
+ /** The weekday the week starts on. */
606
+ @property({attribute: 'week-start'}) weekStart: ScheduleDay = 'mon';
607
+
608
+ /** Displays times as 12 or 24 hour. The serialised value is always 24 hour `HH:MM`. */
609
+ @property({attribute: 'time-format'}) timeFormat: '12' | '24' = '24';
610
+
611
+ /**
612
+ * The IANA timezone the hours are shown in. Accepts `auto` for the viewer's own timezone. Defaults
613
+ * to `save-timezone`, so nothing is converted until you ask for it. Changing this only re-labels
614
+ * the same underlying hours; the value never moves.
615
+ */
616
+ @property({attribute: 'display-timezone'}) displayTimezone: string = '';
617
+
618
+ /**
619
+ * The IANA timezone the value is stored in. Defaults to `UTC` as soon as the schedule is
620
+ * timezone-aware (a display timezone is set, or the picker is shown), and to no timezone at all
621
+ * otherwise — in which case the times are stored exactly as they are shown.
622
+ */
623
+ @property({attribute: 'save-timezone'}) saveTimezone: string = '';
624
+
625
+ /** Shows the timezone picker, letting the user read the schedule in any timezone. */
626
+ @property({attribute: 'show-timezone', type: Boolean}) showTimezone: boolean = false;
627
+
628
+ /**
629
+ * The timezones offered by the picker, as IANA names or one of the named sets — `en` (the four US
630
+ * zones, the UK and Australia, under those names), `offsets` (one zone per UTC offset, the
631
+ * default), `common` (every offset plus the world's major centres) or `all` (the complete IANA
632
+ * list). Names and sets can be mixed, e.g. `en Asia/Tokyo`.
633
+ */
634
+ @property({
635
+ attribute: 'timezones',
636
+ converter: {
637
+ fromAttribute: (value: string) => value.split(/[,\s]+/).filter(Boolean),
638
+ toAttribute: (value: string[]) => value.join(' ')
639
+ }
640
+ }) timezones: string[] = [];
641
+
642
+ /**
643
+ * The date (`YYYY-MM-DD`) used to resolve timezone offsets. A weekly pattern has no date of its
644
+ * own, so one has to be picked to know whether daylight saving applies; today is used by default.
645
+ */
646
+ @property({attribute: 'reference-date'}) referenceDate: string = '';
647
+
648
+ /** Disables the schedule. */
649
+ @property({type: Boolean, reflect: true}) disabled: boolean = false;
650
+
651
+ /** Renders the schedule without any editing affordances. */
652
+ @property({type: Boolean, reflect: true}) readonly: boolean = false;
653
+
654
+ /** Makes the schedule a required field, invalid until at least one period is open. */
655
+ @property({type: Boolean, reflect: true}) required: boolean = false;
656
+
657
+ /** The id of the form to associate with, when the control sits outside of it. */
658
+ @property({reflect: true}) form: string;
659
+
660
+ constructor() {
661
+ super();
662
+ this.internals = typeof this.attachInternals === 'function' ? this.attachInternals() : null;
663
+ }
664
+
665
+ /**
666
+ * The schedule as a plain object, with `days` in the save timezone. Assigning to it replaces the
667
+ * whole schedule.
668
+ */
669
+ get schedule(): ScheduleValue {
670
+ return {
671
+ ...(this._saveZone ? {timezone: this._saveZone} : {}),
672
+ days: this._cloneDays(this._days),
673
+ exceptions: this._exceptions
674
+ };
675
+ }
676
+
677
+ set schedule(schedule: ScheduleValue | null | undefined) {
678
+ this._applySchedule(schedule);
679
+ this._commit(false);
680
+ }
681
+
682
+ /** The exceptions annotating the schedule. Also readable from, and written into, the value. */
683
+ get exceptions(): ScheduleException[] {
684
+ return this._exceptions;
685
+ }
686
+
687
+ set exceptions(exceptions: ScheduleException[] | null | undefined) {
688
+ this._exceptions = Array.isArray(exceptions) ? exceptions : [];
689
+ this._commit(false);
690
+ }
691
+
692
+ /** Gets the validity state object. */
693
+ get validity(): ValidityState {
694
+ return this.internals?.validity ?? validValidityState;
695
+ }
696
+
697
+ /** Gets the validation message. */
698
+ get validationMessage(): string {
699
+ return this.internals?.validationMessage ?? '';
700
+ }
701
+
702
+ /** Whether the schedule carries a timezone at all. */
703
+ private get _isZoned(): boolean {
704
+ return Boolean(this.saveTimezone || this.displayTimezone || this.showTimezone);
705
+ }
706
+
707
+ /** The timezone the value is stored in. Empty means the times are stored exactly as shown. */
708
+ private get _saveZone(): string {
709
+ if (this.saveTimezone) return this.saveTimezone;
710
+ return this._isZoned ? 'UTC' : '';
711
+ }
712
+
713
+ /** The timezone the grid and list are drawn in. */
714
+ private get _displayZone(): string {
715
+ if (this.displayTimezone === 'auto') return localTimezone();
716
+ return this.displayTimezone || this._saveZone;
717
+ }
718
+
719
+ /** The moment used to resolve daylight saving for both zones. */
720
+ private get _reference(): Date {
721
+ return parseDate(this.referenceDate) ?? new Date();
722
+ }
723
+
724
+ /** Minutes to add to a stored time to get the time shown. */
725
+ private get _offsetDelta(): number {
726
+ const save = this._saveZone;
727
+ const display = this._displayZone;
728
+ if (!save || !display || save === display) return 0;
729
+
730
+ const reference = this._reference;
731
+ return timezoneOffset(display, reference) - timezoneOffset(save, reference);
732
+ }
733
+
734
+ private get _orderedDays(): ScheduleDay[] {
735
+ const offset = Math.max(DAY_KEYS.indexOf(this.weekStart), 0);
736
+ return [...DAY_KEYS.slice(offset), ...DAY_KEYS.slice(0, offset)];
737
+ }
738
+
739
+ private get _interval(): number {
740
+ const interval = Math.round(this.interval);
741
+ return [5, 10, 15, 20, 30, 60].includes(interval) ? interval : 30;
742
+ }
743
+
744
+ private get _startMinute(): number {
745
+ return Math.min(Math.max(Math.round(this.startHour), 0), 23) * 60;
746
+ }
747
+
748
+ private get _endMinute(): number {
749
+ const end = Math.min(Math.max(Math.round(this.endHour), 1), 24) * 60;
750
+ return end <= this._startMinute ? Math.min(this._startMinute + 60, MINUTES_IN_DAY) : end;
751
+ }
752
+
753
+ private get _slotCount(): number {
754
+ return Math.ceil((this._endMinute - this._startMinute) / this._interval);
755
+ }
756
+
757
+ private get _slotsPerHour(): number {
758
+ return Math.max(Math.round(60 / this._interval), 1);
759
+ }
760
+
761
+ private get _isEditable(): boolean {
762
+ return !this.disabled && !this.readonly;
763
+ }
764
+
765
+ private get _hasHours(): boolean {
766
+ return DAY_KEYS.some(day => this._days[day].length > 0);
767
+ }
768
+
769
+ connectedCallback() {
770
+ super.connectedCallback();
771
+
772
+ if (this.value) {
773
+ this._applySchedule(this.value);
774
+ }
775
+
776
+ this._syncFormValue();
777
+ }
778
+
779
+ firstUpdated() {
780
+ this._commit(false);
781
+ }
782
+
783
+ @watch('value')
784
+ handleValueChange() {
785
+ // Ignore the echo of our own serialisation; anything else is an external assignment.
786
+ if (this.value === this._serialise()) return;
787
+ this._applySchedule(this.value);
788
+ this._syncFormValue();
789
+ }
790
+
791
+ @watch(['required', 'disabled'])
792
+ handleValidationStateChange() {
793
+ this._syncFormValue();
794
+ }
795
+
796
+ /** Checks validity but does not show a validation message. */
797
+ checkValidity(): boolean {
798
+ return this.internals?.checkValidity() ?? true;
799
+ }
800
+
801
+ /** Gets the associated form, if one exists. */
802
+ getForm(): HTMLFormElement | null {
803
+ return this.formControlController.getForm();
804
+ }
805
+
806
+ /** Checks for validity and shows the browser's validation message if the control is invalid. */
807
+ reportValidity(): boolean {
808
+ return this.internals?.reportValidity() ?? true;
809
+ }
810
+
811
+ /** Sets a custom validation message. Pass an empty string to restore validity. */
812
+ setCustomValidity(message: string) {
813
+ this._customValidity = message;
814
+ this._syncFormValue();
815
+ this.formControlController.updateValidity();
816
+ }
817
+
818
+ /** Replaces the hours for a single day, in the save timezone. */
819
+ setDay(day: ScheduleDay, ranges: ScheduleRange[]) {
820
+ if (!DAY_KEYS.includes(day)) return;
821
+ this._days = {...this._days, [day]: coerceRanges(ranges)};
822
+ this._commit();
823
+ }
824
+
825
+ /** Reads the hours for a single day, in the save timezone. */
826
+ getDay(day: ScheduleDay): ScheduleRange[] {
827
+ return this._days[day] ?? [];
828
+ }
829
+
830
+ /** The hours as currently shown, in the display timezone. */
831
+ get displayedDays(): ScheduleDayMap {
832
+ return this._cloneDays(this._storedAsShown);
833
+ }
834
+
835
+ /** Replaces the hours for a single day, given in the display timezone. */
836
+ setDisplayDay(day: ScheduleDay, ranges: ScheduleRange[]) {
837
+ if (!DAY_KEYS.includes(day)) return;
838
+ this._commitShownDays({...this._storedAsShown, [day]: coerceRanges(ranges)});
839
+ }
840
+
841
+ formResetCallback() {
842
+ this._applySchedule(this.defaultValue);
843
+ this._commit(false);
844
+ }
845
+
846
+ formStateRestoreCallback(restoredValue: string) {
847
+ this._applySchedule(restoredValue);
848
+ this._commit(false);
849
+ }
850
+
851
+ private _customValidity: string = '';
852
+
853
+ private _cloneDays(days: ScheduleDayMap): ScheduleDayMap {
854
+ const clone = emptyDays();
855
+ DAY_KEYS.forEach(day => {
856
+ clone[day] = days[day].map(range => ({...range}));
857
+ });
858
+ return clone;
859
+ }
860
+
861
+ private _serialise(): string {
862
+ return JSON.stringify(this.schedule);
863
+ }
864
+
865
+ /** Accepts a JSON string, a full `ScheduleValue`, or a bare day map. */
866
+ private _applySchedule(input: unknown) {
867
+ let parsed: unknown = input;
868
+
869
+ if (typeof input === 'string') {
870
+ const trimmed = input.trim();
871
+ if (!trimmed) {
872
+ this._days = emptyDays();
873
+ this._exceptions = [];
874
+ return;
875
+ }
876
+
877
+ try {
878
+ parsed = JSON.parse(trimmed);
879
+ } catch {
880
+ // Keep whatever we already have rather than wiping a schedule over a typo.
881
+ return;
882
+ }
883
+ }
884
+
885
+ if (!parsed || typeof parsed !== 'object') {
886
+ this._days = emptyDays();
887
+ this._exceptions = [];
888
+ return;
889
+ }
890
+
891
+ const source = parsed as Partial<ScheduleValue> & Partial<ScheduleDayMap>;
892
+ const daySource = (source.days ?? source) as Partial<Record<ScheduleDay, unknown>>;
893
+ const days = emptyDays();
894
+
895
+ DAY_KEYS.forEach(day => {
896
+ days[day] = coerceRanges(daySource?.[day]);
897
+ });
898
+
899
+ const declared = typeof source.timezone === 'string' ? source.timezone : '';
900
+
901
+ if (declared && !this.saveTimezone) {
902
+ // Nothing was configured, so the incoming data decides which timezone we store in.
903
+ this.saveTimezone = declared;
904
+ } else if (declared && declared !== this.saveTimezone) {
905
+ // The data is in a different timezone to the one we store in, so bring it across.
906
+ const reference = this._reference;
907
+ const delta = timezoneOffset(this.saveTimezone, reference) - timezoneOffset(declared, reference);
908
+ this._days = shiftDays(days, delta);
909
+ this._exceptions = Array.isArray(source.exceptions) ? source.exceptions : [];
910
+ return;
911
+ }
912
+
913
+ this._days = days;
914
+ this._exceptions = Array.isArray(source.exceptions) ? source.exceptions : [];
915
+ }
916
+
917
+ private _syncFormValue() {
918
+ if (!this.internals) return;
919
+
920
+ this.internals.setFormValue(this.value);
921
+
922
+ const anchor = this.shadowRoot?.querySelector<HTMLElement>('.schedule-builder') ?? undefined;
923
+
924
+ if (this._customValidity) {
925
+ this.internals.setValidity({customError: true}, this._customValidity, anchor);
926
+ return;
927
+ }
928
+
929
+ if (this.required && !this._hasHours) {
930
+ this.internals.setValidity({valueMissing: true}, 'Please open at least one period.', anchor);
931
+ return;
932
+ }
933
+
934
+ this.internals.setValidity({});
935
+ }
936
+
937
+ private _commit(emit: boolean = true) {
938
+ const serialised = this._serialise();
939
+
940
+ if (serialised !== this.value) {
941
+ this.value = serialised;
942
+ }
943
+
944
+ this._syncFormValue();
945
+ this.formControlController.updateValidity();
946
+
947
+ if (emit) {
948
+ this.emit('zn-change');
949
+ }
950
+ }
951
+
952
+ private _formatTime(time: string): string {
953
+ const minutes = parseTime(time);
954
+ if (minutes === null) return time;
955
+ if (this.timeFormat === '24') return formatMinutes(minutes);
956
+
957
+ const hours = Math.floor(minutes / 60) % 24;
958
+ const mins = minutes % 60;
959
+ const suffix = hours < 12 ? 'am' : 'pm';
960
+ const display = hours % 12 === 0 ? 12 : hours % 12;
961
+
962
+ return mins === 0 ? `${display}${suffix}` : `${display}:${String(mins).padStart(2, '0')}${suffix}`;
963
+ }
964
+
965
+ private _formatRange(range: ScheduleRange): string {
966
+ return `${this._formatTime(range.start)}–${this._formatTime(range.end)}`;
967
+ }
968
+
969
+ private _formatDate(value: string | undefined): string {
970
+ const date = parseDate(value);
971
+ return date ? this.localize.date(date, {day: 'numeric', month: 'short'}) : '';
972
+ }
973
+
974
+ private _summariseDay(day: ScheduleDay, days: ScheduleDayMap): string {
975
+ const ranges = days[day];
976
+ return ranges.length ? ranges.map(range => this._formatRange(range)).join(', ') : this.closedLabel;
977
+ }
978
+
979
+ /** The stored hours rotated into the display timezone. */
980
+ private get _storedAsShown(): ScheduleDayMap {
981
+ return shiftDays(this._days, this._offsetDelta);
982
+ }
983
+
984
+ /** The hours as drawn, which is the drag preview while a drag is in flight. */
985
+ private get _shownDays(): ScheduleDayMap {
986
+ return this._dragPreview ?? this._storedAsShown;
987
+ }
988
+
989
+ /** Stores hours that were edited in display coordinates, rotating them back to the save timezone. */
990
+ private _commitShownDays(days: ScheduleDayMap) {
991
+ this._days = shiftDays(days, -this._offsetDelta);
992
+ this._commit();
993
+ }
994
+
995
+ /** The exceptions that touch a given weekday and actually change its hours. */
996
+ private _exceptionsForDay(day: ScheduleDay): ScheduleException[] {
997
+ return this._exceptions.filter(exception => exceptionWeekdays(exception).includes(day));
998
+ }
999
+
1000
+ /**
1001
+ * The week spans an exception takes away, in display coordinates. Computed as spans rather than
1002
+ * per-day ranges because a timezone rotation can move an exception's hours onto another weekday.
1003
+ */
1004
+ private get _shownReductionSpans(): WeekSpan[] {
1005
+ const spans: WeekSpan[] = [];
1006
+
1007
+ this._exceptions.forEach(exception => {
1008
+ const ranges = exceptionRanges(exception);
1009
+ if (ranges === null) return;
1010
+
1011
+ exceptionWeekdays(exception).forEach(day => {
1012
+ const offset = DAY_KEYS.indexOf(day) * MINUTES_IN_DAY;
1013
+
1014
+ // Whatever the exception doesn't leave open on that weekday is taken away.
1015
+ spans.push(...subtractSpans([[offset, offset + MINUTES_IN_DAY]], ranges.reduce<WeekSpan[]>((open, range) => {
1016
+ const start = parseTime(range.start);
1017
+ const end = parseTime(range.end);
1018
+ if (start !== null && end !== null) open.push([offset + start, offset + end]);
1019
+ return open;
1020
+ }, [])));
1021
+ });
1022
+ });
1023
+
1024
+ return shiftSpans(normaliseSpans(spans), this._offsetDelta);
1025
+ }
1026
+
1027
+ /** The week minute a calendar cell sits on, measured from Monday 00:00. */
1028
+ private _slotMinute(day: ScheduleDay, index: number): number {
1029
+ // The midpoint keeps part-covered slots on the right side of the boundary.
1030
+ return DAY_KEYS.indexOf(day) * MINUTES_IN_DAY
1031
+ + this._startMinute + index * this._interval + this._interval / 2;
1032
+ }
1033
+
1034
+ private _slotState(day: ScheduleDay, index: number, open: WeekSpan[], reductions: WeekSpan[]): SlotState {
1035
+ const minute = this._slotMinute(day, index);
1036
+ if (!spansCover(open, minute)) return 'closed';
1037
+ return spansCover(reductions, minute) ? 'reduced' : 'open';
1038
+ }
1039
+
1040
+ private _pointerPosition(event: PointerEvent): { col: number; row: number } | null {
1041
+ if (!this.canvas) return null;
1042
+
1043
+ const rect = this.canvas.getBoundingClientRect();
1044
+ if (!rect.width || !rect.height) return null;
1045
+
1046
+ const columns = this._orderedDays.length;
1047
+ const col = Math.floor(((event.clientX - rect.left) / rect.width) * columns);
1048
+ const row = Math.floor(((event.clientY - rect.top) / rect.height) * this._slotCount);
1049
+
1050
+ return {
1051
+ col: Math.min(Math.max(col, 0), columns - 1),
1052
+ row: Math.min(Math.max(row, 0), this._slotCount - 1)
1053
+ };
1054
+ }
1055
+
1056
+ /** Paints the rectangle between the drag anchor and the cursor onto a copy of the shown schedule. */
1057
+ private _buildDragPreview(cursor: { col: number; row: number }): ScheduleDayMap {
1058
+ const anchor = this._dragAnchor!;
1059
+ const preview = this._cloneDays(this._storedAsShown);
1060
+
1061
+ const firstCol = Math.min(anchor.col, cursor.col);
1062
+ const lastCol = Math.max(anchor.col, cursor.col);
1063
+ const firstRow = Math.min(anchor.row, cursor.row);
1064
+ const lastRow = Math.max(anchor.row, cursor.row);
1065
+
1066
+ const start = this._startMinute + firstRow * this._interval;
1067
+ const end = Math.min(this._startMinute + (lastRow + 1) * this._interval, this._endMinute);
1068
+
1069
+ const days = this._orderedDays.slice(firstCol, lastCol + 1);
1070
+ days.forEach(day => {
1071
+ preview[day] = this._dragMode === 'open'
1072
+ ? unionSpan(preview[day], start, end)
1073
+ : subtractSpan(preview[day], start, end);
1074
+ });
1075
+
1076
+ return preview;
1077
+ }
1078
+
1079
+ private _handleCanvasPointerDown = (event: PointerEvent) => {
1080
+ if (!this._isEditable || event.button !== 0) return;
1081
+
1082
+ const position = this._pointerPosition(event);
1083
+ if (!position) return;
1084
+
1085
+ const day = this._orderedDays[position.col];
1086
+ const open = daysToSpans(this._storedAsShown);
1087
+ this._dragMode = spansCover(open, this._slotMinute(day, position.row)) ? 'close' : 'open';
1088
+ this._dragAnchor = position;
1089
+ this._dragPointerId = event.pointerId;
1090
+ this._dragPreview = this._buildDragPreview(position);
1091
+
1092
+ this.canvas.setPointerCapture(event.pointerId);
1093
+ event.preventDefault();
1094
+ };
1095
+
1096
+ private _handleCanvasPointerMove = (event: PointerEvent) => {
1097
+ if (!this._dragAnchor || event.pointerId !== this._dragPointerId) return;
1098
+
1099
+ const position = this._pointerPosition(event);
1100
+ if (!position) return;
1101
+
1102
+ this._dragPreview = this._buildDragPreview(position);
1103
+ };
1104
+
1105
+ private _handleCanvasPointerUp = (event: PointerEvent) => {
1106
+ if (!this._dragAnchor || event.pointerId !== this._dragPointerId) return;
1107
+
1108
+ const position = this._pointerPosition(event) ?? this._dragAnchor;
1109
+ const painted = this._buildDragPreview(position);
1110
+
1111
+ this._dragAnchor = null;
1112
+ this._dragPointerId = null;
1113
+ this._dragPreview = null;
1114
+
1115
+ if (this.canvas.hasPointerCapture(event.pointerId)) {
1116
+ this.canvas.releasePointerCapture(event.pointerId);
1117
+ }
1118
+
1119
+ this._commitShownDays(painted);
1120
+ };
1121
+
1122
+ private _handleCanvasPointerCancel = () => {
1123
+ this._dragAnchor = null;
1124
+ this._dragPointerId = null;
1125
+ this._dragPreview = null;
1126
+ };
1127
+
1128
+ private _handleViewToggle(view: ScheduleView) {
1129
+ if (this.view === view) return;
1130
+ this._editing = null;
1131
+ this.view = view;
1132
+ }
1133
+
1134
+ /** Picks a sensible slot for a newly added range: the first hour-wide gap in the day. */
1135
+ private _nextFreeRange(ranges: ScheduleRange[]): ScheduleRange {
1136
+ if (!ranges.length) return {start: '09:00', end: '17:00'};
1137
+
1138
+ let cursor = this._startMinute;
1139
+
1140
+ for (const range of ranges) {
1141
+ const start = parseTime(range.start) ?? 0;
1142
+ const end = parseTime(range.end) ?? 0;
1143
+ if (start - cursor >= 60) break;
1144
+ cursor = Math.max(cursor, end);
1145
+ }
1146
+
1147
+ const start = Math.min(cursor, MINUTES_IN_DAY - 60);
1148
+ return {start: formatMinutes(start), end: formatMinutes(start + 60)};
1149
+ }
1150
+
1151
+ private _handleAddRange(day: ScheduleDay) {
1152
+ if (!this._isEditable) return;
1153
+
1154
+ const shown = this._storedAsShown;
1155
+ const range = this._nextFreeRange(shown[day]);
1156
+ const ranges = normaliseRanges([...shown[day], range]);
1157
+
1158
+ this._commitShownDays({...shown, [day]: ranges});
1159
+ // The new range may merge into a neighbour, so find where it actually landed.
1160
+ this._editing = {day, index: Math.max(ranges.findIndex(item => item.start === range.start), 0)};
1161
+ }
1162
+
1163
+ private _handleRemoveRange(day: ScheduleDay, index: number) {
1164
+ if (!this._isEditable) return;
1165
+
1166
+ const shown = this._storedAsShown;
1167
+ this._editing = null;
1168
+ this._commitShownDays({...shown, [day]: shown[day].filter((_, position) => position !== index)});
1169
+ }
1170
+
1171
+ private _handleRangeEdit(day: ScheduleDay, index: number, edge: 'start' | 'end', value: string) {
1172
+ const time = parseTime(value);
1173
+ if (time === null) return;
1174
+
1175
+ const shown = this._storedAsShown;
1176
+ const normalised = normaliseRanges(shown[day].map((range, position) =>
1177
+ position === index ? {...range, [edge]: formatMinutes(time)} : range));
1178
+
1179
+ // Normalising can merge or drop the edited range; keep the editor on something that exists.
1180
+ if (this._editing && this._editing.day === day && this._editing.index >= normalised.length) {
1181
+ this._editing = normalised.length ? {day, index: normalised.length - 1} : null;
1182
+ }
1183
+
1184
+ this._commitShownDays({...shown, [day]: normalised});
1185
+ }
1186
+
1187
+ private _handleEditorKeyDown(event: KeyboardEvent) {
1188
+ if (event.key === 'Enter' || event.key === 'Escape') {
1189
+ event.preventDefault();
1190
+ event.stopPropagation();
1191
+ this._editing = null;
1192
+ }
1193
+ }
1194
+
1195
+ private _handleEditorFocusOut(event: FocusEvent) {
1196
+ const editor = event.currentTarget as HTMLElement;
1197
+ const next = event.relatedTarget as Node | null;
1198
+ if (next && editor.contains(next)) return;
1199
+ this._editing = null;
1200
+ }
1201
+
1202
+ private _renderToolbar() {
1203
+ const showToggle = !this.noToggle;
1204
+ const showLegend = this.view === 'calendar';
1205
+
1206
+ if (!showToggle && !showLegend && !this.showTimezone) return nothing;
1207
+
1208
+ return html`
1209
+ <div class="schedule-builder__toolbar" part="toolbar">
1210
+ ${this.view === 'calendar' && this._isEditable
1211
+ ? html`<p class="schedule-builder__hint">
1212
+ Drag across the grid to open hours; drag over open hours to close them.
1213
+ </p>`
1214
+ : html`<span class="schedule-builder__hint"></span>`}
1215
+
1216
+ ${showLegend
1217
+ ? html`
1218
+ <ul class="legend">
1219
+ <li class="legend__item"><span class="legend__swatch legend__swatch--open"></span>${this.openLabel}</li>
1220
+ <li class="legend__item"><span class="legend__swatch"></span>${this.closedLabel}</li>
1221
+ </ul>`
1222
+ : nothing}
1223
+
1224
+ ${this.showTimezone ? this._renderTimezonePicker() : nothing}
1225
+
1226
+ ${showToggle
1227
+ ? html`
1228
+ <div class="view-toggle" role="group" aria-label="Schedule view">
1229
+ <button type="button"
1230
+ class="${classMap({
1231
+ 'view-toggle__button': true,
1232
+ 'view-toggle__button--active': this.view === 'calendar'
1233
+ })}"
1234
+ aria-pressed="${this.view === 'calendar'}"
1235
+ title="Calendar view"
1236
+ @click="${() => this._handleViewToggle('calendar')}">
1237
+ <zn-icon src="calendar_view_week" size="16"></zn-icon>
1238
+ </button>
1239
+ <button type="button"
1240
+ class="${classMap({
1241
+ 'view-toggle__button': true,
1242
+ 'view-toggle__button--active': this.view === 'form'
1243
+ })}"
1244
+ aria-pressed="${this.view === 'form'}"
1245
+ title="Form view"
1246
+ @click="${() => this._handleViewToggle('form')}">
1247
+ <zn-icon src="format_list_bulleted" size="16"></zn-icon>
1248
+ </button>
1249
+ </div>`
1250
+ : nothing}
1251
+ </div>
1252
+ `;
1253
+ }
1254
+
1255
+ /** The picker's options: the configured list plus whatever zones are already in play. */
1256
+ private get _timezoneOptions(): TimezoneOption[] {
1257
+ const requested = this.timezones.length ? this.timezones : ['offsets'];
1258
+ const options = new Map<string, TimezoneOption>();
1259
+
1260
+ requested.flatMap(entry => resolveTimezoneSet(entry)).forEach(option => {
1261
+ // First mention of a zone wins, so a named set's label survives a later plain listing.
1262
+ if (!options.has(option.zone)) options.set(option.zone, option);
1263
+ });
1264
+
1265
+ // The zones in play are always reachable, even when they aren't in the configured list.
1266
+ [this._saveZone, this._displayZone, localTimezone()].forEach(zone => {
1267
+ if (zone && !options.has(zone)) options.set(zone, {zone});
1268
+ });
1269
+
1270
+ const reference = this._reference;
1271
+ return [...options.values()].sort((a, b) => {
1272
+ const offset = timezoneOffset(a.zone, reference) - timezoneOffset(b.zone, reference);
1273
+ return offset || a.zone.localeCompare(b.zone);
1274
+ });
1275
+ }
1276
+
1277
+ private _handleTimezoneChange(event: Event) {
1278
+ // The picker is a view control, so its own change event must not read as a schedule change.
1279
+ event.stopPropagation();
1280
+
1281
+ const zone = (event.target as ZnSelect).value;
1282
+ if (typeof zone === 'string' && zone) {
1283
+ this.displayTimezone = zone;
1284
+ }
1285
+ }
1286
+
1287
+ private _renderTimezonePicker() {
1288
+ const reference = this._reference;
1289
+
1290
+ return html`
1291
+ <zn-select class="timezone"
1292
+ size="small"
1293
+ search
1294
+ hoist
1295
+ aria-label="Display timezone"
1296
+ ?disabled=${this.disabled}
1297
+ .value=${this._displayZone}
1298
+ @zn-change=${this._handleTimezoneChange}
1299
+ @zn-input=${(event: Event) => event.stopPropagation()}>
1300
+ ${this._timezoneOptions.map(option => html`
1301
+ <zn-option value=${option.zone}>${formatZone(option, reference)}</zn-option>`)}
1302
+ </zn-select>`;
1303
+ }
1304
+
1305
+ private _renderCalendar() {
1306
+ const days = this._shownDays;
1307
+ const openSpans = daysToSpans(days);
1308
+ const reductionSpans = this._shownReductionSpans;
1309
+ const hours: number[] = [];
1310
+
1311
+ for (let minute = this._startMinute; minute < this._endMinute; minute += 60) {
1312
+ hours.push(minute);
1313
+ }
1314
+
1315
+ return html`
1316
+ <div class="schedule-builder__calendar" part="calendar">
1317
+ <div class="calendar">
1318
+ <div class="calendar__head" style=${styleMap({'--columns': String(this._orderedDays.length)})}>
1319
+ <div class="calendar__head-gutter"></div>
1320
+ ${this._orderedDays.map(day => html`
1321
+ <div class=${classMap({
1322
+ 'calendar__head-cell': true,
1323
+ 'calendar__head-cell--closed': days[day].length === 0
1324
+ })}>
1325
+ <span class="calendar__head-day">${DAY_LABELS[day].short}</span>
1326
+ <span class="calendar__head-hours">${this._summariseDay(day, days)}</span>
1327
+ </div>`)}
1328
+ </div>
1329
+
1330
+ <div class="calendar__body"
1331
+ style=${styleMap({
1332
+ '--columns': String(this._orderedDays.length),
1333
+ '--slots': String(this._slotCount),
1334
+ '--slots-per-hour': String(this._slotsPerHour)
1335
+ })}>
1336
+ <div class="calendar__gutter">
1337
+ ${hours.map(minute => html`
1338
+ <div class="calendar__hour"><span>${this._formatTime(formatMinutes(minute))}</span></div>`)}
1339
+ </div>
1340
+
1341
+ <div class="schedule-builder__canvas calendar__canvas"
1342
+ role="grid"
1343
+ aria-label="Weekly opening hours"
1344
+ aria-readonly=${!this._isEditable}
1345
+ @pointerdown=${this._handleCanvasPointerDown}
1346
+ @pointermove=${this._handleCanvasPointerMove}
1347
+ @pointerup=${this._handleCanvasPointerUp}
1348
+ @pointercancel=${this._handleCanvasPointerCancel}>
1349
+ ${this._orderedDays.map(day => this._renderCalendarColumn(day, openSpans, reductionSpans))}
1350
+ </div>
1351
+ </div>
1352
+ </div>
1353
+
1354
+ ${this.hideSummary ? nothing : this._renderSummary(days)}
1355
+ </div>
1356
+ `;
1357
+ }
1358
+
1359
+ private _renderCalendarColumn(day: ScheduleDay, openSpans: WeekSpan[], reductionSpans: WeekSpan[]) {
1360
+ const slots = [];
1361
+
1362
+ for (let index = 0; index < this._slotCount; index++) {
1363
+ const slotState = this._slotState(day, index, openSpans, reductionSpans);
1364
+ slots.push(html`
1365
+ <div class=${classMap({
1366
+ 'calendar__slot': true,
1367
+ 'calendar__slot--open': slotState === 'open',
1368
+ 'calendar__slot--reduced': slotState === 'reduced',
1369
+ 'calendar__slot--hour': index % this._slotsPerHour === 0
1370
+ })}></div>`);
1371
+ }
1372
+
1373
+ return html`
1374
+ <div class="calendar__col" role="row" aria-label=${DAY_LABELS[day].long}>${slots}</div>`;
1375
+ }
1376
+
1377
+ private _renderSummary(days: ScheduleDayMap) {
1378
+ const total = DAY_KEYS.reduce((sum, day) => sum + totalMinutes(days[day]), 0) / 60;
1379
+
1380
+ return html`
1381
+ <aside class="summary" part="summary">
1382
+ <h4 class="summary__title">Hours by day</h4>
1383
+ <dl class="summary__list">
1384
+ ${this._orderedDays.map(day => html`
1385
+ <div class="summary__row">
1386
+ <dt>${DAY_LABELS[day].long}</dt>
1387
+ <dd class=${classMap({'summary__closed': days[day].length === 0})}>${this._summariseDay(day, days)}</dd>
1388
+ </div>`)}
1389
+ <div class="summary__row summary__row--total">
1390
+ <dt>Total</dt>
1391
+ <dd>${Number(total.toFixed(2))} h</dd>
1392
+ </div>
1393
+ </dl>
1394
+ </aside>
1395
+ `;
1396
+ }
1397
+
1398
+ private _renderList() {
1399
+ const days = this._shownDays;
1400
+
1401
+ return html`
1402
+ <div class="schedule-builder__list list" part="list">
1403
+ ${this._orderedDays.map(day => this._renderListRow(day, days[day]))}
1404
+ </div>
1405
+ `;
1406
+ }
1407
+
1408
+ private _renderListRow(day: ScheduleDay, ranges: ScheduleRange[]) {
1409
+ const editing = this._editing?.day === day ? this._editing.index : -1;
1410
+ const note = this._dayNote(day);
1411
+
1412
+ return html`
1413
+ <div class=${classMap({list__row: true, 'list__row--editing': editing > -1})}>
1414
+ <div class=${classMap({list__day: true, 'list__day--closed': ranges.length === 0})}>
1415
+ ${DAY_LABELS[day].short}
1416
+ </div>
1417
+
1418
+ <div class="list__ranges">
1419
+ ${ranges.length
1420
+ ? ranges.map((range, index) => index === editing
1421
+ ? this._renderRangeEditor(day, index, range)
1422
+ : this._renderRangeChip(day, index, range, note.reduced))
1423
+ : html`<span class="list__closed">${this.closedLabel}</span>`}
1424
+
1425
+ ${note.text ? html`<span class="list__note">${note.text}</span>` : nothing}
1426
+ </div>
1427
+
1428
+ ${this._isEditable
1429
+ ? html`
1430
+ <button type="button" class="list__add" @click=${() => this._handleAddRange(day)}>
1431
+ <zn-icon src="add" size="16"></zn-icon>
1432
+ Add range
1433
+ </button>`
1434
+ : nothing}
1435
+ </div>
1436
+ `;
1437
+ }
1438
+
1439
+ private _renderRangeChip(day: ScheduleDay, index: number, range: ScheduleRange, reduced: boolean) {
1440
+ return html`
1441
+ <button type="button"
1442
+ class=${classMap({list__chip: true, 'list__chip--reduced': reduced})}
1443
+ ?disabled=${!this._isEditable}
1444
+ @click=${() => {
1445
+ if (this._isEditable) this._editing = {day, index};
1446
+ }}>
1447
+ ${this._formatRange(range)}
1448
+ </button>`;
1449
+ }
1450
+
1451
+ private _renderRangeEditor(day: ScheduleDay, index: number, range: ScheduleRange) {
1452
+ const step = this._interval * 60;
1453
+
1454
+ return html`
1455
+ <div class="list__editor"
1456
+ @keydown=${this._handleEditorKeyDown}
1457
+ @focusout=${this._handleEditorFocusOut}>
1458
+ <zn-input type="time"
1459
+ size="small"
1460
+ step=${step}
1461
+ .value=${range.start}
1462
+ aria-label="${DAY_LABELS[day].long} opens"
1463
+ @zn-input=${(event: Event) => event.stopPropagation()}
1464
+ @zn-change=${(event: Event) => {
1465
+ event.stopPropagation();
1466
+ this._handleRangeEdit(day, index, 'start', (event.target as ZnInput).value as string);
1467
+ }}></zn-input>
1468
+ <span class="list__editor-separator">–</span>
1469
+ <zn-input type="time"
1470
+ size="small"
1471
+ step=${step}
1472
+ .value=${range.end}
1473
+ aria-label="${DAY_LABELS[day].long} closes"
1474
+ @zn-input=${(event: Event) => event.stopPropagation()}
1475
+ @zn-change=${(event: Event) => {
1476
+ event.stopPropagation();
1477
+ this._handleRangeEdit(day, index, 'end', (event.target as ZnInput).value as string);
1478
+ }}></zn-input>
1479
+ <button type="button"
1480
+ class="list__remove"
1481
+ title="Remove range"
1482
+ @click=${() => this._handleRemoveRange(day, index)}>
1483
+ <zn-icon src="close" size="14"></zn-icon>
1484
+ </button>
1485
+ </div>`;
1486
+ }
1487
+
1488
+ /** The exception annotation shown against a day in the form view. */
1489
+ private _dayNote(day: ScheduleDay): { text: string; reduced: boolean } {
1490
+ const exception = this._exceptionsForDay(day).find(item => exceptionRanges(item) !== null);
1491
+ if (!exception) return {text: '', reduced: false};
1492
+
1493
+ const ranges = exceptionRanges(exception) ?? [];
1494
+ const verb = ranges.length ? 'Reduced' : this.closedLabel;
1495
+ const until = exception.to ? `until ${this._formatDate(exception.to)}` : '';
1496
+ const on = !exception.to && (exception.date ?? exception.from)
1497
+ ? `on ${this._formatDate(exception.date ?? exception.from)}`
1498
+ : '';
1499
+ const when = until || on;
1500
+ const label = exception.label ? ` — ${exception.label}` : '';
1501
+
1502
+ return {text: `${verb}${when ? ` ${when}` : ''}${label}`, reduced: ranges.length > 0};
1503
+ }
1504
+
1505
+ render() {
1506
+ const hasLabel = this.label ? true : this.hasSlotController.test('label');
1507
+ const hasHelpText = this.helpText ? true : this.hasSlotController.test('help-text');
1508
+
1509
+ return html`
1510
+ <div part="form-control"
1511
+ class=${classMap({
1512
+ 'form-control': true,
1513
+ 'form-control--medium': true,
1514
+ 'form-control--has-label': hasLabel,
1515
+ 'form-control--has-help-text': hasHelpText
1516
+ })}>
1517
+ <label part="form-control-label"
1518
+ class="form-control__label"
1519
+ aria-hidden=${hasLabel ? 'false' : 'true'}>
1520
+ <slot name="label">${this.label}</slot>
1521
+ </label>
1522
+
1523
+ <div part="base"
1524
+ class=${classMap({
1525
+ 'schedule-builder': true,
1526
+ 'schedule-builder--disabled': this.disabled,
1527
+ 'schedule-builder--readonly': this.readonly,
1528
+ 'schedule-builder--calendar': this.view === 'calendar',
1529
+ 'schedule-builder--form': this.view === 'form'
1530
+ })}>
1531
+ ${this._renderToolbar()}
1532
+ ${this.view === 'calendar' ? this._renderCalendar() : this._renderList()}
1533
+ </div>
1534
+
1535
+ <div part="form-control-help-text"
1536
+ class="form-control__help-text"
1537
+ aria-hidden=${hasHelpText ? 'false' : 'true'}>
1538
+ <slot name="help-text">${this.helpText}</slot>
1539
+ </div>
1540
+ </div>
1541
+ `;
1542
+ }
1543
+ }