@rchemist/listgrid 0.2.9 → 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,78 +1,41 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
-
import {
|
|
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 [
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
// 편집 중(open=true 유지)에는 부모 재렌더로 props 참조가 바뀌어도 재초기화하지 않아
|
|
29
|
-
// 사용자가 상세주소(address2) 등에 입력한 값이 유실되지 않도록 한다.
|
|
30
|
-
if (open) {
|
|
31
|
-
initializeData();
|
|
32
|
-
}
|
|
33
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
34
|
-
}, [open]);
|
|
35
|
-
return (_jsxs(_Fragment, { children: [_jsxs(Flex, { gap: 10, children: [_jsx("button", { type: "button", className: "rcm-button", "data-variant": "primary", onClick: () => {
|
|
36
|
-
setOpen(!open);
|
|
37
|
-
}, children: "\uC8FC\uC18C \uCC3E\uAE30" }), !required && !isBlank(postalCode) && (_jsx("button", { type: "button", className: "rcm-button", "data-variant": "outline", onClick: () => {
|
|
38
|
-
removeAddress();
|
|
39
|
-
}, children: "\uC8FC\uC18C \uC81C\uAC70" }))] }), open && (_jsx(Modal, { opened: open, onClose: () => {
|
|
40
|
-
initializeData();
|
|
41
|
-
setOpen(false);
|
|
42
|
-
}, closeOnClickOutside: true, 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,
|
|
43
35
|
/* lockScroll={true} */
|
|
44
|
-
position: "center", size: 'lg', zIndex: 11000, title: "\uC8FC\uC18C \uAC80\uC0C9", children:
|
|
45
|
-
setOpenDaumPostCode(true);
|
|
46
|
-
}, 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) => {
|
|
47
|
-
setAddress2(e.target.value ?? '');
|
|
48
|
-
}, 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)
|
|
49
|
-
? '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'
|
|
50
|
-
: '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: () => {
|
|
51
|
-
validateAndSubmit();
|
|
52
|
-
}, children: "\uC8FC\uC18C \uC785\uB825" }) })] }) })), openDaumPostCode && (_jsx(Modal, { opened: openDaumPostCode, onClose: () => {
|
|
53
|
-
setOpenDaumPostCode(false);
|
|
54
|
-
}, closeOnClickOutside: false, closeOnEscape: true,
|
|
55
|
-
/* lockScroll={true} */
|
|
56
|
-
position: "center", zIndex: 12000, children: _jsx(DaumPostcode, { onComplete: (data) => {
|
|
57
|
-
setState(data.sido);
|
|
58
|
-
setCity(data.sigungu);
|
|
59
|
-
setAddress1(data.roadAddress);
|
|
60
|
-
setAddress2('');
|
|
61
|
-
setPostalCode(data.zonecode);
|
|
62
|
-
setLongitude(data.longitude);
|
|
63
|
-
setLatitude(data.latitude);
|
|
64
|
-
setOpenDaumPostCode(false);
|
|
65
|
-
} }) }))] }));
|
|
36
|
+
position: "center", size: 'lg', zIndex: 11000, title: "\uC8FC\uC18C \uAC80\uC0C9", children: _jsx(PostCodeSelectorForm, { initialAddress: props.address, required: required, onSubmit: handleSubmit }, sessionKey) }))] }));
|
|
66
37
|
function removeAddress() {
|
|
67
38
|
if (!required) {
|
|
68
|
-
setState('');
|
|
69
|
-
setCity('');
|
|
70
|
-
setAddress1('');
|
|
71
|
-
setAddress2('');
|
|
72
|
-
setPostalCode('');
|
|
73
|
-
setLatitude(undefined);
|
|
74
|
-
setLongitude(undefined);
|
|
75
|
-
// onRemove 콜백이 있으면 사용, 없으면 기존 방식 사용
|
|
76
39
|
if (props.onRemove) {
|
|
77
40
|
props.onRemove();
|
|
78
41
|
}
|
|
@@ -88,44 +51,64 @@ export const PostCodeSelector = (props) => {
|
|
|
88
51
|
}
|
|
89
52
|
}
|
|
90
53
|
}
|
|
91
|
-
|
|
92
|
-
|
|
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 = () => {
|
|
93
86
|
setError('');
|
|
94
87
|
if (isBlank(address1)) {
|
|
95
88
|
setError('주소 선택을 눌러 주소를 입력하세요');
|
|
96
|
-
|
|
97
|
-
}
|
|
98
|
-
if (validated) {
|
|
99
|
-
if (isBlank(address2)) {
|
|
100
|
-
setError('상세 주소를 반드시 입력해야 합니다.');
|
|
101
|
-
validated = false;
|
|
102
|
-
}
|
|
89
|
+
return;
|
|
103
90
|
}
|
|
104
|
-
if (
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const address = {
|
|
108
|
-
state: state ?? address1.split(' ')[0],
|
|
109
|
-
city: city ?? address1.split(' ')[1],
|
|
110
|
-
address1: address1,
|
|
111
|
-
address2: address2,
|
|
112
|
-
postalCode: postalCode,
|
|
113
|
-
...(longitudeValue !== undefined ? { longitude: longitudeValue } : {}),
|
|
114
|
-
...(latitudeValue !== undefined ? { latitude: latitudeValue } : {}),
|
|
115
|
-
};
|
|
116
|
-
props.onSubmit(address);
|
|
117
|
-
setOpen(false);
|
|
91
|
+
if (isBlank(address2)) {
|
|
92
|
+
setError('상세 주소를 반드시 입력해야 합니다.');
|
|
93
|
+
return;
|
|
118
94
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
}
|
|
130
|
-
|
|
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 }) }))] }));
|
|
131
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
|
/* ------------------------------------------------------------------ *
|
package/dist/styles.css
CHANGED
|
@@ -5690,9 +5690,60 @@
|
|
|
5690
5690
|
/* ------------------------------------------------------------------ *
|
|
5691
5691
|
* PostCodeSelector (주소 선택기)
|
|
5692
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
|
+
|
|
5693
5717
|
.rcm-postcode-input-row {
|
|
5694
5718
|
display: flex;
|
|
5695
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);
|
|
5696
5747
|
}
|
|
5697
5748
|
|
|
5698
5749
|
/* ------------------------------------------------------------------ *
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rchemist/listgrid",
|
|
3
|
-
"version": "0.2.
|
|
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": [
|