Survey Slate | Survey Components

Reusable code components for survey development.

Config

const config = ({
  // All artifacts in the PUBLIC bucket are on the internet
  PUBLIC_BUCKET: "public-publicwrfxcsvqwpcgcrpx",
  // The private bucket requires credentials to access, should be for system wide things like user lists
  PRIVATE_BUCKET: "private-mjgvubdpwmdipjsn",
  // The confidential bucket is for customer data, it is encypted and access shoudl be minimized.
  CONFIDENTIAL_BUCKET: "confidential-bspqugxjstgxwjnt",
  // The URL to redirect users to
  FILLER_APP_URL: "https://www.surveyslate.org/survey/index.html",
  // Cloud Front distribution ID
  CLOUD_FRONT_DISTRIBUTION_ID: 'EG13QGKCG6LI9',
  // URL that hosts appplications
  CLOUD_FRONT_URL: 'https://www.surveyslate.org',
})

Questions

Every cell is wrapped so we can apply cross-cutting features like visibility and numbering to all controls

// Special arg suffixes like _json, _md, _html are converted to real objects
function reifyAttributes(args) {
  const reifyAttribute = ([k, v]) => {
    k = k.trim()
    if (k.endsWith("_json")) {
      return [k.replace(/_json$/, ''),
              Array.isArray(v) ? v.map(v => reifyAttributes(JSON.parse("(" + v + ")")))
                                       : reifyAttributes(JSON.parse("(" + v + ")"))] 
    }
    if (k.endsWith("_eval")) {
      return [k.replace(/_eval$/, ''),
              Array.isArray(v) ? v.map(v => reifyAttributes(eval("(" + v + ")")))
                                       : reifyAttributes(eval("(" + v + ")"))]    
    }
    if (k.endsWith("_js")) {
      return [k.replace(/_js$/, ''),
              Array.isArray(v) ? v.map(v => reifyAttributes(eval("(" + v + ")")))
                                       : reifyAttributes(eval("(" + v + ")"))]    
    }
    if (k.endsWith("_md")) {
      return [k.replace(/_md$/, ''), md([v])]    
    }
    
    return [k, v]
  }
  if (Array.isArray(args))
    return args.map(arg => reifyAttributes(arg));
  else if (typeof args === 'object')
    return Object.fromEntries(Object.entries(args).map(reifyAttribute))
  else 
    return args
} 
const createQuestion = (q, index, options) => {
  const args = reifyAttributes(q);
  
  function createControl() {
    try {
      if (Object.keys(args).length == 0
         || (Object.keys(args).length == 1 && args[""] == "")) {
        return htl.html` `
      }


      if (args.content || args.md) {
        return md`${args.content || args.md}`
      }

      if (args.type === 'checkbox') {
        return checkbox({
          options: [args.title],
          ...args
        })
      }

      if (args.type === 'textarea') {
        return textarea({
          ...args
        })
      }

      if (args.type === 'radio') {
        return radio({
          ...args
        })
      }

      if (args.type === 'number') {
        return number({
          ...args
        })
      }

      if (args.type === 'table') {
        return table({
          ...args
        })
      }

      if (args.type === 'file_attachment') {
        return file_attachment({
          ...args,
          putFile: options.putFile,
          getFile: options.getFile,
        })
      }
      
      if (args.type === 'summary') {
        return summary({
          ...args
        })
      }
      
      if (args.type === 'aggregate') {
        return aggregateSummary({
          ...args
        })
      }


    } catch (err) {
      console.log("Error creating question for ", q, index, options)
      console.log(err)
    }


    return htl.html`<div><mark>${index}. ${JSON.stringify(q)}</mark></div>`
  }
  
  const control = createControl();
  const logic = questionWrapper({
    control
  });
  logic.args = args;
  return logic
}
//!!!!!!!!!!!!!!!!!
//!!!!! NOTE: We have to adjust the use of viewof here.
//!!!!!!!!!!!!!!!!
const example_q = view(() => {
  return createQuestion(JSON.parse(`{"id":"viewof borrower_GESI_support_equal_pay","type":"radio","title":"Do you provide equal pay for work of equal value of women and men?","options":[{"value":"No","label":"No"},{"value":"Yes","label":"Yes"}],"value":"savedFormData.borrower_GESI_support_equal_pay"}`))
})
example_q.numbering = "34."
example_q
const scoreQuestion = (q) => {
  if (!q) return 0;
  if (q.hidden.value === true) return 0;
  if (q.args.type === 'checkbox') {
    // Max score function
    return q.control.value.reduce((max, value) => {
      // find option with higest score
      const option = q.args.options.find(option => option.value === value)
      return Math.max(option.score, max);
    }, 0)
  }
  if (q.args.type === 'radio') {
    const option = q.args.options.find(option => option.value === q.control.value)
    if (option) return option.score;
  };
  
  if (q.args.type === 'summary') {
    return q.control.score.value;
  };
}

TextNodeView

Moved to Common Components

Table

