@cahyo-dimas/freeday 3.10.0 → 3.12.0

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 (41) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/COMPONENTS.md +72 -3
  3. package/README.id.md +4 -4
  4. package/README.md +3 -3
  5. package/adapters/blazor/FdyCascade.razor +2 -1
  6. package/adapters/blazor/FdyCascade.razor.cs +7 -0
  7. package/adapters/blazor/FdyCombo.razor +2 -1
  8. package/adapters/blazor/FdyCombo.razor.cs +7 -0
  9. package/adapters/blazor/FdyDateRange.razor +20 -2
  10. package/adapters/blazor/FdyDateRange.razor.cs +20 -0
  11. package/adapters/blazor/FdyDatepicker.razor +1 -0
  12. package/adapters/blazor/FdyDatepicker.razor.cs +7 -0
  13. package/adapters/blazor/FdyMonthpicker.razor +1 -0
  14. package/adapters/blazor/FdyMonthpicker.razor.cs +7 -0
  15. package/adapters/blazor/FdyTimepicker.razor +24 -0
  16. package/adapters/blazor/FdyTimepicker.razor.cs +86 -0
  17. package/adapters/react/components/FdyCascade.tsx +10 -1
  18. package/adapters/react/components/FdyCombo.tsx +9 -0
  19. package/adapters/react/components/FdyDateRange.tsx +29 -0
  20. package/adapters/react/components/FdyDatepicker.tsx +10 -1
  21. package/adapters/react/components/FdyMonthpicker.tsx +10 -1
  22. package/adapters/react/components/FdyTimepicker.tsx +221 -0
  23. package/adapters/react/index.d.ts +1 -0
  24. package/adapters/react/index.js +1 -0
  25. package/adapters/vue/components/FdyCascade.vue +10 -1
  26. package/adapters/vue/components/FdyCombo.vue +9 -0
  27. package/adapters/vue/components/FdyDateRange.vue +29 -0
  28. package/adapters/vue/components/FdyDatepicker.vue +10 -1
  29. package/adapters/vue/components/FdyMonthpicker.vue +10 -1
  30. package/adapters/vue/components/FdyTimepicker.vue +285 -0
  31. package/adapters/vue/index.d.ts +1 -0
  32. package/adapters/vue/index.js +1 -0
  33. package/dist/freeday-cascade.js +5 -0
  34. package/dist/freeday-datepicker.js +5 -0
  35. package/dist/freeday-form.js +60 -0
  36. package/dist/freeday-timepicker.js +49 -1
  37. package/dist/freeday.js +119 -1
  38. package/docs/agent-onboarding.md +3 -3
  39. package/docs/getting-started.md +5 -5
  40. package/docs/integrations.md +2 -2
  41. package/package.json +2 -2
