@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.
- package/CHANGELOG.md +105 -0
- package/COMPONENTS.md +72 -3
- package/README.id.md +4 -4
- package/README.md +3 -3
- package/adapters/blazor/FdyCascade.razor +2 -1
- package/adapters/blazor/FdyCascade.razor.cs +7 -0
- package/adapters/blazor/FdyCombo.razor +2 -1
- package/adapters/blazor/FdyCombo.razor.cs +7 -0
- package/adapters/blazor/FdyDateRange.razor +20 -2
- package/adapters/blazor/FdyDateRange.razor.cs +20 -0
- package/adapters/blazor/FdyDatepicker.razor +1 -0
- package/adapters/blazor/FdyDatepicker.razor.cs +7 -0
- package/adapters/blazor/FdyMonthpicker.razor +1 -0
- package/adapters/blazor/FdyMonthpicker.razor.cs +7 -0
- package/adapters/blazor/FdyTimepicker.razor +24 -0
- package/adapters/blazor/FdyTimepicker.razor.cs +86 -0
- package/adapters/react/components/FdyCascade.tsx +10 -1
- package/adapters/react/components/FdyCombo.tsx +9 -0
- package/adapters/react/components/FdyDateRange.tsx +29 -0
- package/adapters/react/components/FdyDatepicker.tsx +10 -1
- package/adapters/react/components/FdyMonthpicker.tsx +10 -1
- package/adapters/react/components/FdyTimepicker.tsx +221 -0
- package/adapters/react/index.d.ts +1 -0
- package/adapters/react/index.js +1 -0
- package/adapters/vue/components/FdyCascade.vue +10 -1
- package/adapters/vue/components/FdyCombo.vue +9 -0
- package/adapters/vue/components/FdyDateRange.vue +29 -0
- package/adapters/vue/components/FdyDatepicker.vue +10 -1
- package/adapters/vue/components/FdyMonthpicker.vue +10 -1
- package/adapters/vue/components/FdyTimepicker.vue +285 -0
- package/adapters/vue/index.d.ts +1 -0
- package/adapters/vue/index.js +1 -0
- package/dist/freeday-cascade.js +5 -0
- package/dist/freeday-datepicker.js +5 -0
- package/dist/freeday-form.js +60 -0
- package/dist/freeday-timepicker.js +49 -1
- package/dist/freeday.js +119 -1
- package/docs/agent-onboarding.md +3 -3
- package/docs/getting-started.md +5 -5
- package/docs/integrations.md +2 -2
- 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>
|
package/adapters/vue/index.d.ts
CHANGED
|
@@ -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';
|
package/adapters/vue/index.js
CHANGED
|
@@ -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';
|
package/dist/freeday-cascade.js
CHANGED
|
@@ -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
|
package/dist/freeday-form.js
CHANGED
|
@@ -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 = {
|
|
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
|
})();
|