const id = () => Math.random().toString(36).substr(2, 9); // https://gist.github.com/gordonbrander/2230317
function table({
  value = {},
  title = undefined,
  user_rows = false,
  columns = [],
  rows = [],
  grandTotalLabel = "units",
  grandTotal,
  caption = undefined
} = {}) {
  const total = textNodeView()
  const subtotals = columns.reduce((acc, c) => {
    acc[c.key] = textNodeView()
    return acc;
  }, {});
  
  const rowBuilder = (row) => {
    const labelInput = Inputs.text({value: row.label, placeholder: row.placeholder});
    
    const removeRow = (key) => {
      console.log("DELETE ROW", table.value)
      table.value = Object.fromEntries(Object.entries(table.value)
                                       .filter(([id, row]) => row.label !== key));
      table.dispatchEvent(new Event('input', {bubbles: true}));
    }

    const deleteBtn = Inputs.button('Delete', {
      reduce: () => removeRow(labelInput.value),
      disabled: !labelInput.value
    });
    const deleteBtnEl = deleteBtn.querySelector('button');
    deleteBtnEl.classList.add('secondary-button');
    
    if (row.onEnterPressed) {
      labelInput.addEventListener('keyup', (evt) => {
        if (evt.keyCode === 13) {
          row.onEnterPressed(evt);
        }
      });
    }
    // if (user_rows) { // If user editable rows
    //   labelInput.addEventListener('keyup', (evt) => {
    //     // BACKSPACE
    //     if (evt.keyCode === 8 && labelInput.value.length == 0) {
    //       removeRow(labelInput.value)
    //     }
    //   });
    // }
    return viewUI`<tr>
          <th>${user_rows ? ['label', labelInput] : md`${row.label}`}</th>
          ${['...', Object.fromEntries(columns.map(
            column => [column.key, viewUI`<td>${['...', htl.html`<input type="number" value="${value?.[row.key]?.[column.key] || 0}" min="0">`]}</td>`]))]}
          ${user_rows ? viewUI`<td>${deleteBtn}</td>` : ''}
        </tr>`
  }
  const newRow = cautious((done) => {
    return rowBuilder({
      placeholder: "add a new row",
      onEnterPressed: (evt) => {
        table.value = {
          ...table.value,
          [id()]: newRow.value
        }
        newRow.value.label = ""
        table.dispatchEvent(new Event('input', {bubbles: true}))
        done(evt);
      }
    });
  }, {nospan: true});
  
  
  let table = viewUI`<div>
    <h2>${title}</h2>
    <div class="table-ui-wrapper">
      <table class="table-ui">
        <thead class="bb bw1 b--light-gray">
          <th></th>
          ${columns.map(column => viewUI`<th>${column.label}</th>`)}
        </thead>
        <tbody>
          ${['...', {}, rowBuilder]}
        </tbody>
        <tfoot class="bt bw1 b--light-gray">
          ${user_rows ? newRow : ''}
          ${columns[0].total ?
            viewUI`<tr>
              <th>Sub-Totals:</th>
              ${columns.map(c => viewUI`<td><div class="subtotal"><strong>${subtotals[c.key]} ${c.total}</strong></div></td>`)}
            </tr>`
          : null}
          ${grandTotal ?
            viewUI`<tr>
              <th><h3>${grandTotal}</h3></th>
              <td colspan="${columns.length}"><h3>${total} ${grandTotalLabel}</h3></td>
            </tr>`
          : null}
        </tfoot>
      </table>
    </div>
    <div class="table-ui-caption">${caption}</div>
  </div>`
  
  // Set rows from static settings
  table.value = Object.fromEntries(rows.map(row => [row.key, row]))
  // Also set rows from dynamic value
  if (Object.keys(value).length > 0 && user_rows) {
    table.value = value
  }
  
  
  function recomputeTotals() {
    let totalSum = 0;
    Object.keys(subtotals).forEach(k => {
      let sum = 0;
      Object.keys(table.value).forEach(key => {
        sum += Number.parseInt(table.value[key][k])
      })
      subtotals[k].value = sum
      totalSum += sum
    })
    total.value = totalSum;
  }
  
  table.addEventListener('input', () => {
    recomputeTotals()
  })
  recomputeTotals()
  return table
}
exampleTable
const exampleTable = view(table({
  value: {
   board: {
    w: 10,
    m:1, 
    unknown: 3, 
   }
  },
  title: "Workforce Gender Data",
  columns: [
    {key: "w", label:"Women", total: "women"},
    {key: "m", label:"Men", total: "male"},
    {key: "unknown", label: "No Data", total: "no data"}
  ],
  rows: [
    {key: "board", label: "Board"},
    {key: "management", label: "Management"},
    {key: "tech", label: "Technical / Engineering Staff"},
    {key: "staff", label: "Non-Technical Staff"},
    {key: "admin", label: "Administrative / Support Staff"},
    {key: "customerservice", label: "Customer Service Staff"},
    {key: "other", label: "Other Staff"},
    {key: "day", label: "Non-Contractual/Informal Day Workers <br><small>(e.g., for food preparation, laundry, cleaners)</small>"}
  ],
  grandTotal: "Total Workforce:",
  grandTotalLabel: htl.html`<br>people`,
  caption: md`<small>_Please enumerate the sex distribution and number of persons with disabilities in your workforce—ensuring to avoid double counting. If you find that this format does not adequately reflect your organization, please submit your data separately to ADB. If you have information for multiple years, please also submit separately.
._</small>`
}))
exampleTable
userEditableTableExample
basicTable
Inputs.button("backwrite", {
  reduce: () => {
    basicTable.value = {"r1": {label: "r1", "c1": "3"}, "r2": {label: "r1", "c1": "2"}};
    basicTable.dispatchEvent(new Event('input', {bubbles: true}))
  }
})

File attachment