@@ -0,0 +1,285 @@
1
+ <script setup lang="ts">
2
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref, useId, type ComputedRef, type Ref } from 'vue';
3
+ import { usePopover } from '../usePopover';
4
+
5
+ // A Vue-native binding over freeday's `.fdy-timepicker` classes (src/components/timepicker.css).
6
+ // The kit also ships a framework-agnostic enhancer (freeday-timepicker.js), but letting it own the
7
+ // DOM would fight Vue's rendering and leave no `v-model` — the same reason FdyCombo re-implements
8
+ // the listbox rather than wrapping freeday-select.js. Charts delegate to their renderer; form
9
+ // controls do not.
10
+ //
11
+ // `<input type="time">` is what this replaces, and the reason is not theming alone: the native
12
+ // picker is drawn by the OPERATING SYSTEM, so iOS shows a scroll wheel, Android a clock dial and
13
+ // desktop a stepper, each with its own width and value format, and no CSS reaches any of them. A
14
+ // phone and a browser at the same width therefore disagree about a screen that is otherwise
15
+ // identical. Raised from keamanan-cluster, whose shift form was audited on an iPhone 7 (#094).
16
+ //
17
+ // Deliberate difference from the enhancer: focus stays on the TRIGGER with `aria-activedescendant`
18
+ // on it, which is what FdyCombo, FdyDatepicker and FdyAutocomplete do, rather than moving focus
19
+ // into the panel as the raw path does. Both are valid APG listbox patterns; one family, one model.
20
+
21
+ const props = defineProps<{
22
+ /** `HH:MM`, 24-hour. `''`/`null` for empty. A loose `H:MM` is normalised for matching. */
23
+ modelValue: string | null;
24
+ /** Minutes between options. Default 30. */
25
+ step?: number;
26
+ /** `HH:MM` bounds, inclusive. Default `00:00` to `23:59`. */
27
+ min?: string;
28
+ max?: string;
29
+ id?: string;
30
+ ariaLabel?: string;
31
+ ariaLabelledby?: string;
32
+ /** Shown when there is no value. Default `--:--`, the same as the raw path. */
33
+ placeholder?: string;
34
+ disabled?: boolean;
35
+ /** Locked/view mode: stays focusable and shows its time, but can't be opened or changed. Unlike `disabled`, it keeps tab order and isn't greyed. */
36
+ readonly?: boolean;
37
+ /**
38
+ * Let a `data-fdy-validate` form demand this control be filled. It renders `data-fdy-required`
39
+ * on the root, and `freeday-form.js` refuses the submit, paints the message in the field and
40
+ * focuses the trigger. Needed because the trigger is a `<button>`: `willValidate` is false, so
41
+ * a plain `required` on it has never meant anything (#096). Override the message per control
42
+ * with `data-fdy-msg-required`, which falls through onto the root.
43
+ */
44
+ required?: boolean;
45
+ invalid?: boolean;
46
+ describedby?: string;
47
+ }>();
48
+
49
+ const emit = defineEmits<{
50
+ 'update:modelValue': [value: string];
51
+ change: [value: string];
52
+ }>();
53
+
54
+ const SHAPE: RegExp = /^\d{1,2}:\d{2}$/;
55
+
56
+ function toMinutes(time: string): number {
57
+ const [h, m] = time.split(':');
58
+ return Number(h) * 60 + Number(m);
59
+ }
60
+ function pad(n: number): string {
61
+ return n < 10 ? `0${n}` : String(n);
62
+ }
63
+ function fromMinutes(total: number): string {
64
+ return `${pad(Math.floor(total / 60))}:${pad(total % 60)}`;
65
+ }
66
+ /** `7:0` is not a time; `7:00` is the same time as `07:00`. Anything else answers `''`. */
67
+ function normalise(time: string | null): string {
68
+ if (time === null || !SHAPE.test(time)) return '';
69
+ const total: number = toMinutes(time);
70
+ return total >= 0 && total <= 23 * 60 + 59 ? fromMinutes(total) : '';
71
+ }
72
+ function bound(time: string | undefined, fallback: number): number {
73
+ return time !== undefined && SHAPE.test(time) ? toMinutes(time) : fallback;
74
+ }
75
+
76
+ const baseId: string = useId();
77
+ const triggerId: ComputedRef<string> = computed((): string => props.id ?? `${baseId}-trigger`);
78
+ const panelId: string = `${baseId}-panel`;
79
+ function optionId(index: number): string {
80
+ return `${baseId}-opt-${index}`;
81
+ }
82
+
83
+ const rootEl: Ref<HTMLDivElement | null> = ref(null);
84
+ const triggerEl: Ref<HTMLButtonElement | null> = ref(null);
85
+ const panelEl: Ref<HTMLUListElement | null> = ref(null);
86
+ const open: Ref<boolean> = ref(false);
87
+
88
+ // Lift the panel into the top layer so it escapes an ancestor overflow clip. A shift form is a
89
+ // modal, and a time list long enough to scroll is exactly the panel a card would cut off.
90
+ usePopover(panelEl, triggerEl, open);
91
+ const highlighted: Ref<number> = ref(-1);
92
+
93
+ const times: ComputedRef<ReadonlyArray<string>> = computed((): ReadonlyArray<string> => {
94
+ const stride: number = Math.max(1, Math.trunc(props.step ?? 30));
95
+ const from: number = bound(props.min, 0);
96
+ const to: number = bound(props.max, 23 * 60 + 59);
97
+ const out: string[] = [];
98
+ for (let m = from; m <= to; m += stride) out.push(fromMinutes(m));
99
+ return out;
100
+ });
101
+
102
+ const value: ComputedRef<string> = computed((): string => normalise(props.modelValue));
103
+ const selectedIndex: ComputedRef<number> = computed((): number => times.value.indexOf(value.value));
104
+ const shown: ComputedRef<string> = computed((): string =>
105
+ value.value !== '' ? value.value : (props.placeholder ?? '--:--'),
106
+ );
107
+ const isPlaceholder: ComputedRef<boolean> = computed((): boolean => value.value === '');
108
+ const isDisabled: ComputedRef<boolean> = computed((): boolean => props.disabled === true);
109
+ const isReadonly: ComputedRef<boolean> = computed((): boolean => props.readonly === true);
110
+ const isInvalid: ComputedRef<boolean> = computed((): boolean => props.invalid === true);
111
+ const activeDescendant: ComputedRef<string | undefined> = computed((): string | undefined =>
112
+ open.value && highlighted.value >= 0 ? optionId(highlighted.value) : undefined,
113
+ );
114
+
115
+ /* The panel is `max-height:15rem` and scrolls: a highlight the reader cannot see is no highlight,
116
+ and with 48 half-hour options most of the list is out of view. */
117
+ function revealHighlight(): void {
118
+ void nextTick((): void => {
119
+ const list: HTMLUListElement | null = panelEl.value;
120
+ if (list === null || highlighted.value < 0) return;
121
+ const opt: Element | undefined = list.children[highlighted.value];
122
+ if (opt instanceof HTMLElement) opt.scrollIntoView({ block: 'nearest' });
123
+ });
124
+ }
125
+
126
+ /* Clamped, not cyclic: FdyCombo wraps because its lists are short and a wrap is a quick way back
127
+ to the top. Here ArrowUp at 00:00 landing on 23:30 reads as a glitch. Matches the raw path. */
128
+ function setHighlight(index: number): void {
129
+ const last: number = times.value.length - 1;
130
+ highlighted.value = last < 0 ? -1 : Math.min(last, Math.max(0, index));
131
+ revealHighlight();
132
+ }
133
+
134
+ function openList(): void {
135
+ if (isDisabled.value || isReadonly.value || open.value) return;
136
+ open.value = true;
137
+ setHighlight(selectedIndex.value >= 0 ? selectedIndex.value : 0);
138
+ }
139
+
140
+ function closeList(focusTrigger: boolean): void {
141
+ if (!open.value) return;
142
+ open.value = false;
143
+ highlighted.value = -1;
144
+ if (focusTrigger) triggerEl.value?.focus();
145
+ }
146
+
147
+ function toggle(): void {
148
+ if (open.value) closeList(false);
149
+ else openList();
150
+ }
151
+
152
+ function choose(index: number): void {
153
+ const next: string | undefined = times.value[index];
154
+ if (next === undefined) return;
155
+ if (next !== value.value) {
156
+ emit('update:modelValue', next);
157
+ emit('change', next);
158
+ }
159
+ closeList(true);
160
+ }
161
+
162
+ function onKeydown(e: KeyboardEvent): void {
163
+ if (isDisabled.value || isReadonly.value) return;
164
+ switch (e.key) {
165
+ case 'ArrowDown':
166
+ e.preventDefault();
167
+ if (open.value) setHighlight(highlighted.value + 1);
168
+ else openList();
169
+ break;
170
+ case 'ArrowUp':
171
+ e.preventDefault();
172
+ if (open.value) setHighlight(highlighted.value - 1);
173
+ else openList();
174
+ break;
175
+ case 'Home':
176
+ if (open.value) {
177
+ e.preventDefault();
178
+ setHighlight(0);
179
+ }
180
+ break;
181
+ case 'End':
182
+ if (open.value) {
183
+ e.preventDefault();
184
+ setHighlight(times.value.length - 1);
185
+ }
186
+ break;
187
+ case 'Enter':
188
+ case ' ':
189
+ e.preventDefault();
190
+ if (open.value && highlighted.value >= 0) choose(highlighted.value);
191
+ else openList();
192
+ break;
193
+ case 'Escape':
194
+ if (open.value) {
195
+ e.preventDefault();
196
+ closeList(true);
197
+ }
198
+ break;
199
+ case 'Tab':
200
+ if (open.value) closeList(false);
201
+ break;
202
+ default:
203
+ break;
204
+ }
205
+ }
206
+
207
+ function onDocPointerDown(e: MouseEvent): void {
208
+ const target: EventTarget | null = e.target;
209
+ if (rootEl.value !== null && target instanceof Node && !rootEl.value.contains(target)) {
210
+ closeList(false);
211
+ }
212
+ }
213
+
214
+ function onFocusout(e: FocusEvent): void {
215
+ const next: EventTarget | null = e.relatedTarget;
216
+ if (rootEl.value !== null && !(next instanceof Node && rootEl.value.contains(next))) {
217
+ closeList(false);
218
+ }
219
+ }
220
+
221
+ onMounted((): void => {
222
+ document.addEventListener('mousedown', onDocPointerDown);
223
+ });
224
+ onBeforeUnmount((): void => {
225
+ document.removeEventListener('mousedown', onDocPointerDown);
226
+ });
227
+ </script>
228
+
229
+ <template>
230
+ <div
231
+ ref="rootEl"
232
+ class="fdy-timepicker"
233
+ :class="{ 'fdy-timepicker--error': isInvalid }"
234
+ :data-value="value"
235
+ :data-fdy-required="required === true ? '' : undefined"
236
+ @keydown="onKeydown"
237
+ @focusout="onFocusout"
238
+ >
239
+ <button
240
+ :id="triggerId"
241
+ ref="triggerEl"
242
+ type="button"
243
+ class="fdy-timepicker__trigger"
244
+ :class="{ 'is-open': open }"
245
+ aria-haspopup="listbox"
246
+ :aria-controls="panelId"
247
+ :aria-expanded="open"
248
+ :aria-activedescendant="activeDescendant"
249
+ :aria-label="ariaLabel"
250
+ :aria-labelledby="ariaLabelledby"
251
+ :aria-invalid="isInvalid ? 'true' : undefined"
252
+ :aria-readonly="isReadonly ? 'true' : undefined"
253
+ :aria-describedby="describedby"
254
+ :disabled="isDisabled"
255
+ @click="toggle"
256
+ >
257
+ <span class="fdy-timepicker__value" :class="{ 'fdy-timepicker__value--placeholder': isPlaceholder }">{{ shown }}</span>
258
+ <span class="fdy-timepicker__icon">
259
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
260
+ stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
261
+ <circle cx="12" cy="12" r="9"></circle>
262
+ <path d="M12 7v5l3 2"></path>
263
+ </svg>
264
+ </span>
265
+ </button>
266
+ <ul :id="panelId" ref="panelEl" class="fdy-timepicker__panel" role="listbox"
267
+ :aria-label="ariaLabel" popover="manual" :hidden="!open">
268
+ <!-- @mousedown.prevent keeps focus on the trigger: an <li> isn't focusable, so a plain
269
+ mousedown moves focus out of the control, fires @focusout and closes the panel before
270
+ the click lands — mouse-select would silently do nothing. Same as FdyCombo. -->
271
+ <li
272
+ v-for="(t, i) in times"
273
+ :id="optionId(i)"
274
+ :key="t"
275
+ class="fdy-timepicker__opt"
276
+ :class="{ 'is-active': i === highlighted }"
277
+ role="option"
278
+ :aria-selected="t === value"
279
+ @mousedown.prevent
280
+ @click="choose(i)"
281
+ @mousemove="setHighlight(i)"
282
+ >{{ t }}</li>
283
+ </ul>
284
+ </div>
285
+ </template>
@@ -44,6 +44,7 @@ export { default as FdyDatepicker } from './components/FdyDatepicker.vue';
44
44
  export { default as FdyMonthpicker } from './components/FdyMonthpicker.vue';
