@nepdatejs/react 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +74 -2
- package/dist/date-text-BX3Uag0O.cjs +84 -0
- package/dist/date-text-BX3Uag0O.cjs.map +1 -0
- package/dist/date-text-DLwhBmYj.js +61 -0
- package/dist/date-text-DLwhBmYj.js.map +1 -0
- package/dist/date-text-Dipetpbp.d.cts +37 -0
- package/dist/date-text-Dipetpbp.d.ts +37 -0
- package/dist/headless-AEI6dxTJ.cjs +619 -0
- package/dist/headless-AEI6dxTJ.cjs.map +1 -0
- package/dist/headless-CVj0DhD4.js +560 -0
- package/dist/headless-CVj0DhD4.js.map +1 -0
- package/dist/headless-ChE3zuYj.d.ts +195 -0
- package/dist/headless-CrIIPnOF.d.cts +195 -0
- package/dist/headless.cjs +11 -0
- package/dist/headless.d.cts +2 -0
- package/dist/headless.d.ts +2 -0
- package/dist/headless.js +2 -0
- package/dist/index.cjs +915 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +245 -0
- package/dist/index.d.ts +245 -0
- package/dist/index.js +895 -0
- package/dist/index.js.map +1 -0
- package/dist/server.cjs +6 -0
- package/dist/server.d.cts +2 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +1 -0
- package/package.json +110 -3
- package/src/bindings.ts +247 -0
- package/src/calendar-body.tsx +133 -0
- package/src/calendar.tsx +34 -0
- package/src/date-picker.tsx +343 -0
- package/src/date-range-picker.tsx +225 -0
- package/src/date-text.tsx +110 -0
- package/src/date-time-picker.tsx +289 -0
- package/src/headless.ts +21 -0
- package/src/hooks.ts +476 -0
- package/src/icons.tsx +35 -0
- package/src/index.ts +21 -0
- package/src/multi-date-picker.tsx +245 -0
- package/src/page-locale.ts +23 -0
- package/src/panel.ts +67 -0
- package/src/presets.tsx +44 -0
- package/src/server.ts +8 -0
- package/src/time-columns.tsx +60 -0
- package/src/use-store.ts +196 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { useEffect, useRef, type CSSProperties, type ReactElement } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
getLocale,
|
|
4
|
+
type CalendarDate,
|
|
5
|
+
type CalendarSystem,
|
|
6
|
+
type LocaleCode,
|
|
7
|
+
type Weekday,
|
|
8
|
+
} from '@nepdatejs/core';
|
|
9
|
+
import type { EventMarker, HolidayInput, HolidayLookup } from '@nepdatejs/calendar';
|
|
10
|
+
import type { DateDetail } from '@nepdatejs/datepicker';
|
|
11
|
+
import { restoreFocus, type PanelLayout } from '@nepdatejs/dom';
|
|
12
|
+
import { CalendarBody } from './calendar-body';
|
|
13
|
+
import { useMultiDatePicker } from './hooks';
|
|
14
|
+
import { Icon } from './icons';
|
|
15
|
+
import { usePageLocale } from './page-locale';
|
|
16
|
+
import { usePanel } from './panel';
|
|
17
|
+
import { PANEL_WIDTH } from './presets';
|
|
18
|
+
|
|
19
|
+
export interface MultiDatePickerProps {
|
|
20
|
+
/** AD "YYYY-MM-DD" days. */
|
|
21
|
+
value?: readonly string[] | undefined;
|
|
22
|
+
defaultValue?: readonly string[] | undefined;
|
|
23
|
+
onChange?:
|
|
24
|
+
((value: readonly string[], dates: readonly DateDetail[]) => void) | undefined;
|
|
25
|
+
calendar?: CalendarSystem | undefined;
|
|
26
|
+
defaultCalendar?: CalendarSystem | undefined;
|
|
27
|
+
onCalendarChange?: ((calendar: CalendarSystem) => void) | undefined;
|
|
28
|
+
/** Earliest and latest allowed days, as AD "YYYY-MM-DD". */
|
|
29
|
+
min?: string | undefined;
|
|
30
|
+
max?: string | undefined;
|
|
31
|
+
/** The most days that may be chosen. */
|
|
32
|
+
maxDates?: number | undefined;
|
|
33
|
+
isDateDisabled?: ((date: CalendarDate) => boolean) | undefined;
|
|
34
|
+
holidays?: readonly HolidayInput[] | HolidayLookup | undefined;
|
|
35
|
+
events?: ((date: CalendarDate) => readonly EventMarker[]) | undefined;
|
|
36
|
+
locale?: LocaleCode | undefined;
|
|
37
|
+
weekStartsOn?: Weekday | undefined;
|
|
38
|
+
weekendDays?: readonly Weekday[] | undefined;
|
|
39
|
+
showOtherCalendar?: boolean | undefined;
|
|
40
|
+
/** Close the panel after each click. Default false. */
|
|
41
|
+
closeOnSelect?: boolean | undefined;
|
|
42
|
+
/** Add the dashes of YYYY-MM-DD while the user types digits. Default true. */
|
|
43
|
+
mask?: boolean | undefined;
|
|
44
|
+
/** Show the calendar in the page, always open, instead of in a popover. */
|
|
45
|
+
inline?: boolean | undefined;
|
|
46
|
+
/** 'sheet' opens a bottom sheet; 'popover' sits beside the field; 'auto' (default) uses the sheet below 480px. */
|
|
47
|
+
panelLayout?: PanelLayout | undefined;
|
|
48
|
+
/** Submit each day under this name, one hidden input per day. */
|
|
49
|
+
name?: string | undefined;
|
|
50
|
+
id?: string | undefined;
|
|
51
|
+
placeholder?: string | undefined;
|
|
52
|
+
required?: boolean | undefined;
|
|
53
|
+
disabled?: boolean | undefined;
|
|
54
|
+
clearable?: boolean | undefined;
|
|
55
|
+
today?: CalendarDate | undefined;
|
|
56
|
+
theme?: 'light' | 'dark' | 'auto' | undefined;
|
|
57
|
+
className?: string | undefined;
|
|
58
|
+
'aria-label'?: string | undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Several separate BS/AD days in one field (PICK-001): click days in the
|
|
63
|
+
* panel to add or remove them, or type them separated by commas. The value is
|
|
64
|
+
* the sorted AD days as "YYYY-MM-DD" strings.
|
|
65
|
+
*/
|
|
66
|
+
export function MultiDatePicker({
|
|
67
|
+
name,
|
|
68
|
+
placeholder,
|
|
69
|
+
required,
|
|
70
|
+
disabled = false,
|
|
71
|
+
clearable = true,
|
|
72
|
+
panelLayout,
|
|
73
|
+
theme,
|
|
74
|
+
className,
|
|
75
|
+
'aria-label': ariaLabel,
|
|
76
|
+
...options
|
|
77
|
+
}: MultiDatePickerProps): ReactElement {
|
|
78
|
+
const locale = usePageLocale(options.locale);
|
|
79
|
+
const picker = useMultiDatePicker({ ...options, locale });
|
|
80
|
+
const { state, store, calendar } = picker;
|
|
81
|
+
const labels = getLocale(locale);
|
|
82
|
+
const field = useRef<HTMLDivElement | null>(null);
|
|
83
|
+
const box = useRef<HTMLDivElement | null>(null);
|
|
84
|
+
const panel = useRef<HTMLDivElement | null>(null);
|
|
85
|
+
const input = useRef<HTMLInputElement | null>(null);
|
|
86
|
+
|
|
87
|
+
usePanel(
|
|
88
|
+
state.open && !state.inline,
|
|
89
|
+
{ anchor: box, panel, field, returnFocus: input },
|
|
90
|
+
() => {
|
|
91
|
+
store.actions.close();
|
|
92
|
+
},
|
|
93
|
+
calendar.focusGrid,
|
|
94
|
+
PANEL_WIDTH,
|
|
95
|
+
panelLayout,
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
// A form reset puts the field back to its default value.
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
const form = input.current?.form;
|
|
101
|
+
if (!form) {
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
const reset = (): void => {
|
|
105
|
+
store.actions.reset();
|
|
106
|
+
};
|
|
107
|
+
form.addEventListener('reset', reset);
|
|
108
|
+
|
|
109
|
+
return () => {
|
|
110
|
+
form.removeEventListener('reset', reset);
|
|
111
|
+
};
|
|
112
|
+
}, [store]);
|
|
113
|
+
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
input.current?.setCustomValidity(state.error?.message ?? '');
|
|
116
|
+
}, [state.error]);
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<div
|
|
120
|
+
ref={field}
|
|
121
|
+
className={className ? `ndp-field ${className}` : 'ndp-field'}
|
|
122
|
+
data-ndp-theme={theme}
|
|
123
|
+
>
|
|
124
|
+
<div
|
|
125
|
+
ref={box}
|
|
126
|
+
className="ndp-input"
|
|
127
|
+
data-invalid={state.error !== null || undefined}
|
|
128
|
+
data-disabled={disabled || undefined}
|
|
129
|
+
>
|
|
130
|
+
<input
|
|
131
|
+
ref={input}
|
|
132
|
+
className="ndp-input__control"
|
|
133
|
+
{...picker.getInputProps()}
|
|
134
|
+
aria-label={ariaLabel}
|
|
135
|
+
placeholder={placeholder ?? state.placeholder}
|
|
136
|
+
required={required}
|
|
137
|
+
disabled={disabled}
|
|
138
|
+
/>
|
|
139
|
+
<span className="ndp-input__actions">
|
|
140
|
+
{clearable && state.text !== '' && !disabled && (
|
|
141
|
+
<button
|
|
142
|
+
className="ndp-icon-button"
|
|
143
|
+
{...picker.getClearProps()}
|
|
144
|
+
onClick={() => {
|
|
145
|
+
store.actions.clear();
|
|
146
|
+
input.current?.focus();
|
|
147
|
+
}}
|
|
148
|
+
>
|
|
149
|
+
<Icon name="close" />
|
|
150
|
+
</button>
|
|
151
|
+
)}
|
|
152
|
+
{!state.inline && (
|
|
153
|
+
<button
|
|
154
|
+
className="ndp-icon-button"
|
|
155
|
+
disabled={disabled}
|
|
156
|
+
{...picker.getToggleProps()}
|
|
157
|
+
>
|
|
158
|
+
<Icon name="calendar" />
|
|
159
|
+
</button>
|
|
160
|
+
)}
|
|
161
|
+
</span>
|
|
162
|
+
</div>
|
|
163
|
+
{state.error && (
|
|
164
|
+
<p id={picker.ids.hint} className="ndp-hint" data-error aria-live="polite">
|
|
165
|
+
{state.error.message}
|
|
166
|
+
</p>
|
|
167
|
+
)}
|
|
168
|
+
{name !== undefined &&
|
|
169
|
+
state.value.map((day) => (
|
|
170
|
+
<input key={day} type="hidden" name={name} value={day} disabled={disabled} />
|
|
171
|
+
))}
|
|
172
|
+
{state.open && (
|
|
173
|
+
<div
|
|
174
|
+
ref={panel}
|
|
175
|
+
className="ndp-panel"
|
|
176
|
+
popover={state.inline ? undefined : 'manual'}
|
|
177
|
+
data-layout={state.inline ? 'inline' : undefined}
|
|
178
|
+
style={
|
|
179
|
+
state.inline
|
|
180
|
+
? ({ '--ndp-panel-width': `${String(PANEL_WIDTH)}px` } as CSSProperties)
|
|
181
|
+
: undefined
|
|
182
|
+
}
|
|
183
|
+
data-ndp-theme={theme}
|
|
184
|
+
{...picker.getPanelProps()}
|
|
185
|
+
aria-label={
|
|
186
|
+
ariaLabel
|
|
187
|
+
? `${ariaLabel}: ${labels.labels.calendar}`
|
|
188
|
+
: labels.labels.chooseDate
|
|
189
|
+
}
|
|
190
|
+
>
|
|
191
|
+
<CalendarBody
|
|
192
|
+
bindings={{
|
|
193
|
+
...calendar,
|
|
194
|
+
getDayProps: (cell) => ({
|
|
195
|
+
...calendar.getDayProps(cell),
|
|
196
|
+
onClick: () => {
|
|
197
|
+
if (
|
|
198
|
+
!cell.isDisabled &&
|
|
199
|
+
store.actions.select(cell.date) &&
|
|
200
|
+
!store.getState().open
|
|
201
|
+
) {
|
|
202
|
+
restoreFocus(input.current);
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
}),
|
|
206
|
+
}}
|
|
207
|
+
/>
|
|
208
|
+
<div className="ndp-panel__footer">
|
|
209
|
+
{state.canSwitchCalendar ? (
|
|
210
|
+
<div
|
|
211
|
+
className="ndp-segment"
|
|
212
|
+
role="group"
|
|
213
|
+
aria-label={labels.labels.calendar}
|
|
214
|
+
>
|
|
215
|
+
{(['BS', 'AD'] as const).map((option) => (
|
|
216
|
+
<button
|
|
217
|
+
key={option}
|
|
218
|
+
className="ndp-segment__item"
|
|
219
|
+
{...picker.getCalendarOptionProps(option)}
|
|
220
|
+
>
|
|
221
|
+
{labels.calendarNames[option]}
|
|
222
|
+
</button>
|
|
223
|
+
))}
|
|
224
|
+
</div>
|
|
225
|
+
) : (
|
|
226
|
+
<span />
|
|
227
|
+
)}
|
|
228
|
+
{!state.inline && (
|
|
229
|
+
<button
|
|
230
|
+
type="button"
|
|
231
|
+
className="ndp-button"
|
|
232
|
+
onClick={() => {
|
|
233
|
+
store.actions.close();
|
|
234
|
+
restoreFocus(input.current);
|
|
235
|
+
}}
|
|
236
|
+
>
|
|
237
|
+
{labels.labels.done}
|
|
238
|
+
</button>
|
|
239
|
+
)}
|
|
240
|
+
</div>
|
|
241
|
+
</div>
|
|
242
|
+
)}
|
|
243
|
+
</div>
|
|
244
|
+
);
|
|
245
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { useSyncExternalStore } from 'react';
|
|
2
|
+
import { getConfig, type LocaleCode } from '@nepdatejs/core';
|
|
3
|
+
|
|
4
|
+
const neverChanges = (): (() => void) => () => undefined;
|
|
5
|
+
|
|
6
|
+
/** Used on the server and while hydrating, so both render the same text. */
|
|
7
|
+
function defaultLocale(): LocaleCode {
|
|
8
|
+
return getConfig().defaultLocale;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Nepali names and digits when the page language is Nepali. Browser only. */
|
|
12
|
+
function pageLocale(): LocaleCode {
|
|
13
|
+
return document.documentElement.lang.toLowerCase().startsWith('ne')
|
|
14
|
+
? 'ne'
|
|
15
|
+
: defaultLocale();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The given locale, or else the page's language. */
|
|
19
|
+
export function usePageLocale(given: LocaleCode | undefined): LocaleCode {
|
|
20
|
+
const page = useSyncExternalStore(neverChanges, pageLocale, defaultLocale);
|
|
21
|
+
|
|
22
|
+
return given ?? page;
|
|
23
|
+
}
|
package/src/panel.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { useEffect, useRef, type RefObject } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
onBackdropPointer,
|
|
4
|
+
onEscape,
|
|
5
|
+
onOutsidePointer,
|
|
6
|
+
restoreFocus,
|
|
7
|
+
trackPosition,
|
|
8
|
+
type PanelLayout,
|
|
9
|
+
} from '@nepdatejs/dom';
|
|
10
|
+
import { useIsomorphicLayoutEffect } from './use-store';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* While `open`: show the panel in the top layer beside the anchor, close on an
|
|
14
|
+
* outside pointer, a tap on a bottom sheet's backdrop, or Escape (returning focus to `returnFocus`), and move focus
|
|
15
|
+
* into the calendar.
|
|
16
|
+
*/
|
|
17
|
+
export function usePanel(
|
|
18
|
+
open: boolean,
|
|
19
|
+
refs: {
|
|
20
|
+
anchor: RefObject<HTMLElement | null>;
|
|
21
|
+
panel: RefObject<HTMLDivElement | null>;
|
|
22
|
+
field: RefObject<HTMLElement | null>;
|
|
23
|
+
returnFocus: RefObject<HTMLElement | null>;
|
|
24
|
+
},
|
|
25
|
+
close: () => void,
|
|
26
|
+
focusGrid: () => void,
|
|
27
|
+
width = 320,
|
|
28
|
+
layout: PanelLayout = 'auto',
|
|
29
|
+
): void {
|
|
30
|
+
const closeRef = useRef(close);
|
|
31
|
+
closeRef.current = close;
|
|
32
|
+
|
|
33
|
+
useIsomorphicLayoutEffect(() => {
|
|
34
|
+
const anchor = refs.anchor.current;
|
|
35
|
+
const panel = refs.panel.current;
|
|
36
|
+
if (!open || !anchor || !panel) {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
const stopTracking = trackPosition(anchor, panel, { width, layout });
|
|
40
|
+
const stopOutside = onOutsidePointer(
|
|
41
|
+
() => [refs.field.current, refs.panel.current],
|
|
42
|
+
() => {
|
|
43
|
+
closeRef.current();
|
|
44
|
+
},
|
|
45
|
+
);
|
|
46
|
+
const stopBackdrop = onBackdropPointer(panel, () => {
|
|
47
|
+
closeRef.current();
|
|
48
|
+
});
|
|
49
|
+
const stopEscape = onEscape(() => {
|
|
50
|
+
closeRef.current();
|
|
51
|
+
restoreFocus(refs.returnFocus.current);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
return () => {
|
|
55
|
+
stopTracking();
|
|
56
|
+
stopOutside();
|
|
57
|
+
stopBackdrop();
|
|
58
|
+
stopEscape();
|
|
59
|
+
};
|
|
60
|
+
}, [open, width, layout]);
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (open) {
|
|
64
|
+
focusGrid();
|
|
65
|
+
}
|
|
66
|
+
}, [open]);
|
|
67
|
+
}
|
package/src/presets.tsx
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ReactElement, ReactNode } from 'react';
|
|
2
|
+
import type { PresetState } from '@nepdatejs/datepicker';
|
|
3
|
+
|
|
4
|
+
/** Panel widths: calendar alone, plus presets column, plus time columns. */
|
|
5
|
+
export const PANEL_WIDTH = 320;
|
|
6
|
+
export const PANEL_WIDTH_WITH_PRESETS = 492;
|
|
7
|
+
export const TIME_COLUMNS_WIDTH = 136;
|
|
8
|
+
|
|
9
|
+
/** The panel body: the shortcut list (when there are presets), then the calendar and anything else. */
|
|
10
|
+
export function PanelBody({
|
|
11
|
+
presets,
|
|
12
|
+
label,
|
|
13
|
+
onPreset,
|
|
14
|
+
children,
|
|
15
|
+
}: {
|
|
16
|
+
presets: readonly PresetState[];
|
|
17
|
+
label: string;
|
|
18
|
+
onPreset: (id: string) => void;
|
|
19
|
+
children: ReactNode;
|
|
20
|
+
}): ReactElement {
|
|
21
|
+
return (
|
|
22
|
+
<div className="ndp-panel__body">
|
|
23
|
+
{presets.length > 0 && (
|
|
24
|
+
<div className="ndp-presets" role="group" aria-label={label}>
|
|
25
|
+
{presets.map((preset) => (
|
|
26
|
+
<button
|
|
27
|
+
key={preset.id}
|
|
28
|
+
type="button"
|
|
29
|
+
className="ndp-preset"
|
|
30
|
+
aria-pressed={preset.isActive}
|
|
31
|
+
disabled={preset.isDisabled}
|
|
32
|
+
onClick={() => {
|
|
33
|
+
onPreset(preset.id);
|
|
34
|
+
}}
|
|
35
|
+
>
|
|
36
|
+
{preset.label}
|
|
37
|
+
</button>
|
|
38
|
+
))}
|
|
39
|
+
</div>
|
|
40
|
+
)}
|
|
41
|
+
{children}
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { useEffect, useRef, type ReactElement } from 'react';
|
|
2
|
+
import { getLocale, type LocaleCode } from '@nepdatejs/core';
|
|
3
|
+
import type { TimeCell } from '@nepdatejs/datepicker';
|
|
4
|
+
|
|
5
|
+
/** Hour and minute columns (Figma "Time Item"); the selected items scroll into view. */
|
|
6
|
+
export function TimeColumns({
|
|
7
|
+
hours,
|
|
8
|
+
minutes,
|
|
9
|
+
locale,
|
|
10
|
+
onHour,
|
|
11
|
+
onMinute,
|
|
12
|
+
}: {
|
|
13
|
+
hours: readonly TimeCell[];
|
|
14
|
+
minutes: readonly TimeCell[];
|
|
15
|
+
locale?: LocaleCode | undefined;
|
|
16
|
+
onHour: (hour: number) => void;
|
|
17
|
+
onMinute: (minute: number) => void;
|
|
18
|
+
}): ReactElement {
|
|
19
|
+
const root = useRef<HTMLDivElement | null>(null);
|
|
20
|
+
const labels = getLocale(locale).labels;
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
for (const item of root.current?.querySelectorAll<HTMLElement>(
|
|
24
|
+
'[aria-pressed="true"]',
|
|
25
|
+
) ?? []) {
|
|
26
|
+
if (typeof item.scrollIntoView === 'function') {
|
|
27
|
+
item.scrollIntoView({ block: 'nearest' });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}, []);
|
|
31
|
+
|
|
32
|
+
const column = (
|
|
33
|
+
label: string,
|
|
34
|
+
cells: readonly TimeCell[],
|
|
35
|
+
pick: (value: number) => void,
|
|
36
|
+
): ReactElement => (
|
|
37
|
+
<div className="ndp-time__column" role="group" aria-label={label}>
|
|
38
|
+
{cells.map((cell) => (
|
|
39
|
+
<button
|
|
40
|
+
key={cell.value}
|
|
41
|
+
type="button"
|
|
42
|
+
className="ndp-time-item"
|
|
43
|
+
aria-pressed={cell.isSelected}
|
|
44
|
+
onClick={() => {
|
|
45
|
+
pick(cell.value);
|
|
46
|
+
}}
|
|
47
|
+
>
|
|
48
|
+
{cell.label}
|
|
49
|
+
</button>
|
|
50
|
+
))}
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div ref={root} className="ndp-time" role="group" aria-label={labels.time}>
|
|
56
|
+
{column(labels.hour, hours, onHour)}
|
|
57
|
+
{column(labels.minute, minutes, onMinute)}
|
|
58
|
+
</div>
|
|
59
|
+
);
|
|
60
|
+
}
|
package/src/use-store.ts
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useEffect,
|
|
3
|
+
useLayoutEffect,
|
|
4
|
+
useRef,
|
|
5
|
+
useState,
|
|
6
|
+
useSyncExternalStore,
|
|
7
|
+
} from 'react';
|
|
8
|
+
|
|
9
|
+
interface StoreLike<O, S> {
|
|
10
|
+
getState(): S;
|
|
11
|
+
subscribe(listener: () => void): () => void;
|
|
12
|
+
setOptions(changes: Partial<O>): void;
|
|
13
|
+
readonly actions?: object;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Function options whose answers decide how the calendar's days look. */
|
|
17
|
+
const DAY_FUNCTIONS = ['isDateDisabled', 'holidays', 'events'] as const;
|
|
18
|
+
|
|
19
|
+
interface DayAnswers {
|
|
20
|
+
/** The days on screen; moving to another month isn't a change of answers. */
|
|
21
|
+
readonly days: string;
|
|
22
|
+
readonly answers: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** useLayoutEffect in the browser, useEffect on the server (no warning). */
|
|
26
|
+
export const useIsomorphicLayoutEffect: typeof useEffect =
|
|
27
|
+
typeof window === 'undefined' ? useEffect : useLayoutEffect;
|
|
28
|
+
|
|
29
|
+
type Fn = (...args: never[]) => unknown;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Create a store once and keep its options in step with props, without
|
|
33
|
+
* loops: functions are wrapped once so their identity never changes, and data
|
|
34
|
+
* options are compared by value, so inline objects and arrays are fine.
|
|
35
|
+
*/
|
|
36
|
+
export function useStore<O extends object, S, T extends StoreLike<O, S>>(
|
|
37
|
+
create: (options: O) => T,
|
|
38
|
+
options: O,
|
|
39
|
+
): { store: T; state: S } {
|
|
40
|
+
const latest = useRef(options);
|
|
41
|
+
latest.current = options;
|
|
42
|
+
const wrappers = useRef(new Map<string, Fn>());
|
|
43
|
+
|
|
44
|
+
const prepare = (input: O): O => {
|
|
45
|
+
const out: Record<string, unknown> = {};
|
|
46
|
+
for (const [key, value] of Object.entries(input)) {
|
|
47
|
+
if (typeof value === 'function') {
|
|
48
|
+
let wrapper = wrappers.current.get(key);
|
|
49
|
+
if (!wrapper) {
|
|
50
|
+
wrapper = (...args: never[]) =>
|
|
51
|
+
((latest.current as Record<string, unknown>)[key] as Fn | undefined)?.(
|
|
52
|
+
...args,
|
|
53
|
+
);
|
|
54
|
+
wrappers.current.set(key, wrapper);
|
|
55
|
+
}
|
|
56
|
+
out[key] = wrapper;
|
|
57
|
+
} else {
|
|
58
|
+
out[key] = value;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return out as O;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const [store] = useState(() => create(prepare(options)));
|
|
66
|
+
const previous = useRef<O>(prepare(options));
|
|
67
|
+
const dayAnswers = useRef<DayAnswers | null>(null);
|
|
68
|
+
const storeValue = useRef(valueOf(store.getState()));
|
|
69
|
+
|
|
70
|
+
useIsomorphicLayoutEffect(() => {
|
|
71
|
+
const next = prepare(options) as Record<string, unknown>;
|
|
72
|
+
const before = previous.current as Record<string, unknown>;
|
|
73
|
+
const changes: Record<string, unknown> = {};
|
|
74
|
+
for (const key of new Set([...Object.keys(before), ...Object.keys(next)])) {
|
|
75
|
+
if (!deepEqual(before[key], next[key])) {
|
|
76
|
+
changes[key] = next[key];
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
previous.current = next as O;
|
|
80
|
+
|
|
81
|
+
// An inline function is wrapped once, so it never looks changed above. Ask it
|
|
82
|
+
// about the days on screen instead, and redraw only if an answer is different.
|
|
83
|
+
const current = askDayFunctions(options, store.getState());
|
|
84
|
+
const asked = dayAnswers.current;
|
|
85
|
+
dayAnswers.current = current;
|
|
86
|
+
if (asked?.days === current?.days && asked?.answers !== current?.answers) {
|
|
87
|
+
for (const key of DAY_FUNCTIONS) {
|
|
88
|
+
if (key in next) {
|
|
89
|
+
changes[key] = next[key];
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (Object.keys(changes).length > 0) {
|
|
94
|
+
store.setOptions(changes as Partial<O>);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// A controlled picker shows what its parent holds. If the person entered a
|
|
98
|
+
// date and the parent kept its value, put that value back.
|
|
99
|
+
const value = valueOf(store.getState());
|
|
100
|
+
const changedByUser = !deepEqual(value, storeValue.current) && !('value' in changes);
|
|
101
|
+
storeValue.current = value;
|
|
102
|
+
const held = next['value'];
|
|
103
|
+
if (
|
|
104
|
+
changedByUser &&
|
|
105
|
+
held !== undefined &&
|
|
106
|
+
isChosen(value) &&
|
|
107
|
+
!deepEqual(value, held)
|
|
108
|
+
) {
|
|
109
|
+
(store.actions as { setValue?: (value: unknown) => void } | undefined)?.setValue?.(
|
|
110
|
+
held,
|
|
111
|
+
);
|
|
112
|
+
storeValue.current = valueOf(store.getState());
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const state = useSyncExternalStore(
|
|
117
|
+
(listener) => store.subscribe(listener),
|
|
118
|
+
() => store.getState(),
|
|
119
|
+
() => store.getState(),
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
return { store, state };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function valueOf(state: unknown): unknown {
|
|
126
|
+
return (state as { value?: unknown }).value;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** A finished value: not empty, half typed, or a range with one end still to pick. */
|
|
130
|
+
function isChosen(value: unknown): boolean {
|
|
131
|
+
if (Array.isArray(value)) {
|
|
132
|
+
return value.length > 0;
|
|
133
|
+
}
|
|
134
|
+
if (typeof value === 'object' && value !== null) {
|
|
135
|
+
return Object.values(value).every((part) => part !== '');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return value !== '' && value !== undefined;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** What the day functions say about the days on screen, or null when there are none. */
|
|
142
|
+
function askDayFunctions(options: object, state: unknown): DayAnswers | null {
|
|
143
|
+
const given = DAY_FUNCTIONS.flatMap((key) => {
|
|
144
|
+
const option = (options as Record<string, unknown>)[key];
|
|
145
|
+
|
|
146
|
+
return typeof option === 'function' ? [option as (date: unknown) => unknown] : [];
|
|
147
|
+
});
|
|
148
|
+
if (given.length === 0) {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
const { weeks, calendarState } = state as {
|
|
152
|
+
weeks?: readonly (readonly ({ date: unknown } | null)[])[];
|
|
153
|
+
calendarState?: { weeks: readonly (readonly ({ date: unknown } | null)[])[] };
|
|
154
|
+
};
|
|
155
|
+
const dates = (weeks ?? calendarState?.weeks ?? [])
|
|
156
|
+
.flat()
|
|
157
|
+
.flatMap((cell) => (cell ? [cell.date] : []));
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
days: JSON.stringify(dates),
|
|
161
|
+
answers: JSON.stringify(dates.map((date) => given.map((ask) => ask(date)))),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Structural equality for plain data (dates, ranges, lists); identity for everything else. */
|
|
166
|
+
export function deepEqual(a: unknown, b: unknown): boolean {
|
|
167
|
+
if (Object.is(a, b)) {
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
174
|
+
return (
|
|
175
|
+
Array.isArray(a) &&
|
|
176
|
+
Array.isArray(b) &&
|
|
177
|
+
a.length === b.length &&
|
|
178
|
+
a.every((item, index) => deepEqual(item, b[index]))
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
if (
|
|
182
|
+
Object.getPrototypeOf(a) !== Object.prototype ||
|
|
183
|
+
Object.getPrototypeOf(b) !== Object.prototype
|
|
184
|
+
) {
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
const keysA = Object.keys(a);
|
|
188
|
+
const keysB = Object.keys(b);
|
|
189
|
+
|
|
190
|
+
return (
|
|
191
|
+
keysA.length === keysB.length &&
|
|
192
|
+
keysA.every((key) =>
|
|
193
|
+
deepEqual((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key]),
|
|
194
|
+
)
|
|
195
|
+
);
|
|
196
|
+
}
|