const file_attachment = (options) => {
  const row = file => viewUI`<li>${['name', textNodeView(file.name)]}
      ${download(async () => {
        return new Blob(
          [await options.getFile(file.name)], 
          {type: "*/*"}
        )
      }, file.name, "Retrieve")}`
  
  let ui = viewUI`<div class="sans-serif">
    <p class="b">Existing files</p>
    <ul class="uploads">
      ${['uploads', [], row]}
    </ul>
    ${viewroutine(async function*() {
      while (true) {
        const filesView = file({
          ...options,
          multiple: true
        });
        yield* ask(filesView);
        // A bug with file means files have to be collected from a nested value, rather than the result of ask.
        const toUpload = filesView.input.files; 
        let uploaded = false;
        const uploads = [...toUpload].map(async file => {
          await options.putFile(file.name, await file.arrayBuffer())
          console.log("push", file.name)
          ui.value.uploads = _.uniqBy(ui.value.uploads.concat({name: file.name}), 'name');
          ui.dispatchEvent(new Event('input', {bubbles: true}))
          return;
        })

        Promise.all(uploads).then(() => uploaded = true);

        while (!uploaded) {
          yield md`uploading .`
          await new Promise(resolve => setTimeout(resolve, 100));
          yield md`uploading ..`
          await new Promise(resolve => setTimeout(resolve, 100));
          yield md`uploading ...`
          await new Promise(resolve => setTimeout(resolve, 100));
        }
      }
    })
  }</div>`;
  
  // Initial list contents
  ui.value.uploads = options?.value?.uploads || [];
  return ui;
}
//viewof exampleFileAttachment = {
const exampleFileAttachmentElement = () => {
  return createQuestion({
    "id":"GESI_national_law_files",
    "type":"file_attachment",
    "title":"File Attachments",
    "placeholder":"No file chosen.",
    "description":"If you would like to share your policy, please upload it here.",
    "value":{"uploads":[{"name":"IMG_20210801_095401.jpg"}]}
  }, 
  0, {
    putFile: () => {},
    getFile: () => {},
  })
}
const exampleFileAttachment = Generators.input(exampleFileAttachmentElement)
exampleFileAttachmentElement()
exampleFileAttachment

Clearable Radio

//viewof radioExamples2 = radio({
const radioExamples2 = view(radio({
  title: "A very long non sensical question to check the wrapping and layout of this component? A very long non sensical question.",
  options: [ 
    "cool",
    "Bring to the table win-win survival strategies to ensure proactive domination. At the end of the day, going forward, a new normal that has evolved from generation X is on the runway heading towards a streamlined cloud solution. User generated content in real-time will have multiple touchpoints for offshoring"
  ],
  value: "cool",
  description: "A slightly long and meaningless description to go with the options"
}))
radioExample
//viewof radioExample
radioExample
const radio = (args) => {
  const base = radioBase(args || {});
  const radios = base.querySelectorAll('input[type=radio]');

  const buttonClass = "[ secondary-button ][ m2 ]";
  const buttonInactiveClass = "dn";
  const button = html`<button class="${args.value ? "" : buttonInactiveClass} ${buttonClass}">Clear selection</button>`;

  base.dataset.formType = "clearable-radio";
  
  const clearHandler = (e) => {
    // Hide clear button
    button.classList.add(buttonInactiveClass);
    
    Array.from(radios).forEach(r => { r.checked = false })
    base.value = undefined;
    base.dispatchEvent(new Event('input', {bubbles: true}))
  }

  const inputChangeHandler = (e) => {
    if (base.value !== undefined) {
      // Show clear button
      button.classList.remove(buttonInactiveClass);
    }
  }
  
  button.addEventListener('click', clearHandler);
  base.addEventListener('input', inputChangeHandler);
  invalidation.then(() => {
    base.removeEventListener('input', inputChangeHandler);
    button.removeEventListener('click',clearHandler)
  });

  const labels = base.querySelectorAll('label');
  const lastLabel = Array.from(labels)[labels.length  - 1];
  if (lastLabel && lastLabel.parentNode) {
    lastLabel.parentNode.insertBefore(button, lastLabel.nextSibling);
  }
  
  return base;
}

textarea

//viewof exampleTextarea = textarea({
const exampleTextarea = view(textarea({
  title: "title",
  description: "description",
  placeholder: "placeholder"
}))

Checkbox++

Includes a scoring function that has to be accessed through the view

