@nepdatejs/react 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +74 -2
- package/dist/date-text-BT82cZ7i.cjs +84 -0
- package/dist/date-text-BT82cZ7i.cjs.map +1 -0
- package/dist/date-text-Df1gFksR.js +61 -0
- package/dist/date-text-Df1gFksR.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-6BuUOtOR.d.cts +194 -0
- package/dist/headless-BK0XRHKb.js +513 -0
- package/dist/headless-BK0XRHKb.js.map +1 -0
- package/dist/headless-CFZ06zLn.d.ts +194 -0
- package/dist/headless-HxTkvgbz.cjs +572 -0
- package/dist/headless-HxTkvgbz.cjs.map +1 -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 +891 -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 +871 -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 +350 -0
- package/src/date-range-picker.tsx +223 -0
- package/src/date-text.tsx +110 -0
- package/src/date-time-picker.tsx +287 -0
- package/src/headless.ts +21 -0
- package/src/hooks.ts +474 -0
- package/src/icons.tsx +35 -0
- package/src/index.ts +21 -0
- package/src/multi-date-picker.tsx +243 -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 +113 -0
|
@@ -0,0 +1,243 @@
|
|
|
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 { usePanel } from './panel';
|
|
16
|
+
import { PANEL_WIDTH } from './presets';
|
|
17
|
+
|
|
18
|
+
export interface MultiDatePickerProps {
|
|
19
|
+
/** AD "YYYY-MM-DD" days. */
|
|
20
|
+
value?: readonly string[] | undefined;
|
|
21
|
+
defaultValue?: readonly string[] | undefined;
|
|
22
|
+
onChange?:
|
|
23
|
+
((value: readonly string[], dates: readonly DateDetail[]) => void) | undefined;
|
|
24
|
+
calendar?: CalendarSystem | undefined;
|
|
25
|
+
defaultCalendar?: CalendarSystem | undefined;
|
|
26
|
+
onCalendarChange?: ((calendar: CalendarSystem) => void) | undefined;
|
|
27
|
+
/** Earliest and latest allowed days, as AD "YYYY-MM-DD". */
|
|
28
|
+
min?: string | undefined;
|
|
29
|
+
max?: string | undefined;
|
|
30
|
+
/** The most days that may be chosen. */
|
|
31
|
+
maxDates?: number | undefined;
|
|
32
|
+
isDateDisabled?: ((date: CalendarDate) => boolean) | undefined;
|
|
33
|
+
holidays?: readonly HolidayInput[] | HolidayLookup | undefined;
|
|
34
|
+
events?: ((date: CalendarDate) => readonly EventMarker[]) | undefined;
|
|
35
|
+
locale?: LocaleCode | undefined;
|
|
36
|
+
weekStartsOn?: Weekday | undefined;
|
|
37
|
+
weekendDays?: readonly Weekday[] | undefined;
|
|
38
|
+
showOtherCalendar?: boolean | undefined;
|
|
39
|
+
/** Close the panel after each click. Default false. */
|
|
40
|
+
closeOnSelect?: boolean | undefined;
|
|
41
|
+
/** Add the dashes of YYYY-MM-DD while the user types digits. Default true. */
|
|
42
|
+
mask?: boolean | undefined;
|
|
43
|
+
/** Show the calendar in the page, always open, instead of in a popover. */
|
|
44
|
+
inline?: boolean | undefined;
|
|
45
|
+
/** 'sheet' opens a bottom sheet; 'popover' sits beside the field; 'auto' (default) uses the sheet below 480px. */
|
|
46
|
+
panelLayout?: PanelLayout | undefined;
|
|
47
|
+
/** Submit each day under this name, one hidden input per day. */
|
|
48
|
+
name?: string | undefined;
|
|
49
|
+
id?: string | undefined;
|
|
50
|
+
placeholder?: string | undefined;
|
|
51
|
+
required?: boolean | undefined;
|
|
52
|
+
disabled?: boolean | undefined;
|
|
53
|
+
clearable?: boolean | undefined;
|
|
54
|
+
today?: CalendarDate | undefined;
|
|
55
|
+
theme?: 'light' | 'dark' | 'auto' | undefined;
|
|
56
|
+
className?: string | undefined;
|
|
57
|
+
'aria-label'?: string | undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Several separate BS/AD days in one field (PICK-001): click days in the
|
|
62
|
+
* panel to add or remove them, or type them separated by commas. The value is
|
|
63
|
+
* the sorted AD days as "YYYY-MM-DD" strings.
|
|
64
|
+
*/
|
|
65
|
+
export function MultiDatePicker({
|
|
66
|
+
name,
|
|
67
|
+
placeholder,
|
|
68
|
+
required,
|
|
69
|
+
disabled = false,
|
|
70
|
+
clearable = true,
|
|
71
|
+
panelLayout,
|
|
72
|
+
theme,
|
|
73
|
+
className,
|
|
74
|
+
'aria-label': ariaLabel,
|
|
75
|
+
...options
|
|
76
|
+
}: MultiDatePickerProps): ReactElement {
|
|
77
|
+
const picker = useMultiDatePicker(options);
|
|
78
|
+
const { state, store, calendar } = picker;
|
|
79
|
+
const labels = getLocale(options.locale);
|
|
80
|
+
const field = useRef<HTMLDivElement | null>(null);
|
|
81
|
+
const box = useRef<HTMLDivElement | null>(null);
|
|
82
|
+
const panel = useRef<HTMLDivElement | null>(null);
|
|
83
|
+
const input = useRef<HTMLInputElement | null>(null);
|
|
84
|
+
|
|
85
|
+
usePanel(
|
|
86
|
+
state.open && !state.inline,
|
|
87
|
+
{ anchor: box, panel, field, returnFocus: input },
|
|
88
|
+
() => {
|
|
89
|
+
store.actions.close();
|
|
90
|
+
},
|
|
91
|
+
calendar.focusGrid,
|
|
92
|
+
PANEL_WIDTH,
|
|
93
|
+
panelLayout,
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
// A form reset puts the field back to its default value.
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
const form = input.current?.form;
|
|
99
|
+
if (!form) {
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
const reset = (): void => {
|
|
103
|
+
store.actions.reset();
|
|
104
|
+
};
|
|
105
|
+
form.addEventListener('reset', reset);
|
|
106
|
+
|
|
107
|
+
return () => {
|
|
108
|
+
form.removeEventListener('reset', reset);
|
|
109
|
+
};
|
|
110
|
+
}, [store]);
|
|
111
|
+
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
input.current?.setCustomValidity(state.error?.message ?? '');
|
|
114
|
+
}, [state.error]);
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<div
|
|
118
|
+
ref={field}
|
|
119
|
+
className={className ? `ndp-field ${className}` : 'ndp-field'}
|
|
120
|
+
data-ndp-theme={theme}
|
|
121
|
+
>
|
|
122
|
+
<div
|
|
123
|
+
ref={box}
|
|
124
|
+
className="ndp-input"
|
|
125
|
+
data-invalid={state.error !== null || undefined}
|
|
126
|
+
data-disabled={disabled || undefined}
|
|
127
|
+
>
|
|
128
|
+
<input
|
|
129
|
+
ref={input}
|
|
130
|
+
className="ndp-input__control"
|
|
131
|
+
{...picker.getInputProps()}
|
|
132
|
+
aria-label={ariaLabel}
|
|
133
|
+
placeholder={placeholder ?? state.placeholder}
|
|
134
|
+
required={required}
|
|
135
|
+
disabled={disabled}
|
|
136
|
+
/>
|
|
137
|
+
<span className="ndp-input__actions">
|
|
138
|
+
{clearable && state.text !== '' && !disabled && (
|
|
139
|
+
<button
|
|
140
|
+
className="ndp-icon-button"
|
|
141
|
+
{...picker.getClearProps()}
|
|
142
|
+
onClick={() => {
|
|
143
|
+
store.actions.clear();
|
|
144
|
+
input.current?.focus();
|
|
145
|
+
}}
|
|
146
|
+
>
|
|
147
|
+
<Icon name="close" />
|
|
148
|
+
</button>
|
|
149
|
+
)}
|
|
150
|
+
{!state.inline && (
|
|
151
|
+
<button
|
|
152
|
+
className="ndp-icon-button"
|
|
153
|
+
disabled={disabled}
|
|
154
|
+
{...picker.getToggleProps()}
|
|
155
|
+
>
|
|
156
|
+
<Icon name="calendar" />
|
|
157
|
+
</button>
|
|
158
|
+
)}
|
|
159
|
+
</span>
|
|
160
|
+
</div>
|
|
161
|
+
{state.error && (
|
|
162
|
+
<p id={picker.ids.hint} className="ndp-hint" data-error aria-live="polite">
|
|
163
|
+
{state.error.message}
|
|
164
|
+
</p>
|
|
165
|
+
)}
|
|
166
|
+
{name !== undefined &&
|
|
167
|
+
state.value.map((day) => (
|
|
168
|
+
<input key={day} type="hidden" name={name} value={day} disabled={disabled} />
|
|
169
|
+
))}
|
|
170
|
+
{state.open && (
|
|
171
|
+
<div
|
|
172
|
+
ref={panel}
|
|
173
|
+
className="ndp-panel"
|
|
174
|
+
popover={state.inline ? undefined : 'manual'}
|
|
175
|
+
data-layout={state.inline ? 'inline' : undefined}
|
|
176
|
+
style={
|
|
177
|
+
state.inline
|
|
178
|
+
? ({ '--ndp-panel-width': `${String(PANEL_WIDTH)}px` } as CSSProperties)
|
|
179
|
+
: undefined
|
|
180
|
+
}
|
|
181
|
+
data-ndp-theme={theme}
|
|
182
|
+
{...picker.getPanelProps()}
|
|
183
|
+
aria-label={
|
|
184
|
+
ariaLabel
|
|
185
|
+
? `${ariaLabel}: ${labels.labels.calendar}`
|
|
186
|
+
: labels.labels.chooseDate
|
|
187
|
+
}
|
|
188
|
+
>
|
|
189
|
+
<CalendarBody
|
|
190
|
+
bindings={{
|
|
191
|
+
...calendar,
|
|
192
|
+
getDayProps: (cell) => ({
|
|
193
|
+
...calendar.getDayProps(cell),
|
|
194
|
+
onClick: () => {
|
|
195
|
+
if (
|
|
196
|
+
!cell.isDisabled &&
|
|
197
|
+
store.actions.select(cell.date) &&
|
|
198
|
+
!store.getState().open
|
|
199
|
+
) {
|
|
200
|
+
restoreFocus(input.current);
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
}),
|
|
204
|
+
}}
|
|
205
|
+
/>
|
|
206
|
+
<div className="ndp-panel__footer">
|
|
207
|
+
{state.canSwitchCalendar ? (
|
|
208
|
+
<div
|
|
209
|
+
className="ndp-segment"
|
|
210
|
+
role="group"
|
|
211
|
+
aria-label={labels.labels.calendar}
|
|
212
|
+
>
|
|
213
|
+
{(['BS', 'AD'] as const).map((option) => (
|
|
214
|
+
<button
|
|
215
|
+
key={option}
|
|
216
|
+
className="ndp-segment__item"
|
|
217
|
+
{...picker.getCalendarOptionProps(option)}
|
|
218
|
+
>
|
|
219
|
+
{labels.calendarNames[option]}
|
|
220
|
+
</button>
|
|
221
|
+
))}
|
|
222
|
+
</div>
|
|
223
|
+
) : (
|
|
224
|
+
<span />
|
|
225
|
+
)}
|
|
226
|
+
{!state.inline && (
|
|
227
|
+
<button
|
|
228
|
+
type="button"
|
|
229
|
+
className="ndp-button"
|
|
230
|
+
onClick={() => {
|
|
231
|
+
store.actions.close();
|
|
232
|
+
restoreFocus(input.current);
|
|
233
|
+
}}
|
|
234
|
+
>
|
|
235
|
+
{labels.labels.done}
|
|
236
|
+
</button>
|
|
237
|
+
)}
|
|
238
|
+
</div>
|
|
239
|
+
</div>
|
|
240
|
+
)}
|
|
241
|
+
</div>
|
|
242
|
+
);
|
|
243
|
+
}
|
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,113 @@
|
|
|
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
|
+
}
|
|
14
|
+
|
|
15
|
+
/** useLayoutEffect in the browser, useEffect on the server (no warning). */
|
|
16
|
+
export const useIsomorphicLayoutEffect: typeof useEffect =
|
|
17
|
+
typeof window === 'undefined' ? useEffect : useLayoutEffect;
|
|
18
|
+
|
|
19
|
+
type Fn = (...args: never[]) => unknown;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Create a store once and keep its options in step with props, without
|
|
23
|
+
* loops: functions are wrapped once so their identity never changes, and data
|
|
24
|
+
* options are compared by value, so inline objects and arrays are fine.
|
|
25
|
+
*/
|
|
26
|
+
export function useStore<O extends object, S, T extends StoreLike<O, S>>(
|
|
27
|
+
create: (options: O) => T,
|
|
28
|
+
options: O,
|
|
29
|
+
): { store: T; state: S } {
|
|
30
|
+
const latest = useRef(options);
|
|
31
|
+
latest.current = options;
|
|
32
|
+
const wrappers = useRef(new Map<string, Fn>());
|
|
33
|
+
|
|
34
|
+
const prepare = (input: O): O => {
|
|
35
|
+
const out: Record<string, unknown> = {};
|
|
36
|
+
for (const [key, value] of Object.entries(input)) {
|
|
37
|
+
if (typeof value === 'function') {
|
|
38
|
+
let wrapper = wrappers.current.get(key);
|
|
39
|
+
if (!wrapper) {
|
|
40
|
+
wrapper = (...args: never[]) =>
|
|
41
|
+
((latest.current as Record<string, unknown>)[key] as Fn | undefined)?.(
|
|
42
|
+
...args,
|
|
43
|
+
);
|
|
44
|
+
wrappers.current.set(key, wrapper);
|
|
45
|
+
}
|
|
46
|
+
out[key] = wrapper;
|
|
47
|
+
} else {
|
|
48
|
+
out[key] = value;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return out as O;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const [store] = useState(() => create(prepare(options)));
|
|
56
|
+
const previous = useRef<O>(prepare(options));
|
|
57
|
+
|
|
58
|
+
useIsomorphicLayoutEffect(() => {
|
|
59
|
+
const next = prepare(options) as Record<string, unknown>;
|
|
60
|
+
const before = previous.current as Record<string, unknown>;
|
|
61
|
+
const changes: Record<string, unknown> = {};
|
|
62
|
+
for (const key of new Set([...Object.keys(before), ...Object.keys(next)])) {
|
|
63
|
+
if (!deepEqual(before[key], next[key])) {
|
|
64
|
+
changes[key] = next[key];
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
previous.current = next as O;
|
|
68
|
+
if (Object.keys(changes).length > 0) {
|
|
69
|
+
store.setOptions(changes as Partial<O>);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const state = useSyncExternalStore(
|
|
74
|
+
(listener) => store.subscribe(listener),
|
|
75
|
+
() => store.getState(),
|
|
76
|
+
() => store.getState(),
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
return { store, state };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Structural equality for plain data (dates, ranges, lists); identity for everything else. */
|
|
83
|
+
export function deepEqual(a: unknown, b: unknown): boolean {
|
|
84
|
+
if (Object.is(a, b)) {
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
91
|
+
return (
|
|
92
|
+
Array.isArray(a) &&
|
|
93
|
+
Array.isArray(b) &&
|
|
94
|
+
a.length === b.length &&
|
|
95
|
+
a.every((item, index) => deepEqual(item, b[index]))
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
if (
|
|
99
|
+
Object.getPrototypeOf(a) !== Object.prototype ||
|
|
100
|
+
Object.getPrototypeOf(b) !== Object.prototype
|
|
101
|
+
) {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
const keysA = Object.keys(a);
|
|
105
|
+
const keysB = Object.keys(b);
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
keysA.length === keysB.length &&
|
|
109
|
+
keysA.every((key) =>
|
|
110
|
+
deepEqual((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key]),
|
|
111
|
+
)
|
|
112
|
+
);
|
|
113
|
+
}
|