|
| 1 | +import type { ChangeEvent, ReactNode } from "react"; |
1 | 2 | import { useCallback, useEffect, useMemo, useState } from "react"; |
2 | | -import type { ChangeEvent } from "react"; |
3 | 3 |
|
4 | 4 | import { default as Renderer } from "../lib"; |
5 | 5 |
|
6 | 6 | import type { Questionnaire, QuestionnaireResponse } from "fhir/r5"; |
7 | | -import CodeMirror from "@uiw/react-codemirror"; |
8 | 7 | import { json } from "@codemirror/lang-json"; |
| 8 | +import CodeMirror from "@uiw/react-codemirror"; |
| 9 | +import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; |
9 | 10 | import { demoSamples } from "./samples"; |
10 | 11 |
|
| 12 | +type DensePanelProps = { |
| 13 | + title: string; |
| 14 | + children: ReactNode; |
| 15 | + defaultSize: number; |
| 16 | + minSize: number; |
| 17 | + minWidthClass?: string; |
| 18 | + headerAction?: ReactNode; |
| 19 | +}; |
| 20 | + |
| 21 | +type ApplyQuestionnaireButtonProps = { |
| 22 | + onClick: () => void; |
| 23 | +}; |
| 24 | + |
| 25 | +function ApplyQuestionnaireButton({ |
| 26 | + onClick, |
| 27 | +}: ApplyQuestionnaireButtonProps) { |
| 28 | + return ( |
| 29 | + <button |
| 30 | + type="button" |
| 31 | + onClick={onClick} |
| 32 | + className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded bg-blue-700 text-white text-xs transition hover:bg-blue-600 active:translate-y-px" |
| 33 | + > |
| 34 | + <span aria-hidden="true">✓</span> |
| 35 | + <span className="sr-only">Apply Questionnaire</span> |
| 36 | + </button> |
| 37 | + ); |
| 38 | +} |
| 39 | + |
| 40 | +function DensePanel({ |
| 41 | + title, |
| 42 | + headerAction, |
| 43 | + children, |
| 44 | + defaultSize, |
| 45 | + minSize, |
| 46 | + minWidthClass, |
| 47 | +}: DensePanelProps) { |
| 48 | + const actionSlot = |
| 49 | + headerAction ?? ( |
| 50 | + <span className="block h-6 w-6 flex-shrink-0" aria-hidden="true" /> |
| 51 | + ); |
| 52 | + |
| 53 | + return ( |
| 54 | + <Panel |
| 55 | + defaultSize={defaultSize} |
| 56 | + minSize={minSize} |
| 57 | + className={`flex h-full flex-col border rounded border-slate-200 bg-white ${minWidthClass ?? ""}`} |
| 58 | + > |
| 59 | + <div className="flex items-center justify-between gap-1 border-b border-slate-200 bg-slate-100 pl-2 pr-3"> |
| 60 | + <h2 className="py-2.5 flex-1 min-w-0 text-xs font-semibold uppercase tracking-wide text-slate-700 whitespace-nowrap"> |
| 61 | + {title} |
| 62 | + </h2> |
| 63 | + {actionSlot} |
| 64 | + </div> |
| 65 | + <div className="flex flex-1 flex-col gap-2 overflow-hidden px-3 py-3"> |
| 66 | + {children} |
| 67 | + </div> |
| 68 | + </Panel> |
| 69 | + ); |
| 70 | +} |
| 71 | + |
11 | 72 | const cloneQuestionnaire = (input: Questionnaire): Questionnaire => |
12 | 73 | JSON.parse(JSON.stringify(input)) as Questionnaire; |
13 | 74 |
|
@@ -114,72 +175,89 @@ export function App() { |
114 | 175 | }, [questionnaireSource]); |
115 | 176 |
|
116 | 177 | return ( |
117 | | - <main className="grid min-h-screen grid-cols-[minmax(24rem,28rem)_minmax(40rem,1fr)_minmax(24rem,28rem)] items-start gap-8 p-8"> |
118 | | - <aside className="sticky top-8 flex h-[calc(100vh-4rem)] flex-col gap-3 rounded-xl border border-slate-200 bg-white p-4"> |
119 | | - <div className="flex items-center justify-between gap-4"> |
120 | | - <h2 className="py-1 text-lg font-medium">Questionnaire</h2> |
121 | | - {hasPendingChanges ? ( |
122 | | - <button |
123 | | - type="button" |
124 | | - onClick={handleApplyQuestionnaire} |
125 | | - className="flex h-9 w-9 items-center justify-center rounded-lg bg-blue-700 text-white transition hover:bg-blue-600 active:translate-y-px" |
126 | | - > |
127 | | - <span aria-hidden="true">✓</span> |
128 | | - <span className="sr-only">Apply Questionnaire</span> |
129 | | - </button> |
| 178 | + <main className="h-screen w-screen bg-slate-50 p-3"> |
| 179 | + <PanelGroup |
| 180 | + direction="horizontal" |
| 181 | + autoSaveId="demo-panel-layout" |
| 182 | + className="flex h-full w-full" |
| 183 | + > |
| 184 | + <DensePanel |
| 185 | + title="Questionnaire" |
| 186 | + defaultSize={26} |
| 187 | + minSize={18} |
| 188 | + minWidthClass="min-w-[16rem]" |
| 189 | + headerAction={ |
| 190 | + hasPendingChanges ? ( |
| 191 | + <ApplyQuestionnaireButton |
| 192 | + onClick={handleApplyQuestionnaire} |
| 193 | + /> |
| 194 | + ) : undefined |
| 195 | + } |
| 196 | + > |
| 197 | + <select |
| 198 | + id="sample-select" |
| 199 | + value={selectedSampleId} |
| 200 | + onChange={handleSelectSample} |
| 201 | + className="w-full rounded border border-slate-200 bg-white px-2 py-2 text-sm text-slate-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200" |
| 202 | + > |
| 203 | + {sampleOptions.map((sample) => ( |
| 204 | + <option key={sample.id} value={sample.id}> |
| 205 | + {sample.label} |
| 206 | + </option> |
| 207 | + ))} |
| 208 | + </select> |
| 209 | + <div className="flex flex-1 overflow-hidden border border-slate-200"> |
| 210 | + <CodeMirror |
| 211 | + aria-label="Questionnaire JSON" |
| 212 | + value={questionnaireSource} |
| 213 | + className="h-full text-xs" |
| 214 | + style={{ height: "100%", width: "100%" }} |
| 215 | + extensions={editorExtensions} |
| 216 | + basicSetup={{ lineNumbers: false, foldGutter: false }} |
| 217 | + onChange={(value) => setQuestionnaireSource(value)} |
| 218 | + /> |
| 219 | + </div> |
| 220 | + {questionnaireError ? ( |
| 221 | + <p className="w-full rounded bg-red-200 px-2 py-2 text-xs text-red-800"> |
| 222 | + {questionnaireError} |
| 223 | + </p> |
130 | 224 | ) : null} |
131 | | - </div> |
132 | | - <select |
133 | | - id="sample-select" |
134 | | - value={selectedSampleId} |
135 | | - onChange={handleSelectSample} |
136 | | - className="rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-slate-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200" |
| 225 | + </DensePanel> |
| 226 | + <PanelResizeHandle className="w-3 cursor-col-resize bg-transparent" /> |
| 227 | + <DensePanel |
| 228 | + title="Form" |
| 229 | + defaultSize={48} |
| 230 | + minSize={30} |
| 231 | + minWidthClass="min-w-[24rem]" |
| 232 | + > |
| 233 | + <div className="h-full flex-1 overflow-auto"> |
| 234 | + <Renderer |
| 235 | + questionnaire={questionnaire} |
| 236 | + onSubmit={setQuestionnaireResponse} |
| 237 | + onChange={setQuestionnaireResponse} |
| 238 | + /> |
| 239 | + </div> |
| 240 | + </DensePanel> |
| 241 | + <PanelResizeHandle className="w-3 cursor-col-resize bg-transparent" /> |
| 242 | + <DensePanel |
| 243 | + title="Questionnaire Response" |
| 244 | + defaultSize={26} |
| 245 | + minSize={18} |
| 246 | + minWidthClass="min-w-[16rem]" |
137 | 247 | > |
138 | | - {sampleOptions.map((sample) => ( |
139 | | - <option key={sample.id} value={sample.id}> |
140 | | - {sample.label} |
141 | | - </option> |
142 | | - ))} |
143 | | - </select> |
144 | | - <div className="flex-1 overflow-hidden rounded-lg border border-slate-200 flex"> |
145 | | - <CodeMirror |
146 | | - aria-label="Questionnaire JSON" |
147 | | - value={questionnaireSource} |
148 | | - className="h-full" |
149 | | - style={{ height: "100%", width: "100%" }} |
150 | | - extensions={editorExtensions} |
151 | | - onChange={(value) => setQuestionnaireSource(value)} |
152 | | - /> |
153 | | - </div> |
154 | | - {questionnaireError ? ( |
155 | | - <p className="rounded-lg bg-red-200 px-3 py-2 text-sm text-red-800"> |
156 | | - {questionnaireError} |
157 | | - </p> |
158 | | - ) : null} |
159 | | - </aside> |
160 | | - |
161 | | - <section className="flex h-[calc(100vh-4rem)] flex-col overflow-auto rounded-xl border border-slate-200 bg-white"> |
162 | | - <Renderer |
163 | | - questionnaire={questionnaire} |
164 | | - onSubmit={setQuestionnaireResponse} |
165 | | - onChange={setQuestionnaireResponse} |
166 | | - /> |
167 | | - </section> |
168 | | - |
169 | | - <aside className="sticky top-8 flex h-[calc(100vh-4rem)] flex-col gap-3 rounded-xl border border-slate-200 bg-white p-4"> |
170 | | - <div className="flex items-center justify-between gap-4"> |
171 | | - <h2 className="py-1 text-lg font-medium">Questionnaire Response</h2> |
172 | | - </div> |
173 | | - <div className="flex-1 overflow-hidden rounded-lg border border-slate-200"> |
174 | | - <CodeMirror |
175 | | - aria-label="Questionnaire Response JSON" |
176 | | - value={JSON.stringify(questionnaireResponse ?? {}, null, 2)} |
177 | | - className="h-full" |
178 | | - extensions={responseViewerExtensions} |
179 | | - readOnly |
180 | | - /> |
181 | | - </div> |
182 | | - </aside> |
| 248 | + <div className="flex flex-1 overflow-hidden border border-slate-200"> |
| 249 | + <CodeMirror |
| 250 | + aria-label="Questionnaire Response JSON" |
| 251 | + value={JSON.stringify(questionnaireResponse ?? {}, null, 2)} |
| 252 | + className="h-full text-xs" |
| 253 | + style={{ height: "100%", width: "100%" }} |
| 254 | + extensions={responseViewerExtensions} |
| 255 | + basicSetup={{ lineNumbers: false, foldGutter: false }} |
| 256 | + readOnly |
| 257 | + /> |
| 258 | + </div> |
| 259 | + </DensePanel> |
| 260 | + </PanelGroup> |
183 | 261 | </main> |
184 | 262 | ); |
185 | 263 | } |
0 commit comments