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
npx shadcn@latest add https://design.moodinary.com/r/public-form.jsonimport { 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.
Código
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.
Código
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
| Prop | Tipo | Default | Descripción |
|---|---|---|---|
| labels | readonly string[] | PULSE_SCALE_LABELS | Cinco nombres en el orden de la escala. |
| value | number | null | undefined | - | Nivel seleccionado; null o undefined indican que falta elegir. |
| onChange | (event: ChangeEvent<HTMLInputElement>) => void | - | Recibe el cambio del radio nativo. |
| name | string | "level" | Nombre compartido de los radios. Usá uno distinto por selector. |
| disabled | boolean | - | Bloquea todos los radios. |
| icons | readonly ComponentType<{ className?: string }>[] | null | - | Un glifo de 32 px por nivel, a la derecha de la fila (caras o clima). |
| dots | boolean | - | Glifo de puntos. |
| className | string | - | Clases del grupo. |
PublicCard
| Prop | Tipo | Default | Descripción |
|---|---|---|---|
| center | boolean | - | Centra el texto de la tarjeta. |
| children | ReactNode | - | Contenido del formulario. |
| className | string | - | Clases de la columna blanca de hasta 560 px, sin borde. |
PublicHead
| Prop | Tipo | Default | Descripción |
|---|---|---|---|
| children | ReactNode | - | Header sticky: logo de 28 px, organización · equipo y progreso de 3 pasos. |
| className | string | - | Clases del header. |
PublicTitle
| Prop | Tipo | Default | Descripción |
|---|---|---|---|
| number | number | - | 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
- Poné una pregunta visible antes del selector.
- Mantené el comentario opcional.
- Mostrá confirmación al terminar el envío.
- 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.