@ncds/ui-admin 1.8.12 → 1.8.14

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 (42) hide show
  1. package/dist/cjs/src/components/data-display/data-grid/DataGrid.js +6 -4
  2. package/dist/cjs/src/components/forms-and-input/date-picker/DatePicker.js +17 -20
  3. package/dist/cjs/src/components/forms-and-input/date-picker/__tests__/DatePicker.memo.test.js +53 -0
  4. package/dist/cjs/src/components/forms-and-input/date-picker/__tests__/DatePicker.test.js +129 -0
  5. package/dist/cjs/src/components/forms-and-input/select/Select.js +12 -1
  6. package/dist/cjs/src/components/forms-and-input/select-box/SelectBox.js +5 -2
  7. package/dist/cjs/src/utils/__tests__/date-picker.test.js +29 -0
  8. package/dist/cjs/src/utils/date-picker.js +15 -1
  9. package/dist/esm/src/components/data-display/data-grid/DataGrid.js +6 -4
  10. package/dist/esm/src/components/forms-and-input/date-picker/DatePicker.js +18 -21
  11. package/dist/esm/src/components/forms-and-input/date-picker/__tests__/DatePicker.memo.test.js +50 -0
  12. package/dist/esm/src/components/forms-and-input/date-picker/__tests__/DatePicker.test.js +126 -0
  13. package/dist/esm/src/components/forms-and-input/select/Select.js +12 -1
  14. package/dist/esm/src/components/forms-and-input/select-box/SelectBox.js +5 -2
  15. package/dist/esm/src/utils/__tests__/date-picker.test.js +26 -0
  16. package/dist/esm/src/utils/date-picker.js +13 -0
  17. package/dist/temp/src/components/data-display/data-grid/DataGrid.d.ts +1 -0
  18. package/dist/temp/src/components/data-display/data-grid/DataGrid.js +7 -6
  19. package/dist/temp/src/components/data-display/data-grid/DataGrid.types.d.ts +1 -0
  20. package/dist/temp/src/components/forms-and-input/date-picker/DatePicker.js +18 -28
  21. package/dist/temp/src/components/forms-and-input/date-picker/__tests__/DatePicker.memo.test.d.ts +1 -0
  22. package/dist/temp/src/components/forms-and-input/date-picker/__tests__/DatePicker.memo.test.js +43 -0
  23. package/dist/temp/src/components/forms-and-input/date-picker/__tests__/DatePicker.test.d.ts +1 -0
  24. package/dist/temp/src/components/forms-and-input/date-picker/__tests__/DatePicker.test.js +109 -0
  25. package/dist/temp/src/components/forms-and-input/select/Select.d.ts +1 -0
  26. package/dist/temp/src/components/forms-and-input/select/Select.js +10 -2
  27. package/dist/temp/src/components/forms-and-input/select-box/SelectBox.d.ts +1 -0
  28. package/dist/temp/src/components/forms-and-input/select-box/SelectBox.js +5 -4
  29. package/dist/temp/src/utils/__tests__/date-picker.test.d.ts +1 -0
  30. package/dist/temp/src/utils/__tests__/date-picker.test.js +26 -0
  31. package/dist/temp/src/utils/date-picker.d.ts +2 -0
  32. package/dist/temp/src/utils/date-picker.js +13 -0
  33. package/dist/types/src/components/data-display/data-grid/DataGrid.d.ts +1 -0
  34. package/dist/types/src/components/data-display/data-grid/DataGrid.types.d.ts +1 -0
  35. package/dist/types/src/components/forms-and-input/date-picker/__tests__/DatePicker.memo.test.d.ts +1 -0
  36. package/dist/types/src/components/forms-and-input/date-picker/__tests__/DatePicker.test.d.ts +1 -0
  37. package/dist/types/src/components/forms-and-input/select/Select.d.ts +1 -0
  38. package/dist/types/src/components/forms-and-input/select-box/SelectBox.d.ts +1 -0
  39. package/dist/types/src/utils/__tests__/date-picker.test.d.ts +1 -0
  40. package/dist/types/src/utils/date-picker.d.ts +2 -0
  41. package/dist/ui-admin/assets/styles/style.css +29 -2
  42. package/package.json +2 -1
