@cahyo-dimas/freeday 3.9.4 → 3.11.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.
@@ -0,0 +1,212 @@
1
+ import type { JSX } from 'react';
2
+ import { useEffect, useId, useMemo, useRef, useState } from 'react';
3
+ import { usePopover } from '../usePopover';
4
+
5
+ // React twin of adapters/vue/components/FdyTimepicker.vue over freeday's `.fdy-timepicker`
6
+ // classes (src/components/timepicker.css). See that file's header for why a form control is
7
+ // re-implemented natively instead of wrapping freeday-timepicker.js, and why this replaces
8
+ // `<input type="time">`: the native picker is drawn by the OS, so iOS shows a scroll wheel,
9
+ // Android a clock dial and desktop a stepper, each with its own width and format, and no CSS
10
+ // reaches any of them (#094).
11
+
12
+ export interface FdyTimepickerProps {
13
+ /** `HH:MM`, 24-hour. `''`/`null` for empty. A loose `H:MM` is normalised for matching. */
14
+ value: string | null;
15
+ onChange: (value: string) => void;
16
+ /** Minutes between options. Default 30. */
17
+ step?: number;
18
+ /** `HH:MM` bounds, inclusive. Default `00:00` to `23:59`. */
19
+ min?: string;
20
+ max?: string;
21
+ id?: string;
22
+ ariaLabel?: string;
23
+ ariaLabelledby?: string;
24
+ /** Shown when there is no value. Default `--:--`, the same as the raw path. */
25
+ placeholder?: string;
26
+ disabled?: boolean;
27
+ /** 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. */
28
+ readonly?: boolean;
29
+ invalid?: boolean;
30
+ describedby?: string;
31
+ }
32
+
33
+ const SHAPE: RegExp = /^\d{1,2}:\d{2}$/;
34
+
35
+ function toMinutes(time: string): number {
36
+ const [h, m] = time.split(':');
37
+ return Number(h) * 60 + Number(m);
38
+ }
39
+ function pad(n: number): string {
40
+ return n < 10 ? `0${n}` : String(n);
41
+ }
42
+ function fromMinutes(total: number): string {
43
+ return `${pad(Math.floor(total / 60))}:${pad(total % 60)}`;
44
+ }
45
+ /** `7:0` is not a time; `7:00` is the same time as `07:00`. Anything else answers `''`. */
46
+ function normalise(time: string | null): string {
47
+ if (time === null || !SHAPE.test(time)) return '';
48
+ const total: number = toMinutes(time);
49
+ return total >= 0 && total <= 23 * 60 + 59 ? fromMinutes(total) : '';
50
+ }
51
+ function bound(time: string | undefined, fallback: number): number {
52
+ return time !== undefined && SHAPE.test(time) ? toMinutes(time) : fallback;
53
+ }
54
+
55
+ export function FdyTimepicker(props: FdyTimepickerProps): JSX.Element {
56
+ const baseId: string = useId();
57
+ const triggerId: string = props.id ?? `${baseId}-trigger`;
58
+ const panelId: string = `${baseId}-panel`;
59
+ const optionId = (index: number): string => `${baseId}-opt-${index}`;
60
+
61
+ const rootRef = useRef<HTMLDivElement>(null);
62
+ const triggerRef = useRef<HTMLButtonElement>(null);
63
+ const panelRef = useRef<HTMLUListElement>(null);
64
+ const [open, setOpen] = useState<boolean>(false);
65
+ const [highlighted, setHighlighted] = useState<number>(-1);
66
+
67
+ // Lift the panel into the top layer so it escapes an ancestor overflow clip. A shift form is a
68
+ // modal, and a time list long enough to scroll is exactly the panel a card would cut off.
69
+ usePopover(panelRef, triggerRef, open);
70
+
71
+ // Popover attr for React 18 (JSX may not type it): set once on mount.
72
+ useEffect((): void => {
73
+ panelRef.current?.setAttribute('popover', 'manual');
74
+ }, []);
75
+
76
+ const isDisabled: boolean = props.disabled === true;
77
+ const isReadonly: boolean = props.readonly === true;
78
+ const isInvalid: boolean = props.invalid === true;
79
+
80
+ const times: ReadonlyArray<string> = useMemo((): ReadonlyArray<string> => {
81
+ const stride: number = Math.max(1, Math.trunc(props.step ?? 30));
82
+ const from: number = bound(props.min, 0);
83
+ const to: number = bound(props.max, 23 * 60 + 59);
84
+ const out: string[] = [];
85
+ for (let m = from; m <= to; m += stride) out.push(fromMinutes(m));
86
+ return out;
87
+ }, [props.step, props.min, props.max]);
88
+
89
+ const value: string = normalise(props.value);
90
+ const selectedIndex: number = times.indexOf(value);
91
+ const shown: string = value !== '' ? value : (props.placeholder ?? '--:--');
92
+ const isPlaceholder: boolean = value === '';
93
+ const activeDescendant: string | undefined = open && highlighted >= 0 ? optionId(highlighted) : undefined;
94
+
95
+ /* The panel is `max-height:15rem` and scrolls: a highlight the reader cannot see is no highlight,
96
+ and with 48 half-hour options most of the list is out of view. */
97
+ useEffect((): void => {
98
+ const list: HTMLUListElement | null = panelRef.current;
99
+ if (list === null || highlighted < 0) return;
100
+ const opt: Element | undefined = list.children[highlighted];
101
+ if (opt instanceof HTMLElement) opt.scrollIntoView({ block: 'nearest' });
102
+ }, [highlighted, open]);
103
+
104
+ /* Clamped, not cyclic: FdyCombo wraps because its lists are short. Here ArrowUp at 00:00 landing
105
+ on 23:30 reads as a glitch. Matches the raw path. */
106
+ const setHighlight = (index: number): void => {
107
+ const last: number = times.length - 1;
108
+ setHighlighted(last < 0 ? -1 : Math.min(last, Math.max(0, index)));
109
+ };
110
+ const openList = (): void => {
111
+ if (isDisabled || isReadonly || open) return;
112
+ setOpen(true);
113
+ setHighlight(selectedIndex >= 0 ? selectedIndex : 0);
114
+ };
115
+ const closeList = (focusTrigger: boolean): void => {
116
+ setOpen(false);
117
+ setHighlighted(-1);
118
+ if (focusTrigger) triggerRef.current?.focus();
119
+ };
120
+ const toggle = (): void => { if (open) closeList(false); else openList(); };
121
+ const choose = (index: number): void => {
122
+ const next: string | undefined = times[index];
123
+ if (next === undefined) return;
124
+ if (next !== value) props.onChange(next);
125
+ closeList(true);
126
+ };
127
+
128
+ const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>): void => {
129
+ if (isDisabled || isReadonly) return;
130
+ switch (e.key) {
131
+ case 'ArrowDown': e.preventDefault(); if (open) setHighlight(highlighted + 1); else openList(); break;
132
+ case 'ArrowUp': e.preventDefault(); if (open) setHighlight(highlighted - 1); else openList(); break;
133
+ case 'Home': if (open) { e.preventDefault(); setHighlight(0); } break;
134
+ case 'End': if (open) { e.preventDefault(); setHighlight(times.length - 1); } break;
135
+ case 'Enter':
136
+ case ' ': e.preventDefault(); if (open && highlighted >= 0) choose(highlighted); else openList(); break;
137
+ case 'Escape': if (open) { e.preventDefault(); closeList(true); } break;
138
+ case 'Tab': if (open) closeList(false); break;
139
+ default: break;
140
+ }
141
+ };
142
+
143
+ const onFocusout = (e: React.FocusEvent<HTMLDivElement>): void => {
144
+ const next: EventTarget | null = e.relatedTarget;
145
+ if (rootRef.current !== null && !(next instanceof Node && rootRef.current.contains(next))) closeList(false);
146
+ };
147
+
148
+ useEffect((): void | (() => void) => {
149
+ if (!open) return;
150
+ const onDocPointerDown = (e: MouseEvent): void => {
151
+ const t: EventTarget | null = e.target;
152
+ if (rootRef.current !== null && t instanceof Node && !rootRef.current.contains(t)) closeList(false);
153
+ };
154
+ document.addEventListener('mousedown', onDocPointerDown);
155
+ return (): void => document.removeEventListener('mousedown', onDocPointerDown);
156
+ }, [open]);
157
+
158
+ return (
159
+ <div
160
+ ref={rootRef}
161
+ className={isInvalid ? 'fdy-timepicker fdy-timepicker--error' : 'fdy-timepicker'}
162
+ data-value={value}
163
+ onKeyDown={onKeyDown}
164
+ onBlur={onFocusout}
165
+ >
166
+ <button
167
+ id={triggerId}
168
+ ref={triggerRef}
169
+ type="button"
170
+ className={open ? 'fdy-timepicker__trigger is-open' : 'fdy-timepicker__trigger'}
171
+ aria-haspopup="listbox"
172
+ aria-controls={panelId}
173
+ aria-expanded={open}
174
+ aria-activedescendant={activeDescendant}
175
+ aria-label={props.ariaLabel}
176
+ aria-labelledby={props.ariaLabelledby}
177
+ aria-invalid={isInvalid ? 'true' : undefined}
178
+ aria-readonly={isReadonly ? 'true' : undefined}
179
+ aria-describedby={props.describedby}
180
+ disabled={isDisabled}
181
+ onClick={toggle}
182
+ >
183
+ <span className={isPlaceholder ? 'fdy-timepicker__value fdy-timepicker__value--placeholder' : 'fdy-timepicker__value'}>{shown}</span>
184
+ <span className="fdy-timepicker__icon">
185
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
186
+ strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
187
+ <circle cx="12" cy="12" r="9" />
188
+ <path d="M12 7v5l3 2" />
189
+ </svg>
190
+ </span>
191
+ </button>
192
+ <ul id={panelId} ref={panelRef} className="fdy-timepicker__panel" role="listbox"
193
+ aria-label={props.ariaLabel} hidden={!open}>
194
+ {times.map((t: string, i: number): JSX.Element => (
195
+ // onMouseDown preventDefault keeps focus on the trigger: an <li> isn't focusable, so a
196
+ // plain mousedown moves focus out of the control, fires onBlur and closes the panel
197
+ // before the click lands. Same pattern as FdyCombo.
198
+ <li
199
+ id={optionId(i)}
200
+ key={t}
201
+ className={i === highlighted ? 'fdy-timepicker__opt is-active' : 'fdy-timepicker__opt'}
202
+ role="option"
203
+ aria-selected={t === value}
204
+ onMouseDown={(e: React.MouseEvent): void => e.preventDefault()}
205
+ onClick={(): void => choose(i)}
206
+ onMouseMove={(): void => setHighlight(i)}
207
+ >{t}</li>
208
+ ))}
209
+ </ul>
210
+ </div>
211
+ );
212
+ }
@@ -35,6 +35,7 @@ export { FdyDatepicker, type FdyDatepickerProps, type FdyDatepickerMode } from '
35
35
  export { FdyMonthpicker, type FdyMonthpickerProps } from './components/FdyMonthpicker';
