All files / src/view controls.js

100% Statements 41/41
100% Branches 16/16
100% Functions 13/13
100% Lines 40/40

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109                                    1x                     3x 3x 3x         3x 3x         6x 6x 6x         3x 2x 2x           1x 1x 4x 4x 4x           1x 1x 1x 1x 1x 1x 1x 1x 1x     4x   4x 4x                 2x 2x 6x           4x 4x 4x 4x 2x 2x   2x 4x    
/**
 * Bundle of form-control helpers wired into the Alpine "app" data object via spread.
 * @type {Object}
 * @property {function(params: Object, desc: {name: string, min: number, max: number}, delta: number): void} stepBy
 *  Stepper +/- click handler: bump params[desc.name] by delta clamped to [desc.min, desc.max].
 * @property {function(parts: Object[], index: number, counter: string): boolean} isDuplicatePart
 *  True when a parts-section row at index reuses an earlier label.
 * @property {function(parts: Object[], index: number): number} duplicateSourceIndex
 *  1-based index of the first earlier rho with the same label, or 0 when none.
 * @property {function(voice: {min_pitch: number, max_pitch: number}, fieldName: string): void} clampVoicePitchRange
 *  Voice-config input handler: snap the just-edited pitch field to the other when the range inverts.
 * @property {function(el: HTMLInputElement, getValue: Function, setValue: Function): void} setupChordTonesTristate
 *  Single checkbox cycling free/scale/chord (getValue: () => 'free'|'scale'|'chord'; setValue: (v) => void);
 *  chord = checked, scale = indeterminate, free = neither.
 * @property {function(formEl: HTMLElement, triData: Object<string,Object<string,string>>): void} setupFeedbackTristates
 *  Form-scan over fieldset[data-tri-group] input[data-tri]: cycle neutral/negative/positive;
 *  mutates triData in place.
 */
export const CONTROLS = {
  stepBy,
  isDuplicatePart,
  duplicateSourceIndex,
  clampVoicePitchRange,
  setupChordTonesTristate,
  setupFeedbackTristates,
};
 
/** @private */
function stepBy(params, descriptor, delta) {
  const { name, min, max } = descriptor;
  const v = params[name] + delta;
  params[name] = Math.max(min, Math.min(max, v));
}
 
/** @private */
function isDuplicatePart(parts, index, counter) {
  const inSection = counter === "parts" || counter === "step1.parts";
  return inSection && duplicateSourceIndex(parts, index) > 0;
}
 
/** @private */
function duplicateSourceIndex(parts, index) {
  const list = parts ?? [];
  const label = list[index]?.label;
  return list.slice(0, index).findIndex((p) => label && p?.label === label) + 1;
}
 
/** @private */
function clampVoicePitchRange(voice, fieldName) {
  if (voice.max_pitch < voice.min_pitch) {
    const other = fieldName === "min_pitch" ? "max_pitch" : "min_pitch";
    voice[fieldName] = voice[other];
  }
}
 
/** @private */
function setupChordTonesTristate(el, getValue, setValue) {
  const states = ["free", "scale", "chord"];
  cycleOnChange(el, states, getValue, (v) => {
    setValue(v);
    el.checked = v === "chord";
    el.indeterminate = v === "scale";
  });
}
 
/** @private */
function setupFeedbackTristates(formEl, triData) {
  const states = ["neutral", "negative", "positive"];
  for (const fieldset of formEl.querySelectorAll("fieldset[data-tri-group]")) {
    const group = fieldset.dataset.triGroup;
    triData[group] = {};
    const inputs = fieldset.querySelectorAll("input[type=checkbox][data-tri]");
    for (const input of inputs) {
      const name = input.name;
      triData[group][name] = states[0];
      cycleOnChange(
        input,
        states,
        () => triData[group][name],
        (v) => {
          triData[group][name] = v;
          applyFeedbackVisual(input, v);
        },
      );
    }
  }
}
 
/** @private Apply current value, then advance to next state on each change event. */
function cycleOnChange(target, states, getValue, applyValue) {
  applyValue(getValue());
  target.addEventListener("change", () => {
    applyValue(states[(states.indexOf(getValue()) + 1) % states.length]);
  });
}
 
/** @private Visual side-effect for one feedback tristate: aria-invalid + class flip. */
function applyFeedbackVisual(el, v) {
  el.checked = v === "positive";
  el.indeterminate = v === "negative";
  el.classList.remove("tristate-positive", "tristate-negative");
  if (v === "neutral") {
    el.removeAttribute("aria-invalid");
    return;
  }
  el.setAttribute("aria-invalid", v === "negative" ? "true" : "false");
  el.classList.add(`tristate-${v}`);
}