Last active
December 7, 2025 16:28
-
-
Save scytacki/23c3b510299dfa43a3fb6fa4f5024839 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <html> | |
| <head> | |
| <script src="https://docs.getgrist.com/grist-plugin-api.js"></script> | |
| <!-- React 18 from CDN --> | |
| <script src="https://unpkg.com/react@18/umd/react.development.js"></script> | |
| <script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script> | |
| <!-- Babel Standalone for JSX at runtime --> | |
| <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script> | |
| <style> | |
| body { | |
| font-family: "sans-serif", padding: 20; | |
| font-size: 13px | |
| } | |
| .sectionTitle { | |
| font-size: 10px; | |
| margin: 16px 0px 0px 0px; | |
| .sectionLabel { | |
| text-transform: uppercase; | |
| } | |
| .sectionOptional { | |
| margin: 0px 0px 0px 3px; | |
| color: #929299 | |
| } | |
| } | |
| .sectionDescription { | |
| font-style: italic; | |
| font-size: 10px; | |
| color: #929299 | |
| } | |
| .sectionFields { | |
| padding: 8px 0px 0px 0px; | |
| } | |
| .buttonAsLink { | |
| color: #16b378; | |
| border: none; | |
| padding: 0px; | |
| text-align: left; | |
| background-color: inherit; | |
| cursor: pointer; | |
| } | |
| .helpButton { | |
| margin-left: 4px; | |
| border-radius: 50%; | |
| border-style: solid; | |
| border-color: #929299; | |
| color: #929299; | |
| width: 1.2em; | |
| height: 1.2em; | |
| padding: 0; | |
| border-width: 1; | |
| background-color: inherit; | |
| cursor: pointer; | |
| } | |
| .revealLink { | |
| margin: 16px 0px 8px 0px; | |
| } | |
| .fieldButton button { | |
| background-color: #16b378; | |
| color: white; | |
| border-color: #16b378; | |
| outline: none; | |
| border-style: none; | |
| border-radius: 4px; | |
| padding: 4px 8px; | |
| } | |
| .fieldCheckbox { | |
| display: flex; | |
| align-items: center; | |
| label { | |
| flex: 1 | |
| } | |
| } | |
| .LeftLabel .fieldCheckbox { | |
| flex-direction: row-reverse; | |
| } | |
| .fieldNumberInput { | |
| display: flex; | |
| label { | |
| flex: 6; | |
| } | |
| input { | |
| flex: 4; | |
| min-width: 0; | |
| text-align: right; | |
| } | |
| } | |
| .fieldMenu { | |
| select { | |
| display: block; | |
| width: 100%; | |
| } | |
| // Providing a label in the default form layout is not used anywhere in Grist | |
| // This styling is invented just to show the label somewhere | |
| label { | |
| display: block; | |
| font-size: 10px; | |
| font-style: italic; | |
| padding-left: 5px; | |
| } | |
| } | |
| .LeftLabel .fieldMenu { | |
| display: flex; | |
| flex-direction: row-reverse; | |
| select { | |
| width: auto; | |
| border-style: none; | |
| text-align: right; | |
| color: #14b478; | |
| } | |
| label { | |
| flex: 1; | |
| display: unset; | |
| font-size: unset; | |
| font-style: unset; | |
| padding-left: unset; | |
| } | |
| } | |
| .fieldFormula { | |
| position: relative; | |
| .formulaBadge { | |
| position: absolute; | |
| left: 3px; | |
| top: 50%; | |
| transform: translateY(-50%); | |
| background: #18b378; | |
| color: white; | |
| padding: 0px 2px 2px 2px; | |
| border-radius: 2px; | |
| border-style: solid; | |
| font-size: 14px; | |
| line-height: 0.9; | |
| } | |
| input { | |
| width: 100%; | |
| padding-left: 16px; | |
| } | |
| } | |
| .fieldEditableList { | |
| display: grid; | |
| row-gap: 3px; | |
| .editableListItem { | |
| background-color: #e5e5e5; | |
| border-radius: 2px; | |
| height: 24px; | |
| padding: 0px 4px; | |
| display: flex; | |
| align-items: center; | |
| .itemLabel { | |
| flex: 1; | |
| } | |
| } | |
| } | |
| .fieldTextStyle { | |
| position: relative; | |
| .textStyleBadge { | |
| position: absolute; | |
| left: 6px; | |
| top: 50%; | |
| transform: translateY(-50%); | |
| border-radius: 2px; | |
| border-style: solid; | |
| border-width: 1px; | |
| font-size: 12px; | |
| padding: 0px 2px; | |
| } | |
| select { | |
| width: 100%; | |
| height: 24px; | |
| padding-left: 16px; | |
| } | |
| } | |
| .fieldColumnMenu select { | |
| width: 100%; | |
| height: 25px; | |
| } | |
| .fieldSlider { | |
| display: flex; | |
| label { | |
| flex: 1; | |
| } | |
| input[type=range] { | |
| width: 82px; | |
| } | |
| input[type=number] { | |
| width: 55px; | |
| } | |
| } | |
| .fieldSubFormMenu select { | |
| width: 100%; | |
| height: 25px; | |
| } | |
| .field { | |
| padding: 2px; | |
| width: 250px; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="root" style="font-family: sans-serif; padding: 1em;"></div> | |
| <!-- JSX code compiled on the fly --> | |
| <script type="text/babel"> | |
| function HelpButton({text}) { | |
| const id = React.useId(); | |
| return ( | |
| <> | |
| <button className="helpButton" | |
| popOverTarget={id} | |
| anchor={id} | |
| >?</button> | |
| <div id={id} | |
| popOver="auto" | |
| style={{ | |
| positionArea: "block-end center", | |
| marginBlockStart: "6px", | |
| width: "240px" | |
| }} | |
| >{text}</div> | |
| </> | |
| ) | |
| } | |
| function Checkbox({field}) { | |
| const id = React.useId(); | |
| return ( | |
| <> | |
| <input id={id} type="checkbox"/> | |
| <label htmlFor={id}>{field.label || "(no label)"}</label> | |
| </> | |
| ) | |
| } | |
| function NumberInput({field}) { | |
| const id = React.useId() | |
| return ( | |
| <> | |
| <label> | |
| {field.label || "(no label)"} | |
| {field.description && <HelpButton text={field.description}/>} | |
| </label> | |
| <input type="number" placeholder={field.placeholder}/> | |
| </> | |
| ) | |
| } | |
| function ColumnMenu({field}) { | |
| return ( | |
| <select> | |
| <option>Column 1</option> | |
| <option>Column 2</option> | |
| </select> | |
| ) | |
| } | |
| function Formula({field}) { | |
| return ( | |
| <> | |
| <span className="formulaBadge">=</span> | |
| <input type="text"></input> | |
| </> | |
| ) | |
| } | |
| const mockItemsMap = { | |
| "visibleColumns": [ | |
| "Start", "End", "Label", "All day" | |
| ], | |
| "hiddenColumns": [ | |
| "Price", "Size" | |
| ] | |
| } | |
| // This list is specific to columns, the "add link" | |
| // lets the user choose a new column | |
| function EditableList({field}) { | |
| const columns = mockItemsMap[field.mockItemsId] || ["Column 1", "Column 2"] | |
| const hasCheckBoxes = !field.addItemLink; | |
| return ( | |
| <> | |
| { columns.map(item => ( | |
| <div key={item} className="editableListItem"> | |
| <span className="itemLabel">{item}</span> | |
| {hasCheckBoxes && <input type="checkbox"/> } | |
| </div> | |
| ))} | |
| {field.addItemLink && <button className="buttonAsLink">+ {field.addItemLink}</button>} | |
| </> | |
| ) | |
| } | |
| function Button({field}) { | |
| return ( | |
| <button>{field.label}</button> | |
| ) | |
| } | |
| function Menu({field}) { | |
| const id = React.useId(); | |
| return ( | |
| <> | |
| <select id={id}> | |
| {field.menuItems.map(item => ( | |
| <option key={item}>{item}</option> | |
| ))} | |
| </select> | |
| { field.label && <label htmlFor={id}>{field.label}</label>} | |
| </> | |
| ) | |
| } | |
| function TextStyle({field}) { | |
| return ( | |
| <> | |
| <span className="textStyleBadge">T</span> | |
| <select> | |
| <option>{field.label}</option> | |
| </select> | |
| </> | |
| ) | |
| } | |
| function Slider({field}) { | |
| const id = React.useId(); | |
| const [value, setValue] = React.useState(75); | |
| return ( | |
| <> | |
| { // How do I label a two inputs? | |
| } | |
| <label htmlFor={id}>{field.label}</label> | |
| <input id={id} type="range" | |
| value={value} onChange={e => setValue(e.target.value)} | |
| /> | |
| { // How do I add the percent "unit" to the slider? | |
| } | |
| <input type="number" | |
| value={value} onChange={e => setValue(e.target.value)} | |
| /> | |
| </> | |
| ) | |
| } | |
| function SubFormMenu({field, formRef}) { | |
| const [selectedSubForm, setSelectedSubForm] = React.useState(field.menuItems[0]) | |
| const [formNode, setFormNode] = React.useState(null); | |
| React.useLayoutEffect(() => { | |
| if (formRef.current) { | |
| setFormNode(formRef.current) | |
| } | |
| }, [formRef]) | |
| const formDesc = field.subForms[selectedSubForm] | |
| return ( | |
| <> | |
| <select | |
| value={selectedSubForm} | |
| onChange={(e) => setSelectedSubForm(e.target.value)} | |
| > | |
| {field.menuItems.map(item => ( | |
| <option key={item}>{item}</option> | |
| ))} | |
| </select> | |
| { formDesc && formNode && ReactDOM.createPortal(<> | |
| { formDesc.sectionGroups.map(group => | |
| <SectionGroup key={group.id} group={group} formRef={formRef}/> | |
| )} | |
| </>, formNode)} | |
| </> | |
| ) | |
| } | |
| const controls = { | |
| Checkbox, | |
| NumberInput, | |
| ColumnMenu, | |
| Formula, | |
| EditableList, | |
| Button, | |
| Menu, | |
| TextStyle, | |
| Slider, | |
| SubFormMenu, | |
| } | |
| function DefaultControl({field}) { | |
| return ( | |
| <span>Unhandled {field.control}: {field.label}</span> | |
| ) | |
| } | |
| function Field({field, formRef}) { | |
| const Control = controls[field.control] || DefaultControl | |
| return ( | |
| <div className={`field field${field.control}`}> | |
| <Control field={field} formRef={formRef}/> | |
| </div> | |
| ) | |
| } | |
| function Section({section, formRef}) { | |
| const [revealed, setRevealed] = React.useState(!section.revealLink) | |
| const handleReveal = () => { | |
| setRevealed(true) | |
| } | |
| console.log("render Section", section, {revealed} ) | |
| return ( | |
| <> | |
| { revealed | |
| ? <div className="section"> | |
| {section.label && <div className="sectionTitle"> | |
| <span className="sectionLabel">{section.label}</span> | |
| {section.optional && <span className="sectionOptional">(optional)</span>} | |
| </div> } | |
| {section.description && <div className="sectionDescription">{section.description}</div> } | |
| <div className="sectionFields"> | |
| { section.fields.map(field => <Field field={field} formRef={formRef}/>) } | |
| </div> | |
| </div> | |
| : <div className="revealLink"> | |
| <button className="buttonAsLink" onClick={handleReveal}>{section.revealLink || "Unknown Reveal Link"}</button> | |
| {section.revealHelp && <HelpButton text={section.revealHelp}/> } | |
| </div> | |
| } | |
| </> | |
| ) | |
| } | |
| function SectionGroup({group, formRef}) { | |
| return ( | |
| <div className="sectionGroup"> | |
| { group.sections.map(section => <Section section={section} formRef={formRef}/>) } | |
| <hr/> | |
| </div> | |
| ); | |
| } | |
| const recordListeners = [] | |
| let currentWidget = undefined; | |
| grist.onRecord(widget => { | |
| console.log("widget", widget) | |
| currentWidget = widget; | |
| for (const listener of recordListeners) { | |
| listener(widget) | |
| } | |
| }) | |
| function addRecordListener(listener) { | |
| recordListeners.push(listener); | |
| if (currentWidget) listener(currentWidget) | |
| return () => { | |
| const index = recordListeners.indexOf(listener); | |
| if (index !== -1) { | |
| recordListeners.splice(index, 1); // removes the element at index | |
| } | |
| } | |
| } | |
| function App() { | |
| const [formDesc, setFormDesc] = React.useState() | |
| const formRef = React.useRef() | |
| React.useEffect(() => { | |
| return addRecordListener(widget => { | |
| setFormDesc(JSON.parse(widget.JSON)) | |
| }) | |
| }, []) | |
| return ( | |
| <div ref={formRef} className={formDesc && formDesc.style}> | |
| { formDesc && formDesc.sectionGroups.map(group => | |
| <SectionGroup key={group.id} group={group} formRef={formRef}/> | |
| )} | |
| </div> | |
| ); | |
| } | |
| const root = ReactDOM.createRoot(document.getElementById("root")); | |
| root.render(<App />); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment