Moodinary/UI

Mood picker (formulario de pulso)

El pulso de 10 segundos. Flechas cambian, un toque elige. Sin cuenta, sin nombre.

Selector de ánimo del formulario público: cinco filas verticales con tecla numérica (Muy mal, Mal, Regular, Bien, Muy bien) y glifo opcional a la derecha según la personalización de la organización. Es la única superficie que toma el color de la org. Se usa en el formulario público de escuelas y empresas. No requiere cuenta ni nombre. No hay una frecuencia mínima obligatoria; la organización puede limitar a un pulso por día.

Instalación

shadcn
npx shadcn@latest add https://design.moodinary.com/r/public-form.json
import
import { MoodPicker, PublicCard, PublicHead, PublicTitle, ToggleChip } from "@moodinary/ui/components/public-form";

Ejemplos

Formulario de pulso

Las tres preguntas reales del form: nivel, motivo y comentario opcional. El envío se habilita con nivel y motivo. Muestra el agradecimiento sin guardar nada.

EBEstudio Bermúdez · Administración

¿Cómo te sentiste hoy?

¿Qué tuvo más que ver?

No pedimos tu nombre ni tu correo, y no registramos desde qué dispositivo respondés. La dirección ve el clima del equipo agrupado, nunca quién respondió qué. En equipos muy chicos, el horario de la respuesta puede cruzar con la agenda de cada persona.

Con la tecnología de Moodinary
Código
pulse-form.tsx
import { CheckIcon } from "@moodinary/ui/components/icons";
import {
	MoodPicker,
	PublicCard,
	PublicHead,
	PublicLead,
	PublicTitle,
	ToggleChip,
} from "@moodinary/ui/components/public-form";
import { type CSSProperties, useId, useState } from "react";

/* Copia real de apps/pulses/src/components/pulse/pulse-form-a.tsx. */
const REASONS = [
	"Comunidad",
	"Familia",
	"Amistades",
	"Trabajo",
	"Actualidad",
	"Otros",
];
const PRIVACY_NOTE =
	"No pedimos tu nombre ni tu correo, y no registramos desde qué dispositivo respondés. La dirección ve el clima del equipo agrupado, nunca quién respondió qué. En equipos muy chicos, el horario de la respuesta puede cruzar con la agenda de cada persona.";

/** El form público es la única superficie con color de la org (acá, un verde de ejemplo). */
const orgStyle = { "--org": "#2f6f5e" } as CSSProperties;

export default function PulseForm() {
	const [level, setLevel] = useState<number | null>(4);
	const [reason, setReason] = useState<string | null>(null);
	const [sent, setSent] = useState(false);
	const id = useId();
	const steps = [
		{ id: "level", on: Boolean(level) },
		{ id: "reason", on: Boolean(reason) },
		{ id: "send", on: false },
	];

	return (
		<div className="w-full bg-white" style={orgStyle}>
			<PublicCard className="mx-auto">
				<PublicHead>
					<span className="grid size-7 shrink-0 place-items-center rounded-[7px] bg-org font-semibold text-[11px] text-white">
						EB
					</span>
					<span>
						<b className="font-medium text-[#16181d]">Estudio Bermúdez</b> ·
						Administración
					</span>
					{sent ? null : (
						<span
							aria-label={`Paso ${1 + Number(steps[0].on) + Number(steps[1].on)} de 3`}
							className="ml-auto flex gap-1"
							role="img"
						>
							{steps.map((step) => (
								<i
									className={
										step.on
											? "h-[3px] w-[18px] rounded-sm bg-org"
											: "h-[3px] w-[18px] rounded-sm bg-[#e7e8eb]"
									}
									key={step.id}
								/>
							))}
						</span>
					)}
				</PublicHead>

				{sent ? (
					<section className="py-16" role="status">
						<div
							aria-hidden
							className="mb-6 grid size-11 place-items-center rounded-full bg-org text-white [&_svg]:size-[22px]"
						>
							<CheckIcon />
						</div>
						<PublicTitle className="mb-2.5" size="sm">
							Gracias.
						</PublicTitle>
						<PublicLead>
							Tu pulso llegó de forma anónima. Podés volver a responder mañana.
						</PublicLead>
						<button
							className="mt-6 cursor-pointer text-[#6b6f78] text-[13px] underline underline-offset-2"
							onClick={() => {
								setLevel(null);
								setReason(null);
								setSent(false);
							}}
							type="button"
						>
							Volver al ejemplo
						</button>
					</section>
				) : (
					<form
						onSubmit={(e) => {
							e.preventDefault();
							setSent(true);
						}}
					>
						<fieldset
							aria-labelledby={`${id}-q1`}
							className="m-0 min-w-0 border-0 p-0 pt-10"
						>
							<div className="block w-full p-0" id={`${id}-q1`}>
								<PublicTitle number={1}>¿Cómo te sentiste hoy?</PublicTitle>
							</div>
							<MoodPicker
								name={`${id}-level`}
								onChange={(e) => setLevel(Number(e.target.value))}
								value={level}
							/>
						</fieldset>

						<fieldset
							aria-labelledby={`${id}-q2`}
							className="m-0 min-w-0 border-0 p-0 pt-14"
						>
							<div className="block w-full p-0" id={`${id}-q2`}>
								<PublicTitle number={2}>¿Qué tuvo más que ver?</PublicTitle>
							</div>
							<div className="flex flex-wrap gap-2">
								{REASONS.map((r) => (
									<ToggleChip
										key={r}
										onClick={() => setReason(r)}
										pressed={reason === r}
									>
										{r}
									</ToggleChip>
								))}
							</div>
						</fieldset>

						<section className="pt-14">
							<PublicTitle number={3}>
								<label htmlFor={`${id}-comment`}>
									¿Algo más? <em>Opcional</em>
								</label>
							</PublicTitle>
							<textarea
								className="w-full resize-none border-0 border-[#e7e8eb] border-b bg-transparent py-2 font-sans text-[20px] leading-[1.4] outline-0 placeholder:text-[#b8bbc1] focus:border-org"
								id={`${id}-comment`}
								maxLength={500}
								placeholder="Escribí lo que quieras…"
								rows={2}
							/>
							<div className="mt-10 flex flex-wrap items-center gap-4">
								<button
									className="cursor-pointer rounded-[10px] bg-org px-[22px] py-3.5 font-medium text-base text-white hover:brightness-110 disabled:cursor-default disabled:opacity-50 disabled:hover:brightness-100"
									disabled={
										!(level && reason)
									} /* = app pulse-form-a.tsx:870 (sin busy/Turnstile: no hay red) */
									type="submit"
								>
									Enviar pulso
								</button>
								<p className="m-0 max-w-[44ch] text-[#6b6f78] text-[13px]">
									{PRIVACY_NOTE}
								</p>
							</div>
						</section>
						<footer className="pt-12 text-[#b8bbc1] text-xs">
							Con la tecnología de Moodinary
						</footer>
					</form>
				)}
			</PublicCard>
		</div>
	);
}