45
45
  export type { FdyMonth } from '../core/month-grid';
46
46
  export { default as FdyDateRange } from './components/FdyDateRange.vue';
47
+ export { default as FdyTimepicker } from './components/FdyTimepicker.vue';
47
48
  export type { DateRangeValue } from './components/FdyDateRange.vue';
48
49
  export { default as FdyAutocomplete } from './components/FdyAutocomplete.vue';
49
50
  export { default as FdyCascade } from './components/FdyCascade.vue';
@@ -4,6 +4,7 @@ export { default as FdyCombo } from './components/FdyCombo.vue';
4
4
  export { default as FdyDatepicker } from './components/FdyDatepicker.vue';
5
5
  export { default as FdyMonthpicker } from './components/FdyMonthpicker.vue';
6
6
  export { default as FdyDateRange } from './components/FdyDateRange.vue';
7
+ export { default as FdyTimepicker } from './components/FdyTimepicker.vue';
7
8
  export { default as FdyAutocomplete } from './components/FdyAutocomplete.vue';
8
9
  export { default as FdyCascade } from './components/FdyCascade.vue';
9
10
  export { default as FdyCfl } from './components/FdyCfl.vue';
@@ -198,6 +198,11 @@
198
198
  if (!node) return;
199
199
  if (node.children) { drill(i); return; }