const checkbox = (options) => {
  const allValues = options.options.map(o => o.value)
  if (options.includeNoneOption) {
    options.options.unshift({
      value: "NONE",
      label: options.includeNoneOption.label || (typeof options.includeNoneOption === 'string' ? options.includeNoneOption : "None of the below."),
      score: options.includeNoneOption.score
    })
  }
  if (options.includeAllOption) {
    options.options.push({
      value: "ALL",
      label: options.includeAllOption.label || (typeof options.includeAllOption === 'string' ? options.includeAllOption : "All of the above."),
      score: options.includeAllOption.score})
  }
  const base = checkboxBase(options);
  
  const ui = viewUI`<div>
    ${['...', base]}
  </div>`
  
  const form = ui.querySelector("form")
  base.dataset.formType = "checkbox-plus-plus";
  let allSelected = (options.value || []).includes("ALL");
  let noneSelected = (options.value || []).includes("NONE");
  
  base.addEventListener('input', evt => {
    console.log(evt, evt.target.elements, allValues)
    // If all the values are selected ALL should be too
    if (evt.target.value.includes('ALL') && !allSelected) {
      console.log("A")
      // User ticks ALL
      allSelected = true
      allValues.forEach(v => {
        const input = form.querySelector(`input[value='${v}']`)
        input.checked = true
      })
      form.querySelector(`input[value=NONE]`).checked = false;
      form.dispatchEvent(new Event('change', {bubbles:true}))
      evt.stopPropagation()
    } else if (!evt.target.value.includes('ALL') && allSelected) {
      console.log("B")
      // User unticks ALL
      allSelected = false
      allValues.forEach(v => {
        const input = form.querySelector(`input[value='${v}']`)
        input.checked = false
      })
      form.dispatchEvent(new Event('change', {bubbles:true}))
      evt.stopPropagation()
    } else if (evt.target.value.includes('ALL') && allSelected && !allValues.every(v => evt.target.value.includes(v))) {
      console.log("C")
      // An unticked option exists while ALL was ticked (so ALL should untick)
      allSelected = false
      const input = form.querySelector(`input[value=ALL]`)
      input.checked = false
      input.dispatchEvent(new Event('change', {bubbles:true}))
      evt.stopPropagation()
    } else if (!evt.target.value.includes('ALL') && !allSelected && allValues.every(v => evt.target.value.includes(v))) {
      console.log("D")
      // All options are ticked while ALL was unticked (so ALL should tick)
      allSelected = true
      const input = form.querySelector(`input[value=ALL]`)
      input.checked = true
      input.dispatchEvent(new Event('change', {bubbles:true}))
      evt.stopPropagation()
    } else if (evt.target.value.includes('NONE') && !noneSelected) {
      console.log("E")
      // User ticks NONE (=> all options should untick)
      noneSelected = true
      allValues.forEach(v => {
        const input = form.querySelector(`input[value='${v}']`)
        input.checked = false
      })
      form.querySelector(`input[value=ALL]`).checked = false
      form.dispatchEvent(new Event('change', {bubbles:true}))
      evt.stopPropagation()
    } else if (!evt.target.value.includes('NONE') && noneSelected) {
      console.log("F")
      // User unticks NONE
      noneSelected = false
    } else if (evt.target.value.includes('NONE') && noneSelected && allValues.some(v => evt.target.value.includes(v))) {
      console.log("G")
      // An option exists while NONE was ticked (so NONE should untick)
      noneSelected = false
      const input = form.querySelector(`input[value=NONE]`)
      input.checked = false
      input.dispatchEvent(new Event('change', {bubbles:true}))
      evt.stopPropagation()
    } else if (!evt.target.value.includes('NONE') && !noneSelected && allValues.every(v => !evt.target.value.includes(v))) {
      console.log("H")
      // Nothing is ticked and NONE is unticked (so NONE should tick)
      noneSelected = true
      const input = form.querySelector(`input[value=NONE]`)
      input.checked = true
      input.dispatchEvent(new Event('change', {bubbles:true}))
      evt.stopPropagation()
    }
  })
  
  return ui
}
//viewof exampleCheckboxPlus = checkbox({
const exampleCheckboxPlus = view(checkbox({
  includeNoneOption: {label: "none of the below", score: 0},
  includeAllOption: {label: "all of the above", score: 4},
  options: [
    { 
      value: 'a',
      label: "option A", 
      score: 1},
    {value: 'b', label: "option B", score: 2}
  ], 
  value: ['a', 'ALL', 'b']
}))
exampleCheckboxPlus
//viewof exampleCheckboxWithSpaces = checkbox({
const exampleCheckboxWithSpaces = view(checkbox({
  includeNoneOption: {label: "", score: 0},
  includeAllOption: {label: "YES ALL", score: 4},
  options: [
    {value: 'a b', label: "A", score: 1},
    {value: 'b', label: "B", score: 2}
  ], 
  value: ['a b'],
  description: "A slightly long and meaningless description to go with the options"
}))
exampleCheckboxWithSpaces
//viewof checkboxTests = createSuite({
const checkboxTests = view(createSuite({
  name: "checkbox tests",
  timeout_ms: 1000
}))
//viewof testCheckboxAll = checkbox({
const testCheckboxAll = view(checkbox({
    includeAllOption: {label: "ALL"},
    options: [
      {value: 'a', label: "A"},
      {value: 'b', label: "B"}
    ],
  }))
function check(checkbox, value, checked = true) {
  const option = checkbox.querySelector(`input[value=${value}]`);
  if (!option) throw new Error("Could not find " + value + " in options");
  option.checked = checked
  option.dispatchEvent(new Event('change', {bubbles: true}))
}
//viewof 
checkboxTests.test("Tick ALL cascades", () => {
  //check(viewof testCheckboxAll, "a", false)
  check(testCheckboxAll, "a", false)
  //check(viewof testCheckboxAll, "b", false)
  check(testCheckboxAll, "b", false)
  //check(viewof testCheckboxAll, "ALL", true)
  check(testCheckboxAll, "ALL", true)
  //expect(viewof testCheckboxAll.value).toEqual(["a", "b", "ALL"])
  expect(testCheckboxAll.value).toEqual(["a", "b", "ALL"])
})
//viewof 
checkboxTests.test("Tick a and b cascades to ALL", () => {
  //check(viewof testCheckboxAll, "ALL", false)
  check(testCheckboxAll, "ALL", false)
  //check(viewof testCheckboxAll, "a", true)
  check(testCheckboxAll, "a", true)
  //check(viewof testCheckboxAll, "b", true)
  check(testCheckboxAll, "b", true)
  //expect(viewof testCheckboxAll.value).toEqual(["a", "b", "ALL"])
  expect(testCheckboxAll.value).toEqual(["a", "b", "ALL"])
})
//viewof 
checkboxTests.test("Untick ALL cascades", () => {
  //check(viewof testCheckboxAll, "a", true)
  check(testCheckboxAll, "a", true)
  //check(viewof testCheckboxAll, "b", true)
  check(testCheckboxAll, "b", true)
  //check(viewof testCheckboxAll, "ALL", true)
  check(testCheckboxAll, "ALL", true)
  //expect(viewof testCheckboxAll.value).toEqual(["a", "b", "ALL"])
  expect(testCheckboxAll.value).toEqual(["a", "b", "ALL"])
  //check(viewof testCheckboxAll, "ALL", false)
  check(testCheckboxAll, "ALL", false)
  //expect(viewof testCheckboxAll.value).toEqual([])
  expect(testCheckboxAll.value).toEqual([])
})
SyntaxError: Unexpected token (3:15)
checkboxTests.test("Untick a cascades to ALL", () => {
  //check(testCheckboxAll, "a", true)
  check(viewof testCheckboxAll, "a", true)
  //check(viewof testCheckboxAll, "b", true)
  check(testCheckboxAll, "b", true)
  //check(viewof testCheckboxAll, "ALL", true)
  check(testCheckboxAll, "ALL", true)
  //expect(viewof testCheckboxAll.value).toEqual(["a", "b", "ALL"])
  expect(testCheckboxAll.value).toEqual(["a", "b", "ALL"])
  //check(viewof testCheckboxAll, "a", false)
  check(testCheckboxAll, "a", false)
  //expect(viewof testCheckboxAll.value).toEqual(["b"])
  expect(testCheckboxAll.value).toEqual(["b"])
})
//viewof testCheckboxAll.querySelector("form").dispatchEvent(new Event('input', {bubbles: true}))
testCheckboxAll.querySelector("form").dispatchEvent(new Event('input', {bubbles: true}))
//viewof testCheckboxAll.querySelector("input[value=ALL]")
testCheckboxAll.querySelector("input[value=ALL]")

