@rchemist/listgrid 0.2.6 → 0.2.10

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.
@@ -5,5 +5,9 @@ interface PostCodeSelectorProps {
5
5
  onRemove?: (() => void) | undefined;
6
6
  required: boolean;
7
7
  }
8
+ /**
9
+ * 외부 (AddressFieldView / EntityForm) 상호작용을 담당하는 껍데기.
10
+ * 모달 본문은 아래 PostCodeSelectorForm 으로 분리해 외부 재렌더에 완전히 격리한다.
11
+ */
8
12
  export declare const PostCodeSelector: (props: PostCodeSelectorProps) => import("react/jsx-runtime").JSX.Element;
9
13
  export {};
@@ -1,72 +1,41 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
- import { useEffect, useState } from 'react';
3
+ import { memo, useCallback, useRef, useState } from 'react';
4
4
  import { isBlank } from '../../../utils/StringUtil';
5
- import { Box } from '../../../ui';
6
5
  import { Flex } from '../../../ui';
7
- import { Grid } from '../../../ui';
8
6
  import { Modal } from '../../../ui';
9
- import clsx from 'clsx';
10
- // CSS module removed in Stage 8 (host app supplies styling)
11
- const classes = {};
12
7
  import DaumPostcode from 'react-daum-postcode';
8
+ /**
9
+ * 외부 (AddressFieldView / EntityForm) 상호작용을 담당하는 껍데기.
10
+ * 모달 본문은 아래 PostCodeSelectorForm 으로 분리해 외부 재렌더에 완전히 격리한다.
11
+ */
13
12
  export const PostCodeSelector = (props) => {
14
13
  const [open, setOpen] = useState(false);
15
- const [openDaumPostCode, setOpenDaumPostCode] = useState(false);
16
- const [postalCode, setPostalCode] = useState();
17
- const [state, setState] = useState();
18
- const [city, setCity] = useState();
19
- const [address1, setAddress1] = useState();
20
- const [address2, setAddress2] = useState();
21
- const [longitude, setLongitude] = useState();
22
- const [latitude, setLatitude] = useState();
23
- const [error, setError] = useState('');
24
- const disabled = isBlank(postalCode);
14
+ const [sessionKey, setSessionKey] = useState(0);
15
+ // props.onSubmit 부모 재렌더마다 새 참조가 되어도 Form 내부 memo 비교를 깨지 않도록
16
+ // 안정된 핸들러를 ref 패턴으로 구성한다.
17
+ const onSubmitRef = useRef(props.onSubmit);
18
+ onSubmitRef.current = props.onSubmit;
19
+ const handleSubmit = useCallback((address) => {
20
+ onSubmitRef.current(address);
21
+ setOpen(false);
22
+ }, []);
23
+ const handleOpen = () => {
24
+ // 모달을 열 때마다 sessionKey 를 증가시켜 Form 인스턴스를 새로 마운트한다.
25
+ // lazy initializer 가 initialAddress 로부터 깨끗이 초기화된다.
26
+ setSessionKey((k) => k + 1);
27
+ setOpen(true);
28
+ };
29
+ const handleClose = () => {
30
+ setOpen(false);
31
+ };
25
32
  const required = props.required;
26
- useEffect(() => {
27
- initializeData();
28
- }, [props]);
29
- return (_jsxs(_Fragment, { children: [_jsxs(Flex, { gap: 10, children: [_jsx("button", { type: "button", className: "rcm-button", "data-variant": "primary", onClick: () => {
30
- setOpen(!open);
31
- }, children: "\uC8FC\uC18C \uCC3E\uAE30" }), !required && !isBlank(postalCode) && (_jsx("button", { type: "button", className: "rcm-button", "data-variant": "outline", onClick: () => {
32
- removeAddress();
33
- }, children: "\uC8FC\uC18C \uC81C\uAC70" }))] }), open && (_jsx(Modal, { opened: open, onClose: () => {
34
- initializeData();
35
- setOpen(false);
36
- }, closeOnClickOutside: true, closeOnEscape: true,
37
- /* lockScroll={true} */
38
- position: "center", size: 'lg', zIndex: 11000, title: "\uC8FC\uC18C \uAC80\uC0C9", children: _jsxs("div", { style: { padding: `2rem` }, children: [_jsxs(Grid, { className: classes.row, gutter: 16, align: "center", children: [_jsx(Grid.Col, { span: 2, className: clsx(classes.title, 'text-right pr-2'), children: "\uC6B0\uD3B8\uBC88\uD638" }), _jsx(Grid.Col, { span: 10, children: _jsxs("div", { className: "rcm-postcode-input-row", children: [_jsx("input", { type: "text", value: postalCode, disabled: true, readOnly: true, className: "rcm-input" }), _jsx("button", { type: "button", className: "rcm-button", "data-variant": "primary", onClick: () => {
39
- setOpenDaumPostCode(true);
40
- }, children: "\uC8FC\uC18C \uAC80\uC0C9" })] }) })] }), _jsxs(Grid, { className: clsx(classes.row, classes.subRow), gutter: 16, align: "center", children: [_jsx(Grid.Col, { span: 2, className: clsx(classes.title, 'text-right pr-2'), children: "\uC8FC\uC18C" }), _jsx(Grid.Col, { span: 10, children: _jsx("input", { type: "text", value: address1, placeholder: '주소 검색을 눌러 주소를 선택하세요', readOnly: true, disabled: true, className: "rcm-input" }) })] }), !disabled && (_jsxs(Grid, { className: clsx(classes.row, classes.subRow), gutter: 16, align: "center", children: [_jsx(Grid.Col, { span: 2, className: clsx(classes.title, 'text-right pr-2'), children: "\uC0C1\uC138 \uC8FC\uC18C" }), _jsxs(Grid.Col, { span: 10, children: [_jsx("input", { type: "text", value: address2, placeholder: '상세 주소를 입력하세요', onChange: (e) => {
41
- setAddress2(e.target.value ?? '');
42
- }, className: "rcm-input" }), !isBlank(error) && _jsx(Box, { className: classes.error, children: error })] })] })), _jsx(Box, { className: classes.buttonContainer, children: _jsx("button", { type: "button", className: `px-4 py-2 rounded-md text-sm font-medium transition-colors ${disabled || isBlank(address2)
43
- ? 'btn btn-outline-primary border border-gray-300 text-gray-500 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2'
44
- : 'btn btn-outline-primary border border-blue-500 text-blue-500 hover:bg-blue-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2'}`, disabled: disabled || isBlank(address2), onClick: () => {
45
- validateAndSubmit();
46
- }, children: "\uC8FC\uC18C \uC785\uB825" }) })] }) })), openDaumPostCode && (_jsx(Modal, { opened: openDaumPostCode, onClose: () => {
47
- setOpenDaumPostCode(false);
48
- }, closeOnClickOutside: false, closeOnEscape: true,
33
+ const hasAddress = !isBlank(props.address?.postalCode);
34
+ return (_jsxs(_Fragment, { children: [_jsxs(Flex, { gap: 10, children: [_jsx("button", { type: "button", className: "rcm-button", "data-variant": "primary", onClick: handleOpen, children: "\uC8FC\uC18C \uCC3E\uAE30" }), !required && hasAddress && (_jsx("button", { type: "button", className: "rcm-button", "data-variant": "outline", onClick: removeAddress, children: "\uC8FC\uC18C \uC81C\uAC70" }))] }), open && (_jsx(Modal, { opened: open, onClose: handleClose, closeOnClickOutside: true, closeOnEscape: true,
49
35
  /* lockScroll={true} */
50
- position: "center", zIndex: 12000, children: _jsx(DaumPostcode, { onComplete: (data) => {
51
- setState(data.sido);
52
- setCity(data.sigungu);
53
- setAddress1(data.roadAddress);
54
- setAddress2('');
55
- setPostalCode(data.zonecode);
56
- setLongitude(data.longitude);
57
- setLatitude(data.latitude);
58
- setOpenDaumPostCode(false);
59
- } }) }))] }));
36
+ position: "center", size: 'lg', zIndex: 11000, title: "\uC8FC\uC18C \uAC80\uC0C9", children: _jsx(PostCodeSelectorForm, { initialAddress: props.address, required: required, onSubmit: handleSubmit }, sessionKey) }))] }));
60
37
  function removeAddress() {
61
38
  if (!required) {
62
- setState('');
63
- setCity('');
64
- setAddress1('');
65
- setAddress2('');
66
- setPostalCode('');
67
- setLatitude(undefined);
68
- setLongitude(undefined);
69
- // onRemove 콜백이 있으면 사용, 없으면 기존 방식 사용
70
39
  if (props.onRemove) {
71
40
  props.onRemove();
72
41
  }
@@ -82,44 +51,64 @@ export const PostCodeSelector = (props) => {
82
51
  }
83
52
  }
84
53
  }
85
- function validateAndSubmit() {
86
- let validated = true;
54
+ };
55
+ /**
56
+ * 모달 내부 전용 입력 폼.
57
+ * - initialAddress 는 최초 1회 lazy initializer 로만 소비한다 (이후 prop 변화 무시).
58
+ * - address2 는 빈 문자열로 시작해 controlled input 으로 고정 (uncontrolled→controlled 전이 차단).
59
+ * - props.onSubmit 은 "주소 입력" 버튼을 누를 때만 호출. 타이핑은 로컬 state 만 갱신.
60
+ * - React.memo(() => true) 로 부모 재렌더에 완전히 격리하여 타이핑 중 포커스 유실을 방지.
61
+ * 세션 전환은 부모에서 key 변경으로 처리하므로 prop 업데이트를 막아도 문제 없음.
62
+ */
63
+ const PostCodeSelectorFormImpl = (props) => {
64
+ const [postalCode, setPostalCode] = useState(() => props.initialAddress?.postalCode ?? '');
65
+ const [state, setState] = useState(() => props.initialAddress?.state ?? '');
66
+ const [city, setCity] = useState(() => props.initialAddress?.city ?? '');
67
+ const [address1, setAddress1] = useState(() => props.initialAddress?.address1 ?? '');
68
+ const [address2, setAddress2] = useState(() => props.initialAddress?.address2 ?? '');
69
+ const [longitude, setLongitude] = useState(() => props.initialAddress?.longitude);
70
+ const [latitude, setLatitude] = useState(() => props.initialAddress?.latitude);
71
+ const [error, setError] = useState('');
72
+ const [openDaumPostCode, setOpenDaumPostCode] = useState(false);
73
+ const disabled = isBlank(postalCode);
74
+ const submitDisabled = disabled || isBlank(address2);
75
+ const handleDaumComplete = (data) => {
76
+ setState(data.sido);
77
+ setCity(data.sigungu);
78
+ setAddress1(data.roadAddress);
79
+ setAddress2('');
80
+ setPostalCode(data.zonecode);
81
+ setLongitude(data.longitude);
82
+ setLatitude(data.latitude);
83
+ setOpenDaumPostCode(false);
84
+ };
85
+ const validateAndSubmit = () => {
87
86
  setError('');
88
87
  if (isBlank(address1)) {
89
88
  setError('주소 선택을 눌러 주소를 입력하세요');
90
- validated = false;
91
- }
92
- if (validated) {
93
- if (isBlank(address2)) {
94
- setError('상세 주소를 반드시 입력해야 합니다.');
95
- validated = false;
96
- }
89
+ return;
97
90
  }
98
- if (validated) {
99
- const longitudeValue = isBlank(longitude) ? undefined : Number(longitude);
100
- const latitudeValue = isBlank(latitude) ? undefined : Number(latitude);
101
- const address = {
102
- state: state ?? address1.split(' ')[0],
103
- city: city ?? address1.split(' ')[1],
104
- address1: address1,
105
- address2: address2,
106
- postalCode: postalCode,
107
- ...(longitudeValue !== undefined ? { longitude: longitudeValue } : {}),
108
- ...(latitudeValue !== undefined ? { latitude: latitudeValue } : {}),
109
- };
110
- props.onSubmit(address);
111
- setOpen(false);
91
+ if (isBlank(address2)) {
92
+ setError('상세 주소를 반드시 입력해야 합니다.');
93
+ return;
112
94
  }
113
- }
114
- function initializeData() {
115
- if (props.address) {
116
- setCity(props.address.city);
117
- setState(props.address.state);
118
- setPostalCode(props.address.postalCode);
119
- setAddress1(props.address.address1);
120
- setAddress2(props.address.address2);
121
- setLongitude(props.address.longitude);
122
- setLatitude(props.address.latitude);
123
- }
124
- }
95
+ const longitudeValue = isBlank(longitude) ? undefined : Number(longitude);
96
+ const latitudeValue = isBlank(latitude) ? undefined : Number(latitude);
97
+ const address = {
98
+ state: state || address1.split(' ')[0],
99
+ city: city || address1.split(' ')[1],
100
+ address1,
101
+ address2,
102
+ postalCode,
103
+ ...(longitudeValue !== undefined ? { longitude: longitudeValue } : {}),
104
+ ...(latitudeValue !== undefined ? { latitude: latitudeValue } : {}),
105
+ };
106
+ props.onSubmit(address);
107
+ };
108
+ return (_jsxs("div", { className: "rcm-postcode-form", children: [_jsxs("div", { className: "rcm-postcode-row", children: [_jsx("div", { className: "rcm-postcode-row-label", children: "\uC6B0\uD3B8\uBC88\uD638" }), _jsx("div", { className: "rcm-postcode-row-content", children: _jsxs("div", { className: "rcm-postcode-input-row", children: [_jsx("input", { type: "text", value: postalCode, disabled: true, readOnly: true, className: "rcm-input" }), _jsx("button", { type: "button", className: "rcm-button", "data-variant": "primary", onClick: () => setOpenDaumPostCode(true), children: "\uC8FC\uC18C \uAC80\uC0C9" })] }) })] }), _jsxs("div", { className: "rcm-postcode-row", children: [_jsx("div", { className: "rcm-postcode-row-label", children: "\uC8FC\uC18C" }), _jsx("div", { className: "rcm-postcode-row-content", children: _jsx("input", { type: "text", value: address1, placeholder: "\uC8FC\uC18C \uAC80\uC0C9\uC744 \uB20C\uB7EC \uC8FC\uC18C\uB97C \uC120\uD0DD\uD558\uC138\uC694", readOnly: true, disabled: true, className: "rcm-input rcm-postcode-input-full" }) })] }), !disabled && (_jsxs("div", { className: "rcm-postcode-row", children: [_jsx("div", { className: "rcm-postcode-row-label", children: "\uC0C1\uC138 \uC8FC\uC18C" }), _jsxs("div", { className: "rcm-postcode-row-content", children: [_jsx("input", { type: "text", value: address2, placeholder: "\uC0C1\uC138 \uC8FC\uC18C\uB97C \uC785\uB825\uD558\uC138\uC694", onChange: (e) => setAddress2(e.target.value ?? ''), className: "rcm-input rcm-postcode-input-full" }), !isBlank(error) && _jsx("div", { className: "rcm-postcode-error", children: error })] })] })), _jsx("div", { className: "rcm-postcode-submit-row", children: _jsx("button", { type: "button", className: `px-4 py-2 rounded-md text-sm font-medium transition-colors ${submitDisabled
109
+ ? 'btn btn-outline-primary border border-gray-300 text-gray-500 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2'
110
+ : 'btn btn-outline-primary border border-blue-500 text-blue-500 hover:bg-blue-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2'}`, disabled: submitDisabled, onClick: validateAndSubmit, children: "\uC8FC\uC18C \uC785\uB825" }) }), openDaumPostCode && (_jsx(Modal, { opened: openDaumPostCode, onClose: () => setOpenDaumPostCode(false), closeOnClickOutside: false, closeOnEscape: true,
111
+ /* lockScroll={true} */
112
+ position: "center", zIndex: 12000, children: _jsx(DaumPostcode, { onComplete: handleDaumComplete }) }))] }));
125
113
  };
114
+ const PostCodeSelectorForm = memo(PostCodeSelectorFormImpl, () => true);
@@ -721,9 +721,60 @@
721
721
  /* ------------------------------------------------------------------ *
722
722
  * PostCodeSelector (주소 선택기)
723
723
  * ------------------------------------------------------------------ */
724
+ .rcm-postcode-form {
725
+ display: flex;
726
+ flex-direction: column;
727
+ gap: var(--rcm-space-md);
728
+ }
729
+
730
+ .rcm-postcode-row {
731
+ display: grid;
732
+ grid-template-columns: 6.5rem 1fr;
733
+ gap: var(--rcm-space-md);
734
+ align-items: center;
735
+ }
736
+
737
+ .rcm-postcode-row-label {
738
+ text-align: right;
739
+ padding-right: var(--rcm-space-sm);
740
+ font-weight: var(--rcm-font-weight-medium);
741
+ color: var(--rcm-color-text);
742
+ }
743
+
744
+ .rcm-postcode-row-content {
745
+ min-width: 0;
746
+ }
747
+
724
748
  .rcm-postcode-input-row {
725
749
  display: flex;
726
750
  gap: var(--rcm-space-sm);
751
+ align-items: center;
752
+ }
753
+
754
+ .rcm-postcode-input-row > .rcm-input {
755
+ flex: 1 1 auto;
756
+ min-width: 0;
757
+ }
758
+
759
+ .rcm-postcode-input-row > .rcm-button {
760
+ flex: 0 0 auto;
761
+ white-space: nowrap;
762
+ }
763
+
764
+ .rcm-postcode-input-full {
765
+ width: 100%;
766
+ }
767
+
768
+ .rcm-postcode-submit-row {
769
+ display: flex;
770
+ justify-content: center;
771
+ margin-top: var(--rcm-space-sm);
772
+ }
773
+
774
+ .rcm-postcode-error {
775
+ margin-top: var(--rcm-space-xs);
776
+ color: var(--rcm-color-error);
777
+ font-size: var(--rcm-font-size-sm);
727
778
  }
728
779
 
729
780
  /* ------------------------------------------------------------------ *
@@ -511,7 +511,10 @@
511
511
  pointer-events: none;
512
512
  }
513
513
 
514
- .rcm-card-m2o-search-input {
514
+ /* 특이도를 (0,2,0)으로 맞춰 primitives.css 의 .rcm-input[data-size='sm'] 과 동률을 만든다.
515
+ 번들 순서(primitives → layouts)상 동률 시 layouts.css 규칙이 승리하여
516
+ 아이콘 공간 확보를 위한 left padding 이 유지된다. */
517
+ .rcm-card-m2o-search-input.rcm-input {
515
518
  width: 100%;
516
519
  padding: 0.625rem 1rem 0.625rem 2.5rem;
517
520
  font-size: var(--rcm-font-size-sm);
@@ -520,7 +523,7 @@
520
523
  border-radius: var(--rcm-radius-lg);
521
524
  }
522
525
 
523
- .rcm-card-m2o-search-input:focus {
526
+ .rcm-card-m2o-search-input.rcm-input:focus {
524
527
  outline: none;
525
528
  border-color: var(--rcm-color-primary);
526
529
  box-shadow: 0 0 0 2px color-mix(in srgb, var(--rcm-color-primary) 20%, transparent);
package/dist/styles.css CHANGED
@@ -2271,7 +2271,10 @@
2271
2271
  pointer-events: none;
2272
2272
  }
2273
2273
 
2274
- .rcm-card-m2o-search-input {
2274
+ /* 특이도를 (0,2,0)으로 맞춰 primitives.css 의 .rcm-input[data-size='sm'] 과 동률을 만든다.
2275
+ 번들 순서(primitives → layouts)상 동률 시 layouts.css 규칙이 승리하여
2276
+ 아이콘 공간 확보를 위한 left padding 이 유지된다. */
2277
+ .rcm-card-m2o-search-input.rcm-input {
2275
2278
  width: 100%;
2276
2279
  padding: 0.625rem 1rem 0.625rem 2.5rem;
2277
2280
  font-size: var(--rcm-font-size-sm);
@@ -2280,7 +2283,7 @@
2280
2283
  border-radius: var(--rcm-radius-lg);
2281
2284
  }
2282
2285
 
2283
- .rcm-card-m2o-search-input:focus {
2286
+ .rcm-card-m2o-search-input.rcm-input:focus {
2284
2287
  outline: none;
2285
2288
  border-color: var(--rcm-color-primary);
2286
2289
  box-shadow: 0 0 0 2px color-mix(in srgb, var(--rcm-color-primary) 20%, transparent);
@@ -5687,9 +5690,60 @@
5687
5690
  /* ------------------------------------------------------------------ *
5688
5691
  * PostCodeSelector (주소 선택기)
5689
5692
  * ------------------------------------------------------------------ */
5693
+ .rcm-postcode-form {
5694
+ display: flex;
5695
+ flex-direction: column;
5696
+ gap: var(--rcm-space-md);
5697
+ }
5698
+
5699
+ .rcm-postcode-row {
5700
+ display: grid;
5701
+ grid-template-columns: 6.5rem 1fr;
5702
+ gap: var(--rcm-space-md);
5703
+ align-items: center;
5704
+ }
5705
+
5706
+ .rcm-postcode-row-label {
5707
+ text-align: right;
5708
+ padding-right: var(--rcm-space-sm);
5709
+ font-weight: var(--rcm-font-weight-medium);
5710
+ color: var(--rcm-color-text);
5711
+ }
5712
+
5713
+ .rcm-postcode-row-content {
5714
+ min-width: 0;
5715
+ }
5716
+
5690
5717
  .rcm-postcode-input-row {
5691
5718
  display: flex;
5692
5719
  gap: var(--rcm-space-sm);
5720
+ align-items: center;
5721
+ }
5722
+
5723
+ .rcm-postcode-input-row > .rcm-input {
5724
+ flex: 1 1 auto;
5725
+ min-width: 0;
5726
+ }
5727
+
5728
+ .rcm-postcode-input-row > .rcm-button {
5729
+ flex: 0 0 auto;
5730
+ white-space: nowrap;
5731
+ }
5732
+
5733
+ .rcm-postcode-input-full {
5734
+ width: 100%;
5735
+ }
5736
+
5737
+ .rcm-postcode-submit-row {
5738
+ display: flex;
5739
+ justify-content: center;
5740
+ margin-top: var(--rcm-space-sm);
5741
+ }
5742
+
5743
+ .rcm-postcode-error {
5744
+ margin-top: var(--rcm-space-xs);
5745
+ color: var(--rcm-color-error);
5746
+ font-size: var(--rcm-font-size-sm);
5693
5747
  }
5694
5748
 
5695
5749
  /* ------------------------------------------------------------------ *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rchemist/listgrid",
3
- "version": "0.2.6",
3
+ "version": "0.2.10",
4
4
  "private": false,
5
5
  "description": "Framework-free React CRUD UI engine — primitive-based design system, data-attr theming, and a full list/form renderer for RCM-framework-style entity backends.",
6
6
  "keywords": [
@@ -73,6 +73,7 @@
73
73
  "build": "tsc -p tsconfig.build.json && npm run build:styles",
74
74
  "build:styles": "mkdir -p dist/styles && cp src/listgrid/styles/tokens.css src/listgrid/styles/primitives.css src/listgrid/styles/layouts.css src/listgrid/styles/components.css src/listgrid/styles/base.css dist/styles/ && cat src/listgrid/styles/tokens.css src/listgrid/styles/primitives.css src/listgrid/styles/layouts.css src/listgrid/styles/components.css src/listgrid/styles/base.css > dist/styles.css",
75
75
  "clean": "rm -rf dist",
76
+ "prepublishOnly": "npm run clean && npm run type-check && npm test && npm run build",
76
77
  "lint": "eslint 'src/**/*.{ts,tsx}'",
77
78
  "lint:fix": "eslint 'src/**/*.{ts,tsx}' --fix",
78
79
  "format": "prettier --write 'src/**/*.{ts,tsx,css,json}'",