@nepdatejs/react 0.0.0-bootstrap → 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.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +73 -1
  3. package/dist/date-text-BT82cZ7i.cjs +84 -0
  4. package/dist/date-text-BT82cZ7i.cjs.map +1 -0
  5. package/dist/date-text-Df1gFksR.js +61 -0
  6. package/dist/date-text-Df1gFksR.js.map +1 -0
  7. package/dist/date-text-Dipetpbp.d.cts +37 -0
  8. package/dist/date-text-Dipetpbp.d.ts +37 -0
  9. package/dist/headless-6BuUOtOR.d.cts +194 -0
  10. package/dist/headless-BK0XRHKb.js +513 -0
  11. package/dist/headless-BK0XRHKb.js.map +1 -0
  12. package/dist/headless-CFZ06zLn.d.ts +194 -0
  13. package/dist/headless-HxTkvgbz.cjs +572 -0
  14. package/dist/headless-HxTkvgbz.cjs.map +1 -0
  15. package/dist/headless.cjs +11 -0
  16. package/dist/headless.d.cts +2 -0
  17. package/dist/headless.d.ts +2 -0
  18. package/dist/headless.js +2 -0
  19. package/dist/index.cjs +891 -0
  20. package/dist/index.cjs.map +1 -0
  21. package/dist/index.d.cts +245 -0
  22. package/dist/index.d.ts +245 -0
  23. package/dist/index.js +871 -0
  24. package/dist/index.js.map +1 -0
  25. package/dist/server.cjs +6 -0
  26. package/dist/server.d.cts +2 -0
  27. package/dist/server.d.ts +2 -0
  28. package/dist/server.js +1 -0
  29. package/package.json +107 -4
  30. package/src/bindings.ts +247 -0
  31. package/src/calendar-body.tsx +133 -0
  32. package/src/calendar.tsx +34 -0
  33. package/src/date-picker.tsx +350 -0
  34. package/src/date-range-picker.tsx +223 -0
  35. package/src/date-text.tsx +110 -0
  36. package/src/date-time-picker.tsx +287 -0
  37. package/src/headless.ts +21 -0
  38. package/src/hooks.ts +474 -0
  39. package/src/icons.tsx +35 -0
  40. package/src/index.ts +21 -0
  41. package/src/multi-date-picker.tsx +243 -0
  42. package/src/panel.ts +67 -0
  43. package/src/presets.tsx +44 -0
  44. package/src/server.ts +8 -0
  45. package/src/time-columns.tsx +60 -0
  46. 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
+ }
@@ -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,8 @@
1
+ /** @nepdatejs/react/server: components without hooks, safe in React Server Components. */
2
+ export {
3
+ bothDates,
4
+ DateText,
5
+ shortDate,
6
+ ShortDate,
7
+ type DateTextProps,
8
+ } from './date-text';
@@ -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
+ }
@@ -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
+ }