Summary

const colorBoxStyle = html`<style>
  .color-box {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    flex-shrink: 0;
    background-color: #ccc;
    height: 2rem;
    width: 2rem;
    border-radius: .25rem;
    font-weight: bold;
    color: white;
    border: 1px solid rgba(0,0,0,0.05);
  }

  .color-box--lg {
    height: 2.5rem;
    width: 2.5rem;
  }
</style>`
const summary = ({
  label,
  score,
  counter_group,
  counter_value = undefined
} = {}) => {
  counter_value = counter_value && Number.parseInt(counter_value)
  const counterName = `yiwkmotksl-${counter_group}`;
  if (!window[counterName]) { // Initialize counter if its not been started
      window[counterName] = counter_value || 1;
  }
  counter_value = counter_value || window[counterName]; // Ensure we have a counter value
  window[counterName] = counter_value + 1; // Increment counter now we used it
  
  const colorVar = variable(scoreColor(score));
  const textColorVar = variable(contrastTextColor(colorVar))
  const ui = viewUI`<div class="[ flex items-center mv2 ][ sans-serif ]">
  <div class="[ flex items-center w-100 ]">
    <div class="b mr2">${['numbering', textNodeView((counter_group || '') + (counter_value + ''))]}</div> 
    <div class="[ lh-title mid-gray ][ pr2 mr-auto ]">${['label', textNodeView(label)]}</div>
  </div>
  <div class="color-box f6" style="background-color: ${['color', colorVar]}; color: ${['text-color', textColorVar]}">
    <span>${['score', textNodeView(score)]}<span>
  </div>
</div>
`

  ui.calculate = (scores) => {
    ui.value.score = d3.sum(scores);
    ui.score.dispatchEvent(new Event('input', {bubbles: true}))
  }
  
  bindOneWay(colorVar, ui.score, {
    transform: () => {
      return scoreColor(ui.value.score);
    }
  })
  
  colorVar.addEventListener('assign', () => {
    ui.querySelector(".color-box").style['background-color'] = colorVar.value;
    ui.querySelector(".color-box").style['color'] = contrastTextColor(colorVar.value);
  })
  
  return ui;
}
exampleSummary
//viewof exampleSummary = summary({
const exampleSummary = view(summary({
  color: 'red',
  label: `Accomodate needs for PWQ`,
  score: 2,
  counter_value: 2,
  counter_group: 'AE',
}))
///!!!
/// NOTE: Verify binding
///!!!
//Inputs.bind(Inputs.range([0, 5], {label: 'score', step: 0.1}), viewof exampleSummary.score)
Inputs.bind(Inputs.range([0, 5], {label: 'score', step: 0.1}), exampleSummary.score)
//viewof exampleSummaryNext = summary({
const exampleSummaryNext = view(summary({
  color: 'red',
  label: `Accomodate needs for PWQ and some more very long text to stress test the component`,
  score: 2,
  counter_group: 'AE',
}))
scoreColor(3)
const contrastTextColor = (color) => d3.lab(color).l < 70 ? "#fff" : "#000"
contrastTextColor("#eeeeee")
contrastTextColor("#006837")
const scoreColor = (score) => d3.scaleLinear()
    .domain([0, 0.1, 1, 2, 3, 4, 5])
    // Earlier 0 -> '#EEEEEE', '#0000EA','#7F17D9','#CF2B92','#E577B8','#F6BECB' <- 5
    // Color scale from https://colorbrewer2.org/#type=sequential&scheme=YlGn&n=5
    .range([
  '#0000ff', // '#EEEEEE', // 0
  '#4800ff', // '#ffffcc', // 0.1
  '#5d00ff', // '#ffffcc', // 1
  '#8900e1', // '#c2e699', // 2
  '#e00095', // '#78c679', // 3
  '#f66fbb', // '#31a354', // 4
  '#ffbccb', // '#006837'  // 5
]).clamp(true)(score);

Aggregate Summary

FileAttachment("image@1.png").image()
//viewof exampleAggregateSummary = aggregateSummary({
const exampleAggregateSummary = view(aggregateSummary({
  label: 'Organizational Policies',
  score: 3.08423423423422,
  set: 'org'
}))
SyntaxError: Unexpected token (1:70)
Inputs.bind(Inputs.range([0, 5], {label: 'score', step: 0.1}), viewof exampleAggregateSummary.score)
aggregateSummary({
  label: 'Organizational Policies and a very long label to stress test this component',
  set: 'org'
})
const aggregateSummaryStyle = html`<style>
  .aggregate-summary {}  
</style>`
const aggregateSummary = ({
  label,
  score = 0,
  prependScoreLevel = "L"
} = {}) => {
  const scoreLevel = (score) => Math.ceil(score);
  const colorVar = variable(scoreColor(score));
  const textColorVar = variable(contrastTextColor(colorVar))
  const ui = viewUI`<div class="[ aggregate-summary ][ flex items-center mv2 ][ sans-serif ]">
  <div class="flex items-center w-100">
    <div class="[ aggregate-summary__title ][ b lh-title ][ mr-auto pr3 ]">${['label', textNodeView(label)]}</div>
    <div class="[ aggregate-summary__score ][ mid-gray mr3 ]">${['score', textNodeView(+score.toFixed(2))]}</div>
  </div>
  <div class="color-box color-box--lg" style="background-color: ${['color', colorVar]}; color: ${['text-color', textColorVar]}">
    <span>${['prependScoreLevel', textNodeView(prependScoreLevel)]}${['level', textNodeView(scoreLevel(score))]}<span>
  </div>
</div>`
  
  ui.calculate = (scores) => {
    ui.value.score = +d3.mean(scores).toFixed(2);
    ui.value.level = scoreLevel(ui.value.score);
    ui.score.dispatchEvent(new Event('input', {bubbles: true}))
  }
  
  bindOneWay(colorVar, ui.score, {
    transform: () => {
      return scoreColor(ui.value.score);
    }
  });
  
  colorVar.addEventListener('assign', () => {
    ui.querySelector(".color-box--lg").style['background-color'] = colorVar.value;
    ui.querySelector(".color-box").style['color'] = contrastTextColor(colorVar.value);
  })
  
  return ui;
}