Glifos

Sin glifo, caras o clima: la numeración 1 a 5 y los nombres no cambian. Deshabilitá los radios mientras enviás.

Sin glifo
Caras
Clima, bloqueado mientras se envía
Código
glyphs.tsx
import {
	Angry,
	Cloud,
	CloudLightning,
	CloudRain,
	CloudSun,
	Frown,
	Laugh,
	Meh,
	Smile,
	Sun,
} from "@moodinary/ui/components/icons";
import { MoodPicker } from "@moodinary/ui/components/public-form";
import { type CSSProperties, useState } from "react";

/* Mismos sets que GLYPH_SETS en pulse-form-a.tsx: la numeración 1–5 no cambia, el glifo va a la derecha. */
const faces = [Angry, Frown, Meh, Smile, Laugh];
const climate = [CloudLightning, CloudRain, Cloud, CloudSun, Sun];
const orgStyle = { "--org": "#1f4e8c" } as CSSProperties;

export default function MoodPickerGlyphs() {
	const [plain, setPlain] = useState<number | null>(null);
	const [faceLevel, setFaceLevel] = useState<number | null>(4);
	const [climateLevel, setClimateLevel] = useState<number | null>(2);
	return (
		<div className="grid w-full gap-8 sm:grid-cols-3" style={orgStyle}>
			<div className="grid gap-2">
				<span className="text-[13px] text-ink-3">Sin glifo</span>
				<MoodPicker
					aria-label="Sin glifo"
					name="plain"
					onChange={(e) => setPlain(Number(e.target.value))}
					value={plain}
				/>
			</div>
			<div className="grid gap-2">
				<span className="text-[13px] text-ink-3">Caras</span>
				<MoodPicker
					aria-label="Caras"
					icons={faces}
					name="faces"
					onChange={(e) => setFaceLevel(Number(e.target.value))}
					value={faceLevel}
				/>
			</div>
			<div className="grid gap-2">
				<span className="text-[13px] text-ink-3">
					Clima, bloqueado mientras se envía
				</span>
				<MoodPicker
					aria-label="Clima"
					disabled
					icons={climate}
					name="climate"
					onChange={(e) => setClimateLevel(Number(e.target.value))}
					value={climateLevel}
				/>
			</div>
		</div>
	);
}

Props

MoodPicker

PropTipoDefaultDescripción
labelsreadonly string[]PULSE_SCALE_LABELSCinco nombres en el orden de la escala.
valuenumber | null | undefined-Nivel seleccionado; null o undefined indican que falta elegir.
onChange(event: ChangeEvent<HTMLInputElement>) => void-Recibe el cambio del radio nativo.
namestring"level"Nombre compartido de los radios. Usá uno distinto por selector.
disabledboolean-Bloquea todos los radios.
iconsreadonly ComponentType<{ className?: string }>[] | null-Un glifo de 32 px por nivel, a la derecha de la fila (caras o clima).
dotsboolean-Glifo de puntos.
classNamestring-Clases del grupo.

PublicCard

PropTipoDefaultDescripción
centerboolean-Centra el texto de la tarjeta.
childrenReactNode-Contenido del formulario.
classNamestring-Clases de la columna blanca de hasta 560 px, sin borde.

PublicHead

PropTipoDefaultDescripción
childrenReactNode-Header sticky: logo de 28 px, organización · equipo y progreso de 3 pasos.
classNamestring-Clases del header.

PublicTitle

PropTipoDefaultDescripción
numbernumber-Número de pregunta en color de la org.
size"lg" | "sm""lg"lg para preguntas (30 a 40 px), sm para mensajes.

Cuándo sí y cuándo no

Hacé
  • Poné una pregunta visible antes del selector.
  • Mantené el comentario opcional.
  • Mostrá confirmación al terminar el envío.
Evitá
  • No selecciones una respuesta por la persona.
  • No pidas nombre junto al pulso anónimo.
  • No impongas una frecuencia semanal en la copia.

Accesibilidad

  • Usá fieldset y legend con la pregunta; las teclas 1 a 5 son solo una pista visual.
  • Tab entra al grupo; las flechas cambian el radio seleccionado y Espacio elige.
  • Anunciá envío y confirmación con un estado legible; no dependas solo de un spinner.