36
36
  export type { FdyMonth } from '../core/month-grid';
37
37
  export { FdyDateRange, type FdyDateRangeProps, type DateRangeValue } from './components/FdyDateRange';
38
+ export { FdyTimepicker, type FdyTimepickerProps } from './components/FdyTimepicker';
38
39
  export { FdyAutocomplete, type FdyAutocompleteProps } from './components/FdyAutocomplete';
39
40
  export { FdyCascade, type FdyCascadeProps, type CascadeNode } from './components/FdyCascade';
40
41
  export { FdyCfl, type FdyCflProps, type FdyCflHandle, type CflColumn, type CflPage } from './components/FdyCfl';
@@ -4,6 +4,7 @@ export { FdyCombo } from './components/FdyCombo.tsx';
4
4
  export { FdyDatepicker } from './components/FdyDatepicker.tsx';
5
5
  export { FdyMonthpicker } from './components/FdyMonthpicker.tsx';
6
6
  export { FdyDateRange } from './components/FdyDateRange.tsx';
7
+ export { FdyTimepicker } from './components/FdyTimepicker.tsx';
7
8
  export { FdyAutocomplete } from './components/FdyAutocomplete.tsx';
8
9
  export { FdyCascade } from './components/FdyCascade.tsx';
9
10
  export { FdyCfl } from './components/FdyCfl.tsx';
