CloudPDF
DocsPricing
Start building

A front-desk kiosk

For a tablet on a counter: someone fills in an application and hands it back. The form is all there is, under a header with the desk’s name, the question being answered and how many required answers are in. Three buttons at the bottom, each a finger’s size, put the cursor in the next empty box, clear the form for the next person, and check it: Done lists what’s still missing, or locks the finished form and says thank you.

<link rel="stylesheet" href="./kiosk.css" />

<embedpdf-viewer id="viewer" style="height: 100%">
  <header slot="header" class="kiosk-header">
    <p class="kiosk-brand">Lanternfield <small>Front desk</small></p>
    <p class="kiosk-line" id="line" aria-live="polite">
      Tap a box to answer it, or tap Next field.
    </p>
    <p class="kiosk-progress" id="progress" hidden></p>
  </header>

  <div slot="panel:kiosk-check">
    <div class="kiosk-check" id="thanks" hidden>
      <h2>Thank you!</h2>
      <p>Your application is complete. Please hand the tablet back to the front desk.</p>
    </div>
    <div class="kiosk-check" id="missing">
      <h2>Almost there</h2>
      <p>These questions still need an answer:</p>
      <ul id="questions"></ul>
    </div>
  </div>
</embedpdf-viewer>

<script type="module">
  import 'https://cdn.jsdelivr.net/npm/@cloudpdf/viewer@3/dist/cloudpdf.js';

  const forms = { grant: 'shr_eBRW5E3MpolzD8G36Alp30EF' };

  const element = document.getElementById('viewer');
  const line = document.getElementById('line');
  const progress = document.getElementById('progress');

  /** People fill in the form, and nothing else changes it. */
  const FILLING_ONLY = { except: ['forms.filling'] };

  /** What the form calls a question: its tooltip, else its field's name. */
  const labelOf = (field) => field.alternateName ?? field.name;

  /** Whether a value from `exportValues()` is an answer: text, a choice, a ticked box. */
  const isAnswer = (value) => (Array.isArray(value) ? value.length > 0 : Boolean(value));

  // What the commands read from the app: whether the form is done (it's locked until Clear), and
  // how to change that and the header's line.
  let kiosk = {
    sent: false,
    setSent: (sent) => update({ sent }),
    say: (text) => (line.textContent = text),
  };

  // Each change is a new value: the viewer's buttons ask their commands again. Done locks the
  // form and says thank you; Clear unlocks it for the next person.
  function update(changes) {
    kiosk = { ...kiosk, ...changes };
    element.appState = kiosk;
    element.readOnly = kiosk.sent ? true : FILLING_ONLY;
    document.getElementById('thanks').hidden = !kiosk.sent;
    document.getElementById('missing').hidden = kiosk.sent;
  }

  /** The header's count, and the check's list: the required questions still empty. */
  function show(form) {
    const required = form.list().filter((field) => field.required).length;
    const { missing } = form.validate();
    progress.hidden = required === 0;
    progress.textContent = `${required - missing.length} of ${required} required`;
    progress.classList.toggle('complete', missing.length === 0);
    document.getElementById('questions').replaceChildren(
      ...missing.map((question) => {
        const button = document.createElement('button');
        button.type = 'button';
        button.textContent = labelOf(question);
        button.onclick = () => pick(question);
        const item = document.createElement('li');
        item.append(button);
        return item;
      }),
    );
  }

  /** Close the check, and put the cursor in a question it lists. */
  function pick(question) {
    element.viewer.get('shell').close('kiosk-check');
    element.viewer.get('form').focus(question.ref);
  }

  // The front desk's viewer: the form, a header and three buttons, nothing else. The commands read
  // the app from `appState`.
  document.getElementById('viewer').config = {
    src: forms,
    appState: kiosk,
    readOnly: FILLING_ONLY,
    theme: {
      preference: 'light',
      accent: '#b5500e',
      tokens: {
        background: '#f3e7dc',
        surface: '#fffaf5',
        'surface-raised': '#fffaf5',
        text: '#2b1c12',
        'text-muted': '#6e5848',
        border: '#e3d5c9',
        hover: '#fdefe1',
        canvas: '#efe2d3',
        radius: '14px',
        font: "ui-rounded, 'SF Pro Rounded', 'Segoe UI', system-ui, sans-serif",
      },
    },
    // Every button and menu row a finger's size, even with a mouse plugged in.
    touch: true,
    // A kiosk fills in forms all day and never keeps them: leaving the page asks nothing.
    warnBeforeLeaving: false,
    // No opening, saving or printing files, no search, and no selecting text: a long press is
    // only ever a tap on the form.
    disable: ['documents', 'search', 'text'],
    plugins: {
      stage: { zoom: { mode: 'fit-width' } },
      form: { fields: { background: 'rgb(253 239 225 / 0.9)' } },
    },
    icons: {
      next: ['M5 12l14 0', 'M13 18l6 -6', 'M13 6l6 6'],
      clear: [
        'M19 20h-10.5l-4.21 -4.3a1 1 0 0 1 0 -1.41l10 -10a1 1 0 0 1 1.41 0l5 5a1 1 0 0 1 0 1.41l-9.2 9.3',
        'M18 13.3l-6.3 -6.3',
      ],
      done: ['M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0', 'M9 12l2 2l4 -4'],
    },
    commands: [
      {
        id: 'kiosk:next',
        label: 'Next field',
        icon: 'next',
        // The header names the field once the cursor is in it.
        run: ({ appState, viewer }) => {
          if (appState.sent) {
            appState.say('This form is done. Tap Clear for the next person.');
          } else if (!viewer.get('form').focusNext({ empty: true, wrap: true })) {
            appState.say('Every question has an answer. Tap Done.');
          }
        },
      },
      {
        id: 'kiosk:clear',
        label: 'Clear',
        icon: 'clear',
        enabled: ({ get }) => Object.values(get('form').exportValues()).some(isAnswer),
        run: async ({ appState, viewer }) => {
          appState.setSent(false);
          await viewer.get('form').reset();
          viewer.get('stage').goToPage(0);
          appState.say('Cleared, ready for the next person.');
        },
      },
      {
        id: 'kiosk:done',
        label: 'Done',
        icon: 'done',
        run: ({ appState, viewer }) => {
          const { valid } = viewer.get('form').validate();
          appState.setSent(valid);
          appState.say(valid ? 'All done. Thank you!' : 'A few answers are still missing.');
          viewer.get('shell').open('kiosk-check');
        },
      },
    ],
    // A layout of its own: one bar at the bottom with the three buttons and their labels, Done
    // filled in, and the check as a sheet.
    layout: {
      bars: {
        kiosk: {
          place: 'bottom',
          center: [
            [
              { command: 'kiosk:next', variants: ['icon+label', 'icon'] },
              { command: 'kiosk:clear', variants: ['icon+label', 'icon'] },
              { command: 'kiosk:done', emphasis: 'primary', variants: ['icon+label', 'icon'] },
            ],
          ],
        },
      },
      panels: {
        'kiosk-check': { title: 'Your application', present: 'sheet', resizable: false },
      },
    },
  };

  element.addEventListener('epdf:ready', () => {
    const { viewer } = element;
    // The form is the document's: `watch` waits for it to open, then follows it as people fill
    // it in, for the header's count and the check.
    viewer.watch(
      () => viewer.get('form').list(),
      () => show(viewer.get('form')),
      { immediate: true },
    );
    // The header's line names the question the cursor is in, whether Next field or a tap put it
    // there.
    viewer.documents.onOpened(() => {
      viewer.get('form').onFocusChanged(({ field }) => {
        if (field) line.textContent = `Answering: ${labelOf(field)}`;
      });
    });
  });
</script>

Try it: tap Next field: the cursor goes into Full name, and the header names it. Fill it in, then tap Done: the sheet lists Email and the membership terms, and a tap on one puts the cursor in it. Answer both, tap Done again, then Clear for the next person.

What it uses#