Pagination

//viewof samplePagination1 = pagination({
const samplePagination1 = view(pagination({
  previous: 'prev',
  next: 'next',
  hashPrefix: "foo"
}))
//viewof samplePagination2 = pagination({
const samplePagination2 = view(pagination({
  previous: 'prev',
  previousLabel: "Previous",
  hashPrefix: "foo"
}))
//viewof samplePagination3 = pagination({
const samplePagination3 = view(pagination({
  next: 'next',
  nextLabel: 'Next',
  hashPrefix: "foo"
}))
//viewof samplePagination4 = {
const samplePagination4 = view(() => {
  const p = pagination({
  previous: 'prev',
  next: 'next',
  hashPrefix: "foo"
});

  return html`<div style="overflow-y: auto; height: 300px;">
  <div class="[ bg-light-gray ]">
    <div style="height: 400px"></div>
    <div class="[ bg-white pa2 ][ sticky bottom-0 ]">
      ${p}
    </div>
  </div>
</div>`;
})
const pagination = ({previous, next, hashPrefix = "", previousLabel = "← Go back", nextLabel ="Proceed →"} = {}) => {
  const prevLink = previous ? html`<a class="[ pagination_previous ][ brand no-underline underline-hover ]" href="#${hashPrefix}${previous}">${previousLabel}</a>` : "";
  const nextLink = next ? html`<a class="[ pagination_next ][ ml-auto pv2 ph3 br1 ][ bg-brand text-on-brand hover-bg-accent no-underline ]" href="#${hashPrefix}${next}">${nextLabel}</a>` : "";

  return html`<nav class="[ pagination ][ f5 ][ flex items-center ]">
  ${prevLink} ${nextLink}
</nav>`
}

Component Styles

const ns = Inputs.text().classList[0]
const styles = html`<style>
  ${colorBoxStyle.innerHTML}
  ${aggregateSummaryStyle.innerHTML}
  ${tableStyles.innerHTML}
  ${formInputStyles.innerHTML}
</style>`

Inputs

const formInputStyles = html`<style>
/* For @jashkenas/inputs */
/* Important seems to be the only way to override inline styles */
form label[style] {
  font-size: 1rem !important;
  display: block !important;
}

form div div,
form label[style] {
  line-height: var(--lh-copy, 1.3) !important; /* .lh-copy */
  margin: 0 !important;
}

form div + label[style], 
form label[style] + label[style], 
form label[style] + button + div,
form label[style] + div { 
  margin-top: var(--spacing-small, .5rem) !important;
} 

form textarea[style] {
  width: 100% !important;
}

form[data-form-type="checkbox-plus-plus"] label[style] ,
form[data-form-type="clearable-radio"] label[style] {
  display: grid !important;
  grid-template-columns: 1em auto;
  gap: var(--spacing-extra-small, .25rem);
  align-items: start;
}

form[data-form-type="clearable-radio"] input[type="radio"],
form[data-form-type="checkbox-plus-plus"] input[type="checkbox"] {
  margin-left: 0 !important;
  margin-top: 0.25rem !important;
}

form[data-form-type="clearable-radio"] div {
  grid-template-columns: 1fr minmax(120px, max-content);
  grid-gap: 0 1rem;
  grid-auto-flow: column;
}

form[data-form-type="clearable-radio"] div > *  {
  grid-column: 1;
}

form[data-form-type="clearable-radio"] div > div:first-child,
form[data-form-type="clearable-radio"] div > div:last-child {
  grid-column: 1/-1;
}

form[data-form-type="clearable-radio"] div > button  {
  grid-column: 2;
  align-self: start;
  justify-self: end;
}

form[data-form-type="clearable-radio"] div > button {
  margin-top: .5rem;
}

@media screen and (min-width: 30em) {
  form[data-form-type="clearable-radio"] > div {
    display: grid;
  }
}

.secondary-button {
  font-size: .875rem; /* .f6 */
  border: 1px solid currentColor;
  padding: var(--spacing-extra-small);
  color: var(--brand) !important;
  background-color: white;
  font-family: var(--brand-font);
  border-radius: var(--border-radius-1);
}

.secondary-button:hover,
.secondary-button:focus,
.secondary-button:active {
  background-color: #f4f4f4; /* near-white */
}

.secondary-button[disabled] {
  color: #999 !important; 
  background-color: white;
  cursor: not-allowed;
}

/* For @observable/inputs */
form.${ns} label {
  display: block;
}
</style>`

Logic

Bind logic

const setTypes = ["", "yes", "yesnomaybe", "ifyes", "ifno"]
 d3.group(expandSets(example_multi_set_layout), d => d['set'], d => d['role']);