200
200
  selectedValue = node.value;
201
+ /* Mirror the value onto the ROOT, which until now only seeded it. `freeday-select.js` has
202
+ always done this, so this makes the family agree rather than inventing a second idea, and
203
+ it is what lets `freeday-form.js` require a kit control without knowing which control it
204
+ is: one attribute instead of five event names (#096). */
205
+ wrap.setAttribute('data-value', node.value);
201
206
  var labels = stack.map(function (n) { return n.label; }).concat([node.label]);
202
207
  valueSpan.textContent = labels.join(sep);
203
208
  valueSpan.classList.remove('fdy-cascade__value--placeholder');
@@ -175,6 +175,11 @@
175
175
  wrap.appendChild(panel);
176
176
 
177
177
  function updateDisplay() {
178
+ /* Mirror the value onto the ROOT, which until now only seeded it. `freeday-select.js` has
179
+ always done this, so this makes the family agree rather than inventing a second idea, and
180
+ it is what lets `freeday-form.js` require a kit control without knowing which control it
181
+ is: one attribute instead of five event names (#096). */
182
+ wrap.setAttribute('data-value', selected ? (monthOnly ? toISOMonth(selected) : toISO(selected)) : '');
178
183
  if (selected) {
179
184
  /* Through the same `Intl` formatter the month CELLS and the day grid's title already use,
180
185
  which is the whole reason this mode exists: <input type="month"> names the month in the
@@ -74,6 +74,36 @@
74
74
 
75
75
  function fieldOf(el) { return el.closest('.fdy-field') || el.parentElement; }
76
76
 
77
+ /* A kit control (combo, datepicker, monthpicker, timepicker, cascade) is NOT a form control.
78
+ * Its trigger is a <button>, so `willValidate` is false and `form.elements` never offers it --
79
+ * `required` on one meant nothing, and a form could not demand that it be filled (#096).
80
+ *
81
+ * Nothing here names a component. Two attributes carry it:
82
+ * data-fdy-required the app asking for it, on the control's ROOT
83
+ * data-value the live value, which every value-bearing kit control now mirrors
84
+ * and the element that takes aria-invalid and focus is found by `aria-haspopup`, which every
85
+ * popup-style kit trigger already sets. `FdyAutocomplete` and `FdyCfl` are absent on purpose:
86
+ * they render a real <input>, so the NATIVE `required` is what fits them, and routing them
87
+ * through here would give one field two sources of truth. No prop exposes that yet, which is a
88
+ * separate gap and is written down in NEXT-UP.md rather than half-closed here. */
89
+ function kitRoots(form) {
90
+ return Array.prototype.slice.call(form.querySelectorAll('[data-fdy-required]'));
91
+ }
92
+
93
+ /* The trigger, or the root itself if a control ever ships without one, so a missing trigger
94
+ degrades to a visible error rather than a thrown TypeError. */
95
+ function kitAria(root) {
96
+ return root.querySelector('[aria-haspopup]') || root;
97
+ }
98
+
99
+ function kitMessage(root) {
100
+ if ((root.getAttribute('data-value') || '') !== '') return '';
101
+ var form = root.closest('form');
102
+ return root.getAttribute('data-fdy-msg-required')
103
+ || root.getAttribute('data-fdy-msg')
104
+ || textOf(form, 'required');
105
+ }
106
+
77
107
  function ensureErrorEl(el) {
78
108
  var field = fieldOf(el);
79
109
  var errEl = field ? field.querySelector('[data-fdy-error]') : null;
@@ -164,6 +194,21 @@
164
194
  function controls() {
165
195
  return Array.prototype.filter.call(form.elements, isCandidate);
166
196
  }
197
+ function validateKit(root) {
198
+ var msg = kitMessage(root);
199
+ paint(kitAria(root), msg);
200
+ return !msg;
201
+ }
202
+ /* The value lives in an attribute, and each control changes it through its own event name
203
+ (fdy-change, fdy-datepicker-change, fdy-time-select, fdy-cascade-change). Observing the
204
+ attribute is what keeps this enhancer from having to know any of them. */
205
+ function kitLiveBind(root) {
206
+ if (root.dataset.fdyKitBound === '1') return;
207
+ root.dataset.fdyKitBound = '1';
208
+ new MutationObserver(function () {
209
+ if (submitted || eager) validateKit(root);
210
+ }).observe(root, { attributes: true, attributeFilter: ['data-value'] });
211
+ }
167
212
  function validateOne(el) {
168
213
  var msg = messageFor(el);
169
214
  paint(el, msg);
@@ -178,6 +223,7 @@
178
223
  }
179
224
 
180
225
  Array.prototype.forEach.call(controls(), liveBind);
226
+ kitRoots(form).forEach(kitLiveBind);
181
227
 
182
228
  form.addEventListener('submit', function (e) {
183
229
  submitted = true;
@@ -186,6 +232,18 @@
186
232
  liveBind(el);
187
233
  if (!validateOne(el)) invalid.push(el);
188
234
  });
235
+ kitRoots(form).forEach(function (root) {
236
+ kitLiveBind(root);
237
+ if (!validateKit(root)) invalid.push(kitAria(root));
238
+ });
239
+ /* DOM order, not native-then-kit: `invalid[0]` is what gets revealed and focused, and a
240
+ reader sent to the second field while the first is also red learns the wrong thing. */
241
+ invalid.sort(function (a, b) {
242
+ var rel = a.compareDocumentPosition(b);
243
+ if (rel & Node.DOCUMENT_POSITION_FOLLOWING) return -1;
244
+ if (rel & Node.DOCUMENT_POSITION_PRECEDING) return 1;
245
+ return 0;
246
+ });
189
247
  if (invalid.length) {
190
248
  e.preventDefault();
191
249
  reveal(invalid[0]);
@@ -202,11 +260,13 @@
202
260
  submitted = true;
203
261
  var invalid = [];
204
262
  Array.prototype.forEach.call(controls(), function (el) { if (!validateOne(el)) invalid.push(el); });
263
+ kitRoots(form).forEach(function (root) { if (!validateKit(root)) invalid.push(kitAria(root)); });
205
264
  return invalid;
206
265
  },
207
266
  reset: function () {
208
267
  submitted = false;
209
268
  Array.prototype.forEach.call(controls(), function (el) { paint(el, ''); });
269
+ kitRoots(form).forEach(function (root) { paint(kitAria(root), ''); });
210
270
  }
211
271
  };
212
272
  form._fdyForm = api;
@@ -59,6 +59,14 @@
59
59
  trigger.setAttribute('aria-haspopup', 'listbox');
60
60
  trigger.setAttribute('aria-expanded', 'false');
61
61
  trigger.setAttribute('aria-label', label);
62
+ /* The trigger is a <button>, which is NOT a labelable element, so a form's own `<label for>`
63
+ can never activate it -- but an id is still what `aria-describedby` targets and what a host
64
+ needs to point at this control. Both were unreadable from the seed until now, so a Blazor
65
+ host could not name its own error text. Mirrors the datepicker's seed. */
66
+ var seedId = wrap.getAttribute('data-id');
67
+ if (seedId) trigger.id = seedId;
68
+ var describedby = wrap.getAttribute('data-describedby');
69
+ if (describedby) trigger.setAttribute('aria-describedby', describedby);
62
70
  var valueSpan = document.createElement('span');
63
71
  valueSpan.className = 'fdy-timepicker__value';
64
72
  trigger.appendChild(valueSpan);
@@ -96,8 +104,37 @@
96
104
  wrap.appendChild(panel);
97
105
 
98
106
  var active = -1;
107
+ /* A host that renders its seed ONCE and then never re-renders it (Blazor: ShouldRender is
108
+ false after hydration) still has to be able to disable, lock or invalidate the field
109
+ afterwards. Without this the parameter was there and nothing read it. Mirrors
110
+ FreedayDatepicker.setState so the two controls of one family answer the same call. */
111
+ var stateDisabled = wrap.hasAttribute('data-disabled');
112
+ var stateReadonly = wrap.hasAttribute('data-readonly');
113
+ var stateInvalid = wrap.hasAttribute('data-invalid');
114
+
115
+ function applyState() {
116
+ trigger.disabled = stateDisabled;
117
+ if (stateReadonly) trigger.setAttribute('aria-readonly', 'true');
118
+ else trigger.removeAttribute('aria-readonly');
119
+ if (stateInvalid) trigger.setAttribute('aria-invalid', 'true');
120
+ else trigger.removeAttribute('aria-invalid');
121
+ wrap.classList.toggle('fdy-timepicker--error', stateInvalid);
122
+ if ((stateDisabled || stateReadonly) && !panel.hidden) close(false);
123
+ }
124
+ function setState(next) {
125
+ if (!next) return;
126
+ if (next.disabled != null) stateDisabled = !!next.disabled;
127
+ if (next.readonly != null) stateReadonly = !!next.readonly;
128
+ if (next.invalid != null) stateInvalid = !!next.invalid;
129
+ applyState();
130
+ }
99
131
 
100
132
  function updateDisplay() {
133
+ /* Mirror the value onto the ROOT, which until now only seeded it. `freeday-select.js` has
134
+ always done this, so this makes the family agree rather than inventing a second idea, and
135
+ it is what lets `freeday-form.js` require a kit control without knowing which control it
136
+ is: one attribute instead of five event names (#096). */
137
+ wrap.setAttribute('data-value', value);
101
138
  if (value) {
102
139
  valueSpan.textContent = value;
103
140
  valueSpan.classList.remove('fdy-timepicker__value--placeholder');
@@ -124,6 +161,7 @@
124
161
  var _pop = null;
125
162
  function popCtl() { if (_pop === null && window.FreedayPopover) _pop = window.FreedayPopover.attach(panel, trigger); return _pop; }
126
163
  function open() {
164
+ if (stateDisabled || stateReadonly) return;
127
165
  if (!panel.hidden) return;
128
166
  var p = popCtl(); if (p) p.show(); else panel.hidden = false;
129
167
  trigger.setAttribute('aria-expanded', 'true');
@@ -174,10 +212,12 @@
174
212
  });
175
213
 
176
214
  updateDisplay();
215
+ applyState();
177
216
 
178
217
  var api = {
179
218
  wrap: wrap,
180
219
  getValue: function () { return value; },
220
+ setState: setState,
181
221
  clear: function () { value = ''; for (var i = 0; i < opts.length; i++) opts[i].setAttribute('aria-selected', 'false'); updateDisplay(); }
182
222
  };
183
223
  wrap._fdyTp = api;
@@ -197,5 +237,13 @@
197
237
  initAll();
198
238
  }
199
239
 
200
- window.FreedayTimepicker = { init: initTime, initAll: initAll };
240
+ window.FreedayTimepicker = {
241
+ init: initTime,
242
+ initAll: initAll,
243
+ /* See api.setState above: the one call a render-once host needs. */
244
+ setState: function (root, state) {
245
+ var api = root && root._fdyTp ? root._fdyTp : null;
246
+ if (api && api.setState) api.setState(state);
247
+ }
248
+ };
201
249
  })();