All files / web/src/components/studio StudioChoice.tsx

100% Statements 149/149
90% Branches 9/10
100% Functions 2/2
100% Lines 149/149

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 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 1501x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 10x 20x 20x 20x 20x 20x 20x 20x 20x 20x 20x 20x 20x 20x 20x 20x 20x 20x 20x 20x 20x 20x 20x 20x 20x 20x 20x 20x 20x 20x 20x 20x 5x 5x 5x 20x 20x 20x 10x 10x 10x 10x  
'use client'
 
// StudioChoice — a vertical list of radio options for the dark studio rails.
// Each option is a row: radio, title, one-line description. The SELECTED option
// may carry nested controls, rendered inside its own highlighted block, so the
// controls that exist only because of that choice are parented by it
// structurally — not by sitting somewhere underneath a switch.
//
// WHY NOT A SEGMENTED CONTROL. A full-width pill pair reads as a tab bar, and a
// tab bar claims everything beneath it: the first cut of the construction
// control had the joint select, its description and the NEXT section heading
// all reading as "the Column modules tab". A boxed sub-panel under the switch
// did not fix it — the accent sat on the left while the active segment sat on
// the right, and the box was a card in a rail that has none. An option list
// has no "beneath": the only things inside a row are that row's.
//
// Styled from theme.ts: the selected option wears the studio's one accent as a
// soft fill plus border, and nothing else in the list is tinted at all.
//
// The options are native radios sharing one `name`, so arrow keys move between
// them and the label text is the click target. The nested controls render
// OUTSIDE the <label> (a select inside a label would be activated by the
// label and is invalid nesting) but inside the option's block.
 
import { type CSSProperties, type ReactNode, useId } from 'react'
import { STUDIO } from './theme'
 
export interface StudioChoiceOption<V extends string> {
  value: V
  title: string
  description?: ReactNode
  /** Controls that exist only because this option is chosen. Rendered inside
   *  the option's block, only while it is selected. */
  children?: ReactNode
}
 
export interface StudioChoiceProps<V extends string> {
  label: string
  value: V
  options: ReadonlyArray<StudioChoiceOption<V>>
  onChange: (value: V) => void
  dataElement?: string
  dataAction?: string
}
 
const LABEL: CSSProperties = STUDIO.type.label
 
const OPTION: CSSProperties = {
  borderRadius: STUDIO.radius.choice,
  border: '1px solid transparent',
  padding: '9px 12px 9px 10px',
}
 
const OPTION_SELECTED: CSSProperties = {
  ...OPTION,
  background: STUDIO.color.accentSoft,
  border: `1px solid ${STUDIO.color.accentBorder}`,
}
 
const ROW: CSSProperties = {
  display: 'grid',
  gridTemplateColumns: '14px 1fr',
  columnGap: 10,
  alignItems: 'start',
  cursor: 'pointer',
}
 
const RADIO: CSSProperties = {
  width: 14,
  height: 14,
  margin: '2px 0 0',
  accentColor: STUDIO.color.accent,
  cursor: 'pointer',
}
 
const TITLE: CSSProperties = STUDIO.type.strong
 
const DESCRIPTION: CSSProperties = STUDIO.type.note
 
// indented to the title's column, so the nested controls hang off the option
// the way the description does
const DETAILS: CSSProperties = {
  display: 'flex',
  flexDirection: 'column',
  gap: STUDIO.space.card,
  marginTop: STUDIO.space.card,
  paddingLeft: 24,
}
 
export function StudioChoice<V extends string>({
  label,
  value,
  options,
  onChange,
  dataElement = 'studio-choice',
  dataAction = 'set-choice',
}: StudioChoiceProps<V>) {
  const name = useId()
  return (
    <div
      data-component="studio-choice"
      data-element={dataElement}
      role="radiogroup"
      aria-label={label}
      style={{ display: 'flex', flexDirection: 'column', gap: 4 }}
    >
      <span style={{ ...LABEL, marginBottom: 2 }}>{label}</span>
      {options.map((o) => {
        const selected = o.value === value
        const descriptionId = `${name}-${o.value}-description`
        return (
          <div
            key={o.value}
            data-element={`${dataElement}-option`}
            data-value={o.value}
            data-selected={selected ? 'true' : undefined}
            style={selected ? OPTION_SELECTED : OPTION}
          >
            <label style={ROW}>
              <input
                type="radio"
                name={name}
                value={o.value}
                checked={selected}
                onChange={() => onChange(o.value)}
                data-action={dataAction}
                aria-describedby={o.description != null ? descriptionId : undefined}
                style={RADIO}
              />
              <span style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
                <span style={TITLE}>{o.title}</span>
                {o.description != null && (
                  <span id={descriptionId} style={DESCRIPTION}>
                    {o.description}
                  </span>
                )}
              </span>
            </label>
            {selected && o.children != null && (
              <div data-element={`${dataElement}-details`} style={DETAILS}>
                {o.children}
              </div>
            )}
          </div>
        )
      })}
    </div>
  )
}