@@ -29,6 +29,19 @@ const props = defineProps<{
29
29
  endPlaceholder?: string;
30
30
  ariaLabel?: string;
31
31
  ariaLabelledby?: string;
32
+ /* Forwarded verbatim to BOTH pickers. Without these the range was the one picker in the kit
33
+ whose calendar nav could not be named in the page's own language: FdyDatepicker takes them,
34
+ FdyDateRange dropped them, so an Indonesian app got "Previous month" on the only date control
35
+ it could not reach. Flat `xxxLabel` props, not a `labels` object, because that is the shape
36
+ every other component in the kit already uses. Raised from keamanan-cluster (#095). */
37
+ prevMonthLabel?: string;
38
+ nextMonthLabel?: string;
39
+ prevYearLabel?: string;
40
+ nextYearLabel?: string;
41
+ prevYearsLabel?: string;
42
+ nextYearsLabel?: string;
43
+ chooseMonthLabel?: string;
44
+ chooseYearLabel?: string;
32
45
  }>();
33
46
 
34
47
  const emit = defineEmits<{
@@ -69,6 +82,14 @@ function setEnd(end: string): void {
69
82
  :invalid="invalid"
70
83
  :describedby="describedby"
71
84
  :placeholder="startPlaceholder ?? 'Dari'"
85
+ :prev-month-label="prevMonthLabel"
86
+ :next-month-label="nextMonthLabel"
87
+ :prev-year-label="prevYearLabel"
88
+ :next-year-label="nextYearLabel"
89
+ :prev-years-label="prevYearsLabel"
90
+ :next-years-label="nextYearsLabel"
91
+ :choose-month-label="chooseMonthLabel"
92
+ :choose-year-label="chooseYearLabel"
72
93
  @update:model-value="setStart"
73
94
  />
74
95
  <span class="fdy-daterange__sep" aria-hidden="true">–</span>
@@ -82,6 +103,14 @@ function setEnd(end: string): void {
82
103
  :invalid="invalid"
83
104
  :describedby="describedby"
84
105
  :placeholder="endPlaceholder ?? 'Sampai'"
106
+ :prev-month-label="prevMonthLabel"
107
+ :next-month-label="nextMonthLabel"
108
+ :prev-year-label="prevYearLabel"
109
+ :next-year-label="nextYearLabel"
110
+ :prev-years-label="prevYearsLabel"
111
+ :next-years-label="nextYearsLabel"
112
+ :choose-month-label="chooseMonthLabel"
113
+ :choose-year-label="chooseYearLabel"
85
114
  @update:model-value="setEnd"
86
115
  />
87
116
  </div>
@@ -0,0 +1,276 @@
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
+ invalid?: boolean;
38
+ describedby?: string;
39
+ }>();
40
+
41
+ const emit = defineEmits<{
42
+ 'update:modelValue': [value: string];
43
+ change: [value: string];
44
+ }>();
45
+
46
+ const SHAPE: RegExp = /^\d{1,2}:\d{2}$/;
47
+
48
+ function toMinutes(time: string): number {
49
+ const [h, m] = time.split(':');
50
+ return Number(h) * 60 + Number(m);
51
+ }
52
+ function pad(n: number): string {
53
+ return n < 10 ? `0${n}` : String(n);
54
+ }
55
+ function fromMinutes(total: number): string {
56
+ return `${pad(Math.floor(total / 60))}:${pad(total % 60)}`;
57
+ }
58
+ /** `7:0` is not a time; `7:00` is the same time as `07:00`. Anything else answers `''`. */
59
+ function normalise(time: string | null): string {
60
+ if (time === null || !SHAPE.test(time)) return '';
61
+ const total: number = toMinutes(time);
62
+ return total >= 0 && total <= 23 * 60 + 59 ? fromMinutes(total) : '';
63
+ }
64
+ function bound(time: string | undefined, fallback: number): number {
65
+ return time !== undefined && SHAPE.test(time) ? toMinutes(time) : fallback;
66
+ }
67
+
68
+ const baseId: string = useId();
69
+ const triggerId: ComputedRef<string> = computed((): string => props.id ?? `${baseId}-trigger`);
70
+ const panelId: string = `${baseId}-panel`;
71
+ function optionId(index: number): string {
72
+ return `${baseId}-opt-${index}`;
73
+ }
74
+
75
+ const rootEl: Ref<HTMLDivElement | null> = ref(null);
76
+ const triggerEl: Ref<HTMLButtonElement | null> = ref(null);
77
+ const panelEl: Ref<HTMLUListElement | null> = ref(null);
78
+ const open: Ref<boolean> = ref(false);
79
+
80
+ // Lift the panel into the top layer so it escapes an ancestor overflow clip. A shift form is a
81
+ // modal, and a time list long enough to scroll is exactly the panel a card would cut off.
82
+ usePopover(panelEl, triggerEl, open);
83
+ const highlighted: Ref<number> = ref(-1);
84
+
85
+ const times: ComputedRef<ReadonlyArray<string>> = computed((): ReadonlyArray<string> => {
86
+ const stride: number = Math.max(1, Math.trunc(props.step ?? 30));
87
+ const from: number = bound(props.min, 0);
88
+ const to: number = bound(props.max, 23 * 60 + 59);
89
+ const out: string[] = [];
90
+ for (let m = from; m <= to; m += stride) out.push(fromMinutes(m));
91
+ return out;
92
+ });
93
+
94
+ const value: ComputedRef<string> = computed((): string => normalise(props.modelValue));
95
+ const selectedIndex: ComputedRef<number> = computed((): number => times.value.indexOf(value.value));
96
+ const shown: ComputedRef<string> = computed((): string =>
97
+ value.value !== '' ? value.value : (props.placeholder ?? '--:--'),
98
+ );
99
+ const isPlaceholder: ComputedRef<boolean> = computed((): boolean => value.value === '');
100
+ const isDisabled: ComputedRef<boolean> = computed((): boolean => props.disabled === true);
101
+ const isReadonly: ComputedRef<boolean> = computed((): boolean => props.readonly === true);
102
+ const isInvalid: ComputedRef<boolean> = computed((): boolean => props.invalid === true);
103
+ const activeDescendant: ComputedRef<string | undefined> = computed((): string | undefined =>
104
+ open.value && highlighted.value >= 0 ? optionId(highlighted.value) : undefined,
105
+ );
106
+
107
+ /* The panel is `max-height:15rem` and scrolls: a highlight the reader cannot see is no highlight,
108
+ and with 48 half-hour options most of the list is out of view. */
109
+ function revealHighlight(): void {
110
+ void nextTick((): void => {
111
+ const list: HTMLUListElement | null = panelEl.value;
112
+ if (list === null || highlighted.value < 0) return;
113
+ const opt: Element | undefined = list.children[highlighted.value];
114
+ if (opt instanceof HTMLElement) opt.scrollIntoView({ block: 'nearest' });
115
+ });
116
+ }
117
+
118
+ /* Clamped, not cyclic: FdyCombo wraps because its lists are short and a wrap is a quick way back
119
+ to the top. Here ArrowUp at 00:00 landing on 23:30 reads as a glitch. Matches the raw path. */
120
+ function setHighlight(index: number): void {
121
+ const last: number = times.value.length - 1;
122
+ highlighted.value = last < 0 ? -1 : Math.min(last, Math.max(0, index));
123
+ revealHighlight();
124
+ }
125
+
126
+ function openList(): void {
127
+ if (isDisabled.value || isReadonly.value || open.value) return;
128
+ open.value = true;
129
+ setHighlight(selectedIndex.value >= 0 ? selectedIndex.value : 0);
130
+ }
131
+
132
+ function closeList(focusTrigger: boolean): void {
133
+ if (!open.value) return;
134
+ open.value = false;
135
+ highlighted.value = -1;
136
+ if (focusTrigger) triggerEl.value?.focus();
137
+ }
138
+
139
+ function toggle(): void {
140
+ if (open.value) closeList(false);
141
+ else openList();
142
+ }
143
+
144
+ function choose(index: number): void {
145
+ const next: string | undefined = times.value[index];
146
+ if (next === undefined) return;
147
+ if (next !== value.value) {
148
+ emit('update:modelValue', next);
149
+ emit('change', next);
150
+ }
151
+ closeList(true);
152
+ }
153
+
154
+ function onKeydown(e: KeyboardEvent): void {
155
+ if (isDisabled.value || isReadonly.value) return;
156
+ switch (e.key) {
157
+ case 'ArrowDown':
158
+ e.preventDefault();
159
+ if (open.value) setHighlight(highlighted.value + 1);
160
+ else openList();
161
+ break;
162
+ case 'ArrowUp':
163
+ e.preventDefault();
164
+ if (open.value) setHighlight(highlighted.value - 1);
165
+ else openList();
166
+ break;
167
+ case 'Home':
168
+ if (open.value) {
169
+ e.preventDefault();
170
+ setHighlight(0);
171
+ }
172
+ break;
173
+ case 'End':
174
+ if (open.value) {
175
+ e.preventDefault();
176
+ setHighlight(times.value.length - 1);
177
+ }
178
+ break;
179
+ case 'Enter':
180
+ case ' ':
181
+ e.preventDefault();
182
+ if (open.value && highlighted.value >= 0) choose(highlighted.value);
183
+ else openList();
184
+ break;
185
+ case 'Escape':
186
+ if (open.value) {
187
+ e.preventDefault();
188
+ closeList(true);
189
+ }
190
+ break;
191
+ case 'Tab':
192
+ if (open.value) closeList(false);
193
+ break;
194
+ default:
195
+ break;
196
+ }
197
+ }
198
+
199
+ function onDocPointerDown(e: MouseEvent): void {
200
+ const target: EventTarget | null = e.target;
201
+ if (rootEl.value !== null && target instanceof Node && !rootEl.value.contains(target)) {
202
+ closeList(false);
203
+ }
204
+ }
205
+
206
+ function onFocusout(e: FocusEvent): void {
207
+ const next: EventTarget | null = e.relatedTarget;
208
+ if (rootEl.value !== null && !(next instanceof Node && rootEl.value.contains(next))) {
209
+ closeList(false);
210
+ }
211
+ }
212
+
213
+ onMounted((): void => {
214
+ document.addEventListener('mousedown', onDocPointerDown);
215
+ });
216
+ onBeforeUnmount((): void => {
217
+ document.removeEventListener('mousedown', onDocPointerDown);
218
+ });
219
+ </script>
220
+
221
+ <template>
222
+ <div
223
+ ref="rootEl"
224
+ class="fdy-timepicker"
225
+ :class="{ 'fdy-timepicker--error': isInvalid }"
226
+ :data-value="value"
227
+ @keydown="onKeydown"
228
+ @focusout="onFocusout"
229
+ >
230
+ <button
231
+ :id="triggerId"
232
+ ref="triggerEl"
233
+ type="button"
234
+ class="fdy-timepicker__trigger"
235
+ :class="{ 'is-open': open }"
236
+ aria-haspopup="listbox"
237
+ :aria-controls="panelId"
238
+ :aria-expanded="open"
239
+ :aria-activedescendant="activeDescendant"
240
+ :aria-label="ariaLabel"
241
+ :aria-labelledby="ariaLabelledby"
242
+ :aria-invalid="isInvalid ? 'true' : undefined"
243
+ :aria-readonly="isReadonly ? 'true' : undefined"
244
+ :aria-describedby="describedby"
245
+ :disabled="isDisabled"
246
+ @click="toggle"
247
+ >
248
+ <span class="fdy-timepicker__value" :class="{ 'fdy-timepicker__value--placeholder': isPlaceholder }">{{ shown }}</span>
249
+ <span class="fdy-timepicker__icon">
250
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
251
+ stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
252
+ <circle cx="12" cy="12" r="9"></circle>
253
+ <path d="M12 7v5l3 2"></path>
254
+ </svg>
255
+ </span>
256
+ </button>
257
+ <ul :id="panelId" ref="panelEl" class="fdy-timepicker__panel" role="listbox"
258
+ :aria-label="ariaLabel" popover="manual" :hidden="!open">
259
+ <!-- @mousedown.prevent keeps focus on the trigger: an <li> isn't focusable, so a plain
260
+ mousedown moves focus out of the control, fires @focusout and closes the panel before
261
+ the click lands — mouse-select would silently do nothing. Same as FdyCombo. -->
262
+ <li
263
+ v-for="(t, i) in times"
264
+ :id="optionId(i)"
265
+ :key="t"
266
+ class="fdy-timepicker__opt"
267
+ :class="{ 'is-active': i === highlighted }"
268
+ role="option"
269
+ :aria-selected="t === value"
270
+ @mousedown.prevent
271
+ @click="choose(i)"
272
+ @mousemove="setHighlight(i)"
273
+ >{{ t }}</li>
274
+ </ul>
275
+ </div>
276
+ </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';
@@ -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,6 +104,30 @@
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() {
101
133
  if (value) {
@@ -124,6 +156,7 @@
124
156
  var _pop = null;
125
157
  function popCtl() { if (_pop === null && window.FreedayPopover) _pop = window.FreedayPopover.attach(panel, trigger); return _pop; }
126
158
  function open() {
159
+ if (stateDisabled || stateReadonly) return;
127
160
  if (!panel.hidden) return;
128
161
  var p = popCtl(); if (p) p.show(); else panel.hidden = false;
129
162
  trigger.setAttribute('aria-expanded', 'true');
@@ -174,10 +207,12 @@
174
207
  });
175
208
 
176
209
  updateDisplay();
210
+ applyState();
177
211
 
178
212
  var api = {
179
213
  wrap: wrap,
180
214
  getValue: function () { return value; },
215
+ setState: setState,
181
216
  clear: function () { value = ''; for (var i = 0; i < opts.length; i++) opts[i].setAttribute('aria-selected', 'false'); updateDisplay(); }
182
217
  };
183
218
  wrap._fdyTp = api;
@@ -197,5 +232,13 @@
197
232
  initAll();
198
233
  }
199
234
 
200
- window.FreedayTimepicker = { init: initTime, initAll: initAll };
235
+ window.FreedayTimepicker = {
236
+ init: initTime,
237
+ initAll: initAll,
238
+ /* See api.setState above: the one call a render-once host needs. */
239
+ setState: function (root, state) {
240
+ var api = root && root._fdyTp ? root._fdyTp : null;
241
+ if (api && api.setState) api.setState(state);
242
+ }
243
+ };
201
244
  })();