@@ -0,0 +1,50 @@
1
+ // @vitest-environment jsdom
2
+ import { createElement, useState } from 'react';
3
+ import { createRoot } from 'react-dom/client';
4
+ import { act } from 'react-dom/test-utils';
5
+ import { describe, expect, it, vi } from 'vitest';
6
+ import { DatePicker } from '../DatePicker';
7
+ const {
8
+ captured
9
+ } = vi.hoisted(() => ({
10
+ captured: []
11
+ }));
12
+ // react-flatpickr를 mock하여 DatePicker가 넘기는 options 참조를 렌더마다 수집한다.
13
+ // (vi.mock / vi.hoisted는 vitest가 import 위로 호이스팅하므로 DatePicker import보다 먼저 적용된다)
14
+ vi.mock('react-flatpickr', () => ({
15
+ default: props => {
16
+ captured.push(props.options);
17
+ return null;
18
+ }
19
+ }));
20
+ describe('#1 options useMemo 안정성', () => {
21
+ it('부모가 같은 props로 강제 리렌더해도 options 참조가 동일하게 유지된다(재생성 안 됨)', () => {
22
+ let bump = () => undefined;
23
+ const Harness = () => {
24
+ const [, setN] = useState(0);
25
+ bump = () => setN(x => x + 1);
26
+ return /*#__PURE__*/createElement(DatePicker, {
27
+ currentDate: '2024-03-20',
28
+ onChangeDate: () => undefined
29
+ });
30
+ };
31
+ const container = document.createElement('div');
32
+ document.body.appendChild(container);
33
+ const root = createRoot(container);
34
+ act(() => {
35
+ root.render(/*#__PURE__*/createElement(Harness));
36
+ });
37
+ const countAfterMount = captured.length;
38
+ const firstOptions = captured[captured.length - 1];
39
+ act(() => {
40
+ bump();
41
+ });
42
+ const lastOptions = captured[captured.length - 1];
43
+ expect(captured.length).toBeGreaterThan(countAfterMount); // 리렌더가 실제로 일어났는지
44
+ expect(Object.is(firstOptions, lastOptions)).toBe(true); // 동일 참조 = options 재생성 안 됨
45
+ act(() => {
46
+ root.unmount();
47
+ });
48
+ container.remove();
49
+ });
50
+ });
@@ -0,0 +1,126 @@
1
+ // @vitest-environment jsdom
2
+ import { createElement } from 'react';
3
+ import { createRoot } from 'react-dom/client';
4
+ import { act } from 'react-dom/test-utils';
5
+ import { afterEach, describe, expect, it, vi } from 'vitest';
6
+ import { DatePicker } from '../DatePicker';
7
+ const mounted = [];
8
+ function mount(props) {
9
+ const container = document.createElement('div');
10
+ document.body.appendChild(container);
11
+ const root = createRoot(container);
12
+ act(() => {
13
+ root.render(/*#__PURE__*/createElement(DatePicker, props));
14
+ });
15
+ mounted.push({
16
+ root,
17
+ container
18
+ });
19
+ return container;
20
+ }
21
+ afterEach(() => {
22
+ for (const {
23
+ root,
24
+ container
25
+ } of mounted.splice(0)) {
26
+ act(() => {
27
+ root.unmount();
28
+ });
29
+ container.remove();
30
+ }
31
+ vi.restoreAllMocks();
32
+ });
33
+ describe('DatePicker 렌더 (스모크)', () => {
34
+ it('기본 props로 input을 렌더하고 크래시하지 않는다', () => {
35
+ const container = mount({
36
+ currentDate: '2024-03-20',
37
+ onChangeDate: () => undefined
38
+ });
39
+ expect(container.querySelector('input')).not.toBeNull();
40
+ });
41
+ it('enableTime 옵션으로도 정상 렌더한다', () => {
42
+ const container = mount({
43
+ currentDate: '2024-03-20 14:30',
44
+ onChangeDate: () => undefined,
45
+ datePickerOptions: {
46
+ enableTime: true,
47
+ dateFormat: 'Y-m-d H:i'
48
+ }
49
+ });
50
+ expect(container.querySelector('input')).not.toBeNull();
51
+ });
52
+ });
53
+ describe('#4 document mousedown 리스너 정리', () => {
54
+ it('enableTime 마운트 후 언마운트 시, 추가된 mousedown 리스너가 모두 제거된다(누수 없음)', () => {
55
+ const added = [];
56
+ const removed = [];
57
+ const origAdd = document.addEventListener.bind(document);
58
+ const origRemove = document.removeEventListener.bind(document);
59
+ vi.spyOn(document, 'addEventListener').mockImplementation((type, fn, opts) => {
60
+ if (type === 'mousedown' && fn) added.push(fn);
61
+ return origAdd(type, fn, opts);
62
+ });
63
+ vi.spyOn(document, 'removeEventListener').mockImplementation((type, fn, opts) => {
64
+ if (type === 'mousedown' && fn) removed.push(fn);
65
+ return origRemove(type, fn, opts);
66
+ });
67
+ const container = document.createElement('div');
68
+ document.body.appendChild(container);
69
+ const root = createRoot(container);
70
+ act(() => {
71
+ root.render(/*#__PURE__*/createElement(DatePicker, {
72
+ currentDate: '2024-03-20 14:30',
73
+ onChangeDate: () => undefined,
74
+ datePickerOptions: {
75
+ enableTime: true,
76
+ dateFormat: 'Y-m-d H:i'
77
+ }
78
+ }));
79
+ });
80
+ expect(added.length).toBeGreaterThanOrEqual(1);
81
+ act(() => {
82
+ root.unmount();
83
+ });
84
+ container.remove();
85
+ for (const fn of added) {
86
+ expect(removed).toContain(fn);
87
+ }
88
+ });
89
+ });
90
+ describe('#3 onValidationError.previousDate', () => {
91
+ // 허용 범위(10~25일) 기준: 마지막 유효 선택일과 범위 밖 위반일
92
+ const LAST_VALID_DAY = 15;
93
+ const OUT_OF_RANGE_DAY = 30;
94
+ it('min/max 위반 시 previousDate는 위반 날짜가 아니라 직전 유효 날짜다', () => {
95
+ const onValidationError = vi.fn();
96
+ const container = mount({
97
+ currentDate: '2024-03-20',
98
+ onChangeDate: () => undefined,
99
+ datePickerOptions: {
100
+ minDate: '2024-03-10',
101
+ maxDate: '2024-03-25',
102
+ allowInput: true
103
+ },
104
+ onValidationError
105
+ });
106
+ // flatpickr 인스턴스는 초기화된 input 엘리먼트의 _flatpickr에 부착된다
107
+ const input = container.querySelector('input');
108
+ const instance = input?._flatpickr;
109
+ expect(instance).toBeTruthy();
110
+ // 1) 유효 날짜를 먼저 선택 → 직전 유효값(_previousDateBeforeInput)이 확립됨
111
+ act(() => {
112
+ instance.setDate(`2024-03-${LAST_VALID_DAY}`, true);
113
+ });
114
+ expect(onValidationError).not.toHaveBeenCalled();
115
+ // 2) 허용 범위 밖 날짜 선택 → 위반 분기 진입
116
+ act(() => {
117
+ instance.setDate(`2024-03-${OUT_OF_RANGE_DAY}`, true);
118
+ });
119
+ expect(onValidationError).toHaveBeenCalledTimes(1);
120
+ const arg = onValidationError.mock.calls[0][0];
121
+ expect(arg.violations).toContain('maxDate');
122
+ expect(arg.date.getDate()).toBe(OUT_OF_RANGE_DAY); // 위반 날짜
123
+ expect(arg.previousDate?.getDate()).toBe(LAST_VALID_DAY); // 직전 유효 날짜 (≠ 위반 날짜)
124
+ expect(arg.previousDate?.getTime()).not.toBe(arg.date.getTime());
125
+ });
126
+ });
@@ -17,12 +17,14 @@ export const Select = /*#__PURE__*/forwardRef((_ref, ref) => {
17
17
  optionItems,
18
18
  register,
19
19
  disabled = false,
20
+ readOnly = false,
20
21
  ...props
21
22
  } = _ref;
22
23
  return _jsxs("span", {
23
24
  className: classNames('ncua-select', {
24
25
  destructive: destructive,
25
- 'ncua-select--simple': type === 'simple'
26
+ 'ncua-select--simple': type === 'simple',
27
+ 'ncua-select--readonly': readOnly
26
28
  }, className, `ncua-select--${size}`),
27
29
  children: [_jsx("span", {
28
30
  className: "ncua-select__content",
@@ -32,8 +34,17 @@ export const Select = /*#__PURE__*/forwardRef((_ref, ref) => {
32
34
  id: id,
33
35
  className: classNames('ncua-select__tag', className),
34
36
  disabled: disabled,
37
+ "aria-readonly": readOnly,
35
38
  ...register,
36
39
  ...props,
40
+ onKeyDown: e => {
41
+ props.onKeyDown?.(e);
42
+ // native select에는 readonly 속성이 없어 키보드로 값이 바뀐다.
43
+ // 값 변경 키는 막고, Tab/Shift+Tab(포커스 이동)은 허용한다.
44
+ if (readOnly && e.key !== 'Tab') {
45
+ e.preventDefault();
46
+ }
47
+ },
37
48
  children: [placeholder && _jsx("option", {
38
49
  value: "",
39
50
  disabled: disabledPlaceholder,
@@ -71,6 +71,7 @@ const SelectBox = /*#__PURE__*/forwardRef((_ref2, ref
71
71
  value,
72
72
  optionItems = [],
73
73
  disabled = false,
74
+ readOnly = false,
74
75
  maxHeight = DEFAULT_MAX_HEIGHT,
75
76
  multiple = false,
76
77
  maxSelection,
@@ -103,7 +104,7 @@ const SelectBox = /*#__PURE__*/forwardRef((_ref2, ref
103
104
  return selectedOption ? selectedOption : placeholder;
104
105
  }, [multiple, selectedOption, placeholder]);
105
106
  const handleOptionSelect = option => {
106
- if (disabled) return;
107
+ if (disabled || readOnly) return;
107
108
  if (multiple) {
108
109
  const newValue = tryToggle(option.id, Array.isArray(value) ? value : []);
109
110
  if (newValue === null) return;
@@ -132,7 +133,7 @@ const SelectBox = /*#__PURE__*/forwardRef((_ref2, ref
132
133
  maxHeight,
133
134
  itemCount: optionItems.length,
134
135
  optionItems,
135
- disabled,
136
+ disabled: disabled || readOnly,
136
137
  multiple,
137
138
  onSelect: handleOptionSelect
138
139
  });
@@ -237,6 +238,7 @@ const SelectBox = /*#__PURE__*/forwardRef((_ref2, ref
237
238
  className: classNames('ncua-selectbox', `ncua-selectbox--${size}`, {
238
239
  'ncua-selectbox--open': isOpen,
239
240
  'ncua-selectbox--disabled': disabled,
241
+ 'ncua-selectbox--readonly': readOnly,
240
242
  'ncua-selectbox--simple': type === 'simple',
241
243
  'ncua-selectbox--multiple': multiple,
242
244
  destructive: destructive
@@ -252,6 +254,7 @@ const SelectBox = /*#__PURE__*/forwardRef((_ref2, ref
252
254
  "aria-haspopup": "listbox",
253
255
  "aria-controls": `selectbox-options-${id || 'default'}`,
254
256
  "aria-disabled": disabled,
257
+ "aria-readonly": readOnly,
255
258
  "aria-label": selectedOption ? selectedOption.label : placeholder,
256
259
  "aria-activedescendant": activeDescendantId,
257
260
  children: [_jsxs("div", {
@@ -0,0 +1,26 @@
1
+ import moment from 'moment';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { convertToMomentFormat } from '../date-picker';
4
+ describe('convertToMomentFormat', () => {
5
+ it('날짜 전용 포맷을 moment 토큰으로 변환한다 (기존 동작 보존)', () => {
6
+ expect(convertToMomentFormat('Y-m-d')).toBe('YYYY-MM-DD');
7
+ });
8
+ it('2자리 연도(y)를 YY로 변환한다', () => {
9
+ expect(convertToMomentFormat('y-m-d')).toBe('YY-MM-DD');
10
+ });
11
+ it('시간 포함 포맷의 H/i/S를 모두 변환한다 (버그 수정 지점)', () => {
12
+ expect(convertToMomentFormat('Y-m-d H:i')).toBe('YYYY-MM-DD HH:mm');
13
+ expect(convertToMomentFormat('Y-m-d H:i:S')).toBe('YYYY-MM-DD HH:mm:ss');
14
+ });
15
+ it('월(m)과 분(i)이 함께 있어도 서로 침범하지 않는다', () => {
16
+ // m -> MM(월) 먼저, i -> mm(분) 나중 순서가 지켜져야 함
17
+ expect(convertToMomentFormat('Y-m-d H:i')).not.toContain('i');
18
+ expect(convertToMomentFormat('Y-m-d H:i')).toBe('YYYY-MM-DD HH:mm');
19
+ });
20
+ it('실제 moment 출력이 의도한 문자열과 일치한다 (회귀 핵심)', () => {
21
+ const date = moment('2024-03-20 14:30:05', 'YYYY-MM-DD HH:mm:ss').toDate();
22
+ expect(moment(date).format(convertToMomentFormat('Y-m-d'))).toBe('2024-03-20');
23
+ expect(moment(date).format(convertToMomentFormat('Y-m-d H:i'))).toBe('2024-03-20 14:30');
24
+ expect(moment(date).format(convertToMomentFormat('Y-m-d H:i:S'))).toBe('2024-03-20 14:30:05');
25
+ });
26
+ });
@@ -21,6 +21,19 @@ export function getSubtractDate(_ref) {
21
21
  } = _ref;
22
22
  return moment(date).subtract(period, unit).toDate();
23
23
  }
24
+ /** flatpickr dateFormat 토큰을 moment 포맷 토큰으로 변환 (단일 패스 치환으로 토큰 간 간섭 방지) */
25
+ export const convertToMomentFormat = format => {
26
+ const tokenMap = {
27
+ Y: 'YYYY',
28
+ y: 'YY',
29
+ m: 'MM',
30
+ d: 'DD',
31
+ H: 'HH',
32
+ i: 'mm',
33
+ S: 'ss'
34
+ };
35
+ return format.replace(/[YymdHiS]/g, matched => tokenMap[matched] || matched);
36
+ };
24
37
  export const formatDateInput = input => {
25
38
  const dateRegex = /^(19|20)\d{2}(0[1-9]|1[012])(0[1-9]|[12][0-9]|3[0-1])$/;
26
39
  if (dateRegex.test(input)) {
@@ -3,6 +3,7 @@ import type { DataGridActionBarProps, DataGridFilterBarProps, DataGridPagination
3
3
  export declare const DataGrid: import("react").ForwardRefExoticComponent<Omit<import("react").DetailedHTMLProps<import("react").HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "ref"> & {
4
4
  variant?: import("./DataGrid.types").DataGridVariant | undefined;
5
5
  children: ReactNode;
6
+ showActionBar?: boolean | undefined;
6
7
  } & import("react").RefAttributes<HTMLDivElement>> & {
7
8
  SearchFilter: {
8
9
  ({ children, className, ...rest }: DataGridSearchFilterProps): import("react/jsx-runtime").JSX.Element;
@@ -61,18 +61,19 @@ const sortChildren = (children) => {
61
61
  });
62
62
  return result;
63
63
  };
64
- const hasTableWrapperContent = (sorted) => sorted.filterBar.length > 0 ||
65
- sorted.topActionBar.length > 0 ||
64
+ const hasTableWrapperContent = (sorted, actionBarVisible) => sorted.filterBar.length > 0 ||
65
+ (actionBarVisible && sorted.topActionBar.length > 0) ||
66
66
  sorted.dataTable.length > 0 ||
67
- sorted.bottomActionBar.length > 0;
67
+ (actionBarVisible && sorted.bottomActionBar.length > 0);
68
68
  const isTableSelectable = (dataTable) => dataTable.some((child) => isValidElement(child) && child.props.selectable === true);
69
69
  // ──────────────────────────────────────────────
70
70
  // Main DataGrid component
71
71
  // ──────────────────────────────────────────────
72
- const DataGridComponent = forwardRef(({ variant = 'search-result', children, className, ...rest }, ref) => {
72
+ const DataGridComponent = forwardRef(({ variant = 'search-result', children, className, showActionBar, ...rest }, ref) => {
73
73
  const sorted = sortChildren(children);
74
- const needsEmptyBottomBar = sorted.bottomActionBar.length === 0 && isTableSelectable(sorted.dataTable);
75
- return (_jsxs("div", { ref: ref, className: classNames('ncua-data-grid', className, `ncua-data-grid--${variant}`), ...rest, children: [sorted.searchFilter, sorted.summary, hasTableWrapperContent(sorted) && (_jsxs("div", { className: "ncua-data-grid__table-wrapper", children: [sorted.filterBar, sorted.topActionBar, sorted.dataTable, sorted.bottomActionBar, needsEmptyBottomBar && _jsx("div", { className: "ncua-data-grid__action-bar ncua-data-grid__action-bar--bottom" })] })), sorted.pagination] }));
74
+ const actionBarVisible = showActionBar !== false;
75
+ const needsEmptyBottomBar = actionBarVisible && sorted.bottomActionBar.length === 0 && isTableSelectable(sorted.dataTable);
76
+ return (_jsxs("div", { ref: ref, className: classNames('ncua-data-grid', className, `ncua-data-grid--${variant}`), ...rest, children: [sorted.searchFilter, sorted.summary, hasTableWrapperContent(sorted, actionBarVisible) && (_jsxs("div", { className: "ncua-data-grid__table-wrapper", children: [sorted.filterBar, actionBarVisible && sorted.topActionBar, sorted.dataTable, actionBarVisible && sorted.bottomActionBar, needsEmptyBottomBar && _jsx("div", { className: "ncua-data-grid__action-bar ncua-data-grid__action-bar--bottom" })] })), sorted.pagination] }));
76
77
  });
77
78
  DataGridComponent.displayName = 'DataGrid';
78
79
  // ──────────────────────────────────────────────
@@ -6,6 +6,7 @@ export type ActionBarAlign = 'left' | 'right' | 'space-between';
6
6
  export type DataGridProps = Omit<ComponentProps<'div'>, 'ref'> & {
7
7
  variant?: DataGridVariant;
8
8
  children: ReactNode;
9
+ showActionBar?: boolean;
9
10
  };
10
11
  export type DataGridSearchFilterProps = ComponentProps<'div'> & {
11
12
  children: ReactNode;
@@ -4,7 +4,7 @@ import { Korean } from 'flatpickr/dist/l10n/ko';
4
4
  import moment from 'moment';
5
5
  import { forwardRef, useCallback, useEffect, useId, useMemo, useRef } from 'react';
6
6
  import Flatpickr from 'react-flatpickr';
7
- import { formatDateInput, formatHourInput, formatMinuteInput } from '../../../utils/date-picker';
7
+ import { convertToMomentFormat, formatDateInput, formatHourInput, formatMinuteInput } from '../../../utils/date-picker';
8
8
  import { CustomInput } from './CustomInput';
9
9
  // ══════════════════════════════════════════
10
10
  // 유틸리티 함수
@@ -171,6 +171,12 @@ const setupTimeInputHandlers = (instance, input, isPortal, onHourInput, onMinute
171
171
  };
172
172
  /** 시간 입력 필드 이벤트 리스너 정리 */
173
173
  const cleanupTimeInputHandlers = (instance, input, isPortal, onHourInput, onMinuteInput) => {
174
+ // document 전역 리스너는 캘린더 DOM 조회 성공 여부와 무관하게 항상 먼저 제거한다 (누수 방지)
175
+ const handleMouseDown = instance._handleMouseDown;
176
+ if (handleMouseDown) {
177
+ document.removeEventListener('mousedown', handleMouseDown, true);
178
+ instance._handleMouseDown = undefined;
179
+ }
174
180
  const timeContainer = isPortal ? instance.calendarContainer : input.parentElement;
175
181
  const timeInputWrapper = timeContainer?.querySelector('.flatpickr-time');
176
182
  if (!timeInputWrapper)
@@ -183,10 +189,6 @@ const cleanupTimeInputHandlers = (instance, input, isPortal, onHourInput, onMinu
183
189
  if (minuteInput) {
184
190
  minuteInput.removeEventListener('input', onMinuteInput);
185
191
  }
186
- const handleMouseDown = instance._handleMouseDown;
187
- if (handleMouseDown) {
188
- document.removeEventListener('mousedown', handleMouseDown, true);
189
- }
190
192
  };
191
193
  // ══════════════════════════════════════════
192
194
  // DatePicker 컴포넌트
@@ -204,7 +206,7 @@ export const DatePicker = forwardRef(({ shouldFocus = true, currentDate, size =
204
206
  const onValidationErrorRef = useRef(onValidationError);
205
207
  onValidationErrorRef.current = onValidationError;
206
208
  /** portal 컨테이너를 lazily 생성 (이미 있으면 재사용), className은 매번 갱신 */
207
- const getPortalContainer = () => {
209
+ const getPortalContainer = useCallback(() => {
208
210
  if (!portal || typeof document === 'undefined')
209
211
  return undefined;
210
212
  const portalClassName = classNames('ncua-date-picker', 'ncua-date-picker--portal', `ncua-date-picker--${size}`, {
@@ -221,7 +223,7 @@ export const DatePicker = forwardRef(({ shouldFocus = true, currentDate, size =
221
223
  document.body.appendChild(el);
222
224
  portalContainerRef.current = el;
223
225
  return el;
224
- };
226
+ }, [portal, size, hasTimeOption]);
225
227
  /** 컴포넌트 언마운트 시 포탈 컨테이너 및 스크롤 리스너 정리 */
226
228
  useEffect(() => {
227
229
  return () => {
@@ -233,7 +235,7 @@ export const DatePicker = forwardRef(({ shouldFocus = true, currentDate, size =
233
235
  // 날짜 유효성 검사
234
236
  // ──────────────────────────────────────────────
235
237
  /** minDate/maxDate 범위를 벗어났는지 확인 */
236
- const checkDateViolations = (date, minDate, maxDate) => {
238
+ const checkDateViolations = useCallback((date, minDate, maxDate) => {
237
239
  const violations = [];
238
240
  const inputDate = moment(date);
239
241
  if (!inputDate.isValid())
@@ -251,16 +253,16 @@ export const DatePicker = forwardRef(({ shouldFocus = true, currentDate, size =
251
253
  }
252
254
  }
253
255
  return violations;
254
- };
256
+ }, []);
255
257
  // ──────────────────────────────────────────────
256
258
  // 이벤트 핸들러
257
259
  // ──────────────────────────────────────────────
258
260
  /** 유효하지 않은 입력 시 이전 날짜로 복원 */
259
- const restorePreviousDate = (target, instance) => {
261
+ const restorePreviousDate = useCallback((target, instance) => {
260
262
  if (instance.selectedDates.length > 0) {
261
263
  const prevDate = instance.selectedDates[0];
262
264
  if (prevDate instanceof Date && !Number.isNaN(prevDate.getTime())) {
263
- const momentFormat = dateFormatRef.current.replace(/Y/g, 'YYYY').replace(/m/g, 'MM').replace(/d/g, 'DD');
265
+ const momentFormat = convertToMomentFormat(dateFormatRef.current);
264
266
  target.value = moment(prevDate).format(momentFormat);
265
267
  instance.setDate(prevDate, false);
266
268
  return;
@@ -268,7 +270,7 @@ export const DatePicker = forwardRef(({ shouldFocus = true, currentDate, size =
268
270
  }
269
271
  target.value = '';
270
272
  instance.setDate('', false);
271
- };
273
+ }, []);
272
274
  /** flatpickr에서 날짜가 변경되었을 때 호출 */
273
275
  const onChangeDateHandler = useCallback((dateTimeStamp, dateStr, fpInstance) => {
274
276
  const instance = fpInstance;
@@ -283,7 +285,9 @@ export const DatePicker = forwardRef(({ shouldFocus = true, currentDate, size =
283
285
  const maxDate = instance.config.maxDate;
284
286
  const violations = checkDateViolations(selectedDate, minDate, maxDate);
285
287
  if (violations.length > 0 && onValidationErrorRef.current) {
286
- const prevDate = instance?.selectedDates?.[0];
288
+ // flatpickr는 onChange 발화 전에 selectedDates를 위반된 새 날짜로 갱신하므로,
289
+ // 직전 유효 날짜를 보관해 둔 _previousDateBeforeInput을 previousDate로 사용한다
290
+ const prevDate = instance._previousDateBeforeInput;
287
291
  const validPrevDate = prevDate instanceof Date && !Number.isNaN(prevDate.getTime()) ? prevDate : undefined;
288
292
  onValidationErrorRef.current({
289
293
  date: selectedDate,
@@ -299,7 +303,6 @@ export const DatePicker = forwardRef(({ shouldFocus = true, currentDate, size =
299
303
  isValidDate(formattedDate) ? onChangeDateRef.current(formattedDate) : onChangeDateRef.current(dateStr);
300
304
  }, [checkDateViolations, restorePreviousDate]);
301
305
  /** input에 직접 타이핑할 때 날짜 형식 자동 변환 및 유효성 검사 */
302
- // eslint-disable-next-line react-hooks/exhaustive-deps
303
306
  const onInputHandler = useCallback((e) => {
304
307
  const target = e.target;
305
308
  const input = target.value;
@@ -356,12 +359,6 @@ export const DatePicker = forwardRef(({ shouldFocus = true, currentDate, size =
356
359
  // ──────────────────────────────────────────────
357
360
  // flatpickr 옵션
358
361
  // ──────────────────────────────────────────────
359
- // datePickerOptions는 부모에서 매 렌더 새 객체일 수 있으므로 값 기반 비교
360
- // 주의: JSON.stringify는 함수를 무시하고 Date를 문자열로 변환하므로,
361
- // datePickerOptions에 함수나 Date 객체를 넘기면 변경 감지가 누락될 수 있음
362
- // eslint-disable-next-line react-hooks/exhaustive-deps
363
- const _datePickerOptionsKey = JSON.stringify(datePickerOptions);
364
- // eslint-disable-next-line react-hooks/exhaustive-deps
365
362
  const options = useMemo(() => ({
366
363
  mode: 'single',
367
364
  static: !portal,
@@ -382,14 +379,7 @@ export const DatePicker = forwardRef(({ shouldFocus = true, currentDate, size =
382
379
  if (!momentDate.isValid()) {
383
380
  return '';
384
381
  }
385
- const momentFormat = format
386
- .replace(/Y/g, 'YYYY')
387
- .replace(/y/g, 'YY')
388
- .replace(/m/g, 'MM')
389
- .replace(/d/g, 'DD')
390
- .replace(/H/g, 'HH')
391
- .replace(/i/g, 'mm')
392
- .replace(/S/g, 'ss');
382
+ const momentFormat = convertToMomentFormat(format);
393
383
  return momentDate.format(momentFormat);
394
384
  }
395
385
  catch (_error) {
@@ -0,0 +1,43 @@
1
+ // @vitest-environment jsdom
2
+ import { createElement, useState } from 'react';
3
+ import { createRoot } from 'react-dom/client';
4
+ import { act } from 'react-dom/test-utils';
5
+ import { describe, expect, it, vi } from 'vitest';
6
+ import { DatePicker } from '../DatePicker';
7
+ const { captured } = vi.hoisted(() => ({ captured: [] }));
8
+ // react-flatpickr를 mock하여 DatePicker가 넘기는 options 참조를 렌더마다 수집한다.
9
+ // (vi.mock / vi.hoisted는 vitest가 import 위로 호이스팅하므로 DatePicker import보다 먼저 적용된다)
10
+ vi.mock('react-flatpickr', () => ({
11
+ default: (props) => {
12
+ captured.push(props.options);
13
+ return null;
14
+ },
15
+ }));
16
+ describe('#1 options useMemo 안정성', () => {
17
+ it('부모가 같은 props로 강제 리렌더해도 options 참조가 동일하게 유지된다(재생성 안 됨)', () => {
18
+ let bump = () => undefined;
19
+ const Harness = () => {
20
+ const [, setN] = useState(0);
21
+ bump = () => setN((x) => x + 1);
22
+ return createElement(DatePicker, { currentDate: '2024-03-20', onChangeDate: () => undefined });
23
+ };
24
+ const container = document.createElement('div');
25
+ document.body.appendChild(container);
26
+ const root = createRoot(container);
27
+ act(() => {
28
+ root.render(createElement(Harness));
29
+ });
30
+ const countAfterMount = captured.length;
31
+ const firstOptions = captured[captured.length - 1];
32
+ act(() => {
33
+ bump();
34
+ });
35
+ const lastOptions = captured[captured.length - 1];
36
+ expect(captured.length).toBeGreaterThan(countAfterMount); // 리렌더가 실제로 일어났는지
37
+ expect(Object.is(firstOptions, lastOptions)).toBe(true); // 동일 참조 = options 재생성 안 됨
38
+ act(() => {
39
+ root.unmount();
40
+ });
41
+ container.remove();
42
+ });
43
+ });
@@ -0,0 +1,109 @@
1
+ // @vitest-environment jsdom
2
+ import { createElement } from 'react';
3
+ import { createRoot } from 'react-dom/client';
4
+ import { act } from 'react-dom/test-utils';
5
+ import { afterEach, describe, expect, it, vi } from 'vitest';
6
+ import { DatePicker } from '../DatePicker';
7
+ const mounted = [];
8
+ function mount(props) {
9
+ const container = document.createElement('div');
10
+ document.body.appendChild(container);
11
+ const root = createRoot(container);
12
+ act(() => {
13
+ root.render(createElement(DatePicker, props));
14
+ });
15
+ mounted.push({ root, container });
16
+ return container;
17
+ }
18
+ afterEach(() => {
19
+ for (const { root, container } of mounted.splice(0)) {
20
+ act(() => {
21
+ root.unmount();
22
+ });
23
+ container.remove();
24
+ }
25
+ vi.restoreAllMocks();
26
+ });
27
+ describe('DatePicker 렌더 (스모크)', () => {
28
+ it('기본 props로 input을 렌더하고 크래시하지 않는다', () => {
29
+ const container = mount({ currentDate: '2024-03-20', onChangeDate: () => undefined });
30
+ expect(container.querySelector('input')).not.toBeNull();
31
+ });
32
+ it('enableTime 옵션으로도 정상 렌더한다', () => {
33
+ const container = mount({
34
+ currentDate: '2024-03-20 14:30',
35
+ onChangeDate: () => undefined,
36
+ datePickerOptions: { enableTime: true, dateFormat: 'Y-m-d H:i' },
37
+ });
38
+ expect(container.querySelector('input')).not.toBeNull();
39
+ });
40
+ });
41
+ describe('#4 document mousedown 리스너 정리', () => {
42
+ it('enableTime 마운트 후 언마운트 시, 추가된 mousedown 리스너가 모두 제거된다(누수 없음)', () => {
43
+ const added = [];
44
+ const removed = [];
45
+ const origAdd = document.addEventListener.bind(document);
46
+ const origRemove = document.removeEventListener.bind(document);
47
+ vi.spyOn(document, 'addEventListener').mockImplementation((type, fn, opts) => {
48
+ if (type === 'mousedown' && fn)
49
+ added.push(fn);
50
+ return origAdd(type, fn, opts);
51
+ });
52
+ vi.spyOn(document, 'removeEventListener').mockImplementation((type, fn, opts) => {
53
+ if (type === 'mousedown' && fn)
54
+ removed.push(fn);
55
+ return origRemove(type, fn, opts);
56
+ });
57
+ const container = document.createElement('div');
58
+ document.body.appendChild(container);
59
+ const root = createRoot(container);
60
+ act(() => {
61
+ root.render(createElement(DatePicker, {
62
+ currentDate: '2024-03-20 14:30',
63
+ onChangeDate: () => undefined,
64
+ datePickerOptions: { enableTime: true, dateFormat: 'Y-m-d H:i' },
65
+ }));
66
+ });
67
+ expect(added.length).toBeGreaterThanOrEqual(1);
68
+ act(() => {
69
+ root.unmount();
70
+ });
71
+ container.remove();
72
+ for (const fn of added) {
73
+ expect(removed).toContain(fn);
74
+ }
75
+ });
76
+ });
77
+ describe('#3 onValidationError.previousDate', () => {
78
+ // 허용 범위(10~25일) 기준: 마지막 유효 선택일과 범위 밖 위반일
79
+ const LAST_VALID_DAY = 15;
80
+ const OUT_OF_RANGE_DAY = 30;
81
+ it('min/max 위반 시 previousDate는 위반 날짜가 아니라 직전 유효 날짜다', () => {
82
+ const onValidationError = vi.fn();
83
+ const container = mount({
84
+ currentDate: '2024-03-20',
85
+ onChangeDate: () => undefined,
86
+ datePickerOptions: { minDate: '2024-03-10', maxDate: '2024-03-25', allowInput: true },
87
+ onValidationError,
88
+ });
89
+ // flatpickr 인스턴스는 초기화된 input 엘리먼트의 _flatpickr에 부착된다
90
+ const input = container.querySelector('input');
91
+ const instance = input?._flatpickr;
92
+ expect(instance).toBeTruthy();
93
+ // 1) 유효 날짜를 먼저 선택 → 직전 유효값(_previousDateBeforeInput)이 확립됨
94
+ act(() => {
95
+ instance.setDate(`2024-03-${LAST_VALID_DAY}`, true);
96
+ });
97
+ expect(onValidationError).not.toHaveBeenCalled();
98
+ // 2) 허용 범위 밖 날짜 선택 → 위반 분기 진입
99
+ act(() => {
100
+ instance.setDate(`2024-03-${OUT_OF_RANGE_DAY}`, true);
101
+ });
102
+ expect(onValidationError).toHaveBeenCalledTimes(1);
103
+ const arg = onValidationError.mock.calls[0][0];
104
+ expect(arg.violations).toContain('maxDate');
105
+ expect(arg.date.getDate()).toBe(OUT_OF_RANGE_DAY); // 위반 날짜
106
+ expect(arg.previousDate?.getDate()).toBe(LAST_VALID_DAY); // 직전 유효 날짜 (≠ 위반 날짜)
107
+ expect(arg.previousDate?.getTime()).not.toBe(arg.date.getTime());
108
+ });
109
+ });
@@ -7,6 +7,7 @@ export interface SelectProps extends Omit<ComponentPropsWithRef<'select'>, 'size
7
7
  disabledPlaceholder?: boolean;
8
8
  hintText?: string;
9
9
  destructive?: boolean;
10
+ readOnly?: boolean;
10
11
  size?: Extract<Size, 'xs' | 'sm' | 'md'>;
11
12
  optionItems?: OptionType[];
12
13
  register?: UseFormRegisterReturn;