example_multi_set_layout
example_aggregate_scores_layout
example_multi_set_questions
example_aggregate_scores_layout
function bindLogic(controlsById, layouts) {
  const cellOrder = layouts.reduce((acc, layout, index) => {
    acc[layout.id] = index
    return acc;
  }, {})
  
  const layoutByCodeByType = d3.group(expandSets(layouts), d => d['set'], d => d['role']);
  [...layoutByCodeByType.keys()].forEach(code => {
    const layoutByType = layoutByCodeByType.get(code);
    
    let conditionQuestion = undefined;
    let answer = undefined;
    
    if (layoutByType.has("yes")) {
      if (layoutByType.get("yes").length > 1) throw new Error("Only one yes per set code") 
      const id = layoutByType.get("yes")[0].id;
      conditionQuestion = controlsById.get(id);
      answer = (v) => v ? "yes" : "no"
    }
    
    // detect conditionals questions
    if (layoutByType.has("yesno")) {
      if (conditionQuestion) throw new Error("Only one condition question per set code") 
      const id = layoutByType.get("yes")[0].id;
      conditionQuestion = controlsById.get(id);
      answer = (v) => {
        if (/^yes/i.exec(v)) return "yes";
        if (/^no/i.exec(v)) return "no";
        return "maybe"
      }
    }
    
    if (layoutByType.has("yesnomaybe")) {
      if (conditionQuestion) throw new Error("Only one condition question per set code") 
      const id = layoutByType.get("yesnomaybe")[0].id;
      conditionQuestion = controlsById.get(id);
      answer = (v) => {
        if (/^yes/i.exec(v)) return "yes";
        if (/^no/i.exec(v)) return "no";
        return "maybe"
      }
    }
    // conditionals
    if (conditionQuestion) {
      if (layoutByType.has("ifyes")) {
        layoutByType.get("ifyes").forEach(layout => {
          const question = controlsById.get(layout.id)
          if (question) bindOneWay(question.hidden, conditionQuestion.control, {
            transform: (v) => !(answer(v) === "yes")
          });
        })
      }

      if (layoutByType.has("ifno")) {
        layoutByType.get("ifno").forEach(layout => {
          const question = controlsById.get(layout.id)
          if (question) bindOneWay(question.hidden, conditionQuestion.control, {
            transform: (v) => !(answer(v) === "no")
          });
        })
      }
    }  
    
    
    // wire up calculations
    (layoutByType.get("calculation") || []).forEach(calculationLayout => {
      const calculationQuestion = controlsById.get(calculationLayout.id);
      const scoredQuestions = [...layoutByType.entries()]
        .filter(([type, layouts]) => type !== "calculation") 
        .flatMap(([type, layouts]) => layouts.map(l => {
          const q = controlsById.get(l.id)
          q.args.id = l.id
          return q;
        }));
      
      const calculate = () => {
        const scores = scoredQuestions.map(q => scoreQuestion(q));
        calculationQuestion.control.calculate(scores);
      };
        
      try {
        calculate();
      } catch (err) {
        console.error(`Problem updating calculation '${calculationLayout.id}', reason: ${err.message}`)
        throw err
      }
      // If the calculation is numbered, back write numbering to the inputs
      if (calculationQuestion.control.numbering) {
        try {
          const firstIndex = Math.min(...scoredQuestions.map(q => cellOrder[q.args.id]));
          const firstId = layouts[firstIndex].id;
          const question = controlsById.get(firstId);
          question.numbering.value = calculationQuestion.control.numbering.value;
        } catch (err) {
          debugger;
        }
      }
      
      
      scoredQuestions.forEach(q => {
        q.control.addEventListener('input', calculate);
        invalidation.then(() => q.control.removeEventListener('input', calculate))
      })
    
    })
  })
}
function exampleLogic(questions, layout) {
  const controlsById = new Map(questions.map(q => [q.id, createQuestion(q)]))
  bindLogic(controlsById, layout)
  return viewUI`<div>
    ${['questions', [...controlsById.values()]]}
  `
}

Example 1: yes, ifyes, ifno

const example_yes_ifyes_layout = [{
  id: "open",
  role: "yes",
  set: "a",
}, {
  id: "expand1",
  role: "ifyes",
  set: "a",
}, {
  id: "expand2",
  role: "ifno",
  set: "a",
}] 
const example_yes_ifyes_questions = [{
  id: "open",
  type: "checkbox",
  options: ["Electricity Generation"]
}, {
  id: "expand1",
  type: "textarea",
  placeholder: "Specifiy type(s) of generation project.",
  rows:  1,
}, {
  id: "expand2",
  type: "textarea",
  placeholder: "Explain why not",
  rows:  1,
}] 
exampleLogic(example_yes_ifyes_questions, example_yes_ifyes_layout)

Example 2: yesnomaybe, ifyes, ifno

const example_yesnomaybe_ifyes_questions = [{
  id: "open",
  type: "radio",
  options: ["Yes.", "no", "dunno"]
}, {
  id: "expand1",
  type: "textarea",
  placeholder: "Specifiy type(s) of generation project.",
  rows:  1,
}, {
  id: "expand2",
  type: "textarea",
  placeholder: "Explain why not",
  rows:  1,
}] 
exampleLogic(example_yesnomaybe_ifyes_questions, example_yesnomaybe_ifyes_layout)

Example 3: score

const example_score_layout = [{
  id: "score",
  role: "scored",
  set: "A0.",
}, {
  id: "output",
  role: "calculation",
  set: "A0.",
}] 
const example_score_questions = [
  JSON.parse(`{"id":"score","type":"radio","title":"If yes:","options_eval":["{value:\\"a\\",score: \\"3\\", label: \\"We have a technical skills training program for our officials and staff.\\"}","{value:\\"b\\",score: \\"4\\", value:\\"b\\",label: \\"We implement a technical skills training program, still less than 60%.\\"}","{value:\\"c\\",score: \\"5\\", label: \\"We implement a technical skills training program for our officials and staff, 60% or more have been trained.\\"}"],"description":"Please select the statement that best describes your organization."}`),
  {
    id: "output",
    type: "summary",
    counter_group: "A",
    counter_value: 0,
    label: "this is the sum of the scores: ",
  }
]
//viewof exampleScoreGroup = exampleLogic(example_score_questions, example_score_layout)
const exampleScoreGroup = view(exampleLogic(example_score_questions, example_score_layout))

Example 3b: scored conditional

