Skip to content

Instantly share code, notes, and snippets.

@scytacki
Last active December 7, 2025 16:28
Show Gist options
  • Select an option

  • Save scytacki/23c3b510299dfa43a3fb6fa4f5024839 to your computer and use it in GitHub Desktop.

Select an option

Save scytacki/23c3b510299dfa43a3fb6fa4f5024839 to your computer and use it in GitHub Desktop.
<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