const example_scored_conditional_questions = [{
  id: "open",
  type: "radio",
  options: ["Yes.", "no", "dunno"]
}, {
  id: "expand1",
  type: "radio",
  options: [
    {score: "5", label: "5", value: 'yes'}, 
    {score: "4", label: "4", value: 'no'},
    {score: 3, label: "3", value: 'unknown'}]

}, {
  id: "expand2",
  type: "radio",
  options: [
    {score: "5.1", label: "5.1", value: 'yes'}, 
    {score: "4.1", label: "4.1", value: 'no'},
    {score: 3.1, label: "3.1", value: 'unknown'}]

},
  {
    id: "output",
    counter_group: "count",
    type: "summary",
    label: "this is the sum of the scores: ",
  }
]
const example_scored_conditional_layout = [{
  id: "open",
  role: "yesnomaybe",
  set: "a",
}, {
  id: "expand1",
  role: "ifyes",
  set: "a",
}, {
  id: "expand2",
  role: "ifno",
  set: "a",
}, {
  id: "output",
  role: "calculation",
  set: "a",
}] 
//viewof exampleScoredConditionalGroup = exampleLogic(example_scored_conditional_questions, example_scored_conditional_layout)
const exampleScoredConditionalGroup = view(exampleLogic(example_scored_conditional_questions, example_scored_conditional_layout))

Example 4: multiple sets

const example_multi_set_layout = [{
  id: "open",
  role: "yesnomaybe, scored",
  set: "a, A1.",
}, {
  id: "expand1",
  role: "ifyes,scored",
  set: "a, A1.",
}, {
  id: "expand2",
  role: "ifno,scored",
  set: "a, A1.",
}, {
  id: "score",
  role: "calculation",
  set: "A1.",
}] 
const example_multi_set_questions = [{
  id: "open",
  type: "radio",
  options: [
    {score: "5", label: "Yes.", value: 'yes'}, 
    {score: "4", label: "Nope", value: 'no'},
    {score: 3, label: "Don't know.", value: 'unknown'}]
}, {
  id: "expand1",
  type: "textarea",
  placeholder: "Specifiy type(s) of generation project.",
  rows:  1,
}, {
  id: "expand2",
  type: "textarea",
  placeholder: "Explain why not",
  rows:  1,
}, {
  id: "score",
  type: "summary",
  label:  "overall score: ",
}] 
//viewof exampleMultiSetGroup = exampleLogic(example_multi_set_questions, example_multi_set_layout)
const exampleMultiSetGroup = view(exampleLogic(example_multi_set_questions, example_multi_set_layout))

Example 5: aggregate scores

const example_aggregate_scores_layout = [{
  id: "q1",
  role: "scored",
  set: "1",
}, {
  id: "q2",
  role: "scored,scored",
  set: "2,aggregate",
}, {
  id: "s1",
  role: "calculation,scored",
  set: "1,aggregate",
}, {
  id: "s2",
  role: "calculation",
  set: "2",
}, {
  id: "aggregate",
  role: "calculation",
  set: "aggregate",
}] 
const example_aggregate_scores_questions = [{
  id: "q1",
  type: "radio",
  options: [
    {score: "5", label: "Yes.", value: 'yes'}, 
    {score: "4", label: "Nope", value: 'no'},
    {score: 3, label: "Don't know.", value: 'unknown'}]
}, {
  id: "q2",
  type: "radio",
  options: [
    {score: "5", label: "Yes.", value: 'yes'}, 
    {score: "4", label: "Nope", value: 'no'},
    {score: 3, label: "Don't know.", value: 'unknown'}]
},{
  id: "s1",
  type: "summary",
  label:  "score 1: ",
},{
  id: "s2",
  type: "summary",
  label:  "score 2: ",
},{
  id: "aggregate",
  type: "aggregate",
  label:  "aggregate of 1 & 2: ",
}] 
//viewof exampleAggregateScoreGroup = {
const exampleAggregateScoreGroup = view(() => {
  debugger;
  exampleLogic(example_aggregate_scores_questions, example_aggregate_scores_layout)
})

Styles for demo

Including Tachyons for the demo here

// This config needs to be part of account or survey config
const brandConfig = ({
  colors: {
    brand: mainColors[900], // or, provide and color hex code
    accent: accentColors[900], // or, provide and color hex code
    // The color of text which are usually displayed on top of the brand or accent colors.
    "text-on-brand": "#ffffff",
  },
  fonts: {
    "brand-font": `"Roboto", -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"`
  }
})
const initializeStyles = () => tachyonsExt(brandConfig)
initializeStyles()
const stylesForNotebooks = html`<style>
a[href].pagination_next {
  color: var(--text-on-brand) !important;
}

a[href].pagination_next:hover {
  text-decoration: none;
}`

Imports

//import {checkbox as checkboxBase, textarea, radio as radioBase, text, number, file} from "@jashkenas/inputs"
import {checkbox as checkboxBase, textarea, radio as radioBase, text, number, file} from "/components/inputs.js";
display(checkboxBase);
display(textarea);
display(radioBase);
display(text);
display(number);
display(file)
//import {view, bindOneWay, variable, cautious} from '@tomlarkworthy/view'
import {viewUI, bindOneWay, variable, cautious} from '/components/view.js'
//import {viewroutine, ask} from '@tomlarkworthy/viewroutine'
import {viewroutine, ask} from '/components/viewroutine.js';
display(viewroutine);
display(ask);
//import {toc} from "@nebrius/indented-toc"
import {toc} from "/components/indented-toc.js";
display(toc)
//import {download} from '@mbostock/lazy-download'
import {download} from '/components/lazy-download.js';
display(download)
//import {mainColors, accentColors} from "@categorise/brand"
import {mainColors, accentColors} from "/components/brand.js"
//import {tachyonsExt} from "@categorise/tachyons-and-some-extras"
import {tachyonsExt} from "/components/tachyons-and-some-extras.js";
display(tachyonsExt)
//import {textNodeView} from "@categorise/surveyslate-common-components"
import {textNodeView} from "/components/surveyslate-common-components.js";
display(textNodeView)
//import {createSuite, expect} from '@tomlarkworthy/testing'
import {createSuite, expect} from '/components/testing.js';
display(createSuite);
display(expect)