Actions
Segmented
Selector de opción única que se pliega en un desplegable si no cabe
Preview
Estrechar se activa con auto, el único modo que cambia según el ancho.
size
mode
options
Code
import css from "./Segmented.module.css";
import { useRef } from "react";
import { CaretDownIcon } from "@phosphor-icons/react/dist/csr/CaretDown";
import Popover from "@/components/library/react/Popover/Popover";
type Size = "xs" | "sm" | "md";
// Three values, not two booleans: "forced dropdown that also collapses" must not be expressible.
type Mode = "pills" | "dropdown" | "auto";
interface ResponsiveSize {
base: Size;
mobile?: Size;
tablet?: Size;
desktop?: Size;
wide?: Size;
}
interface SegmentedProps {
options: string[];
value: string;
size: Size | ResponsiveSize;
mode: Mode;
// Required: collapsed, the trigger shows only the value, nothing says of what.
"aria-label": string;
onChange: (value: string) => void;
}
function getSizeClasses(size: Size | ResponsiveSize): string {
if (typeof size === "string") {
return css[size];
}
const classes = [css[`${size.base}_base`]];
if (size.mobile) classes.push(css[`${size.mobile}_mobile`]);
if (size.tablet) classes.push(css[`${size.tablet}_tablet`]);
if (size.desktop) classes.push(css[`${size.desktop}_desktop`]);
if (size.wide) classes.push(css[`${size.wide}_wide`]);
return classes.join(" ");
}
export default function Segmented({
options,
value,
size,
mode,
"aria-label": ariaLabel,
onChange,
}: SegmentedProps) {
const collapsed = useRef<HTMLDivElement>(null);
const auto = mode === "auto";
const select = (option: string, close?: () => void) => {
onChange(option);
close?.();
};
// Popover takes over the trigger's ref, so the keys are read from the collapsed box.
const onKeyDown = (ev: React.KeyboardEvent) => {
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(ev.key)) return;
const box = collapsed.current!;
const trigger = box.querySelector<HTMLElement>(`.${css.collapsed_trigger}`)!;
const items = [...box.querySelectorAll<HTMLElement>(`.${css.menu_item}`)];
if (!items.length) return;
const chosen = Math.max(0, options.indexOf(value));
if (trigger.getAttribute("aria-expanded") !== "true") {
if (ev.key !== "ArrowDown" && ev.key !== "ArrowUp") return;
ev.preventDefault();
trigger.click();
// The box only shows after the re-render; focus waits for that frame.
requestAnimationFrame(() => items[chosen].focus());
return;
}
ev.preventDefault();
const at = items.indexOf(document.activeElement as HTMLElement);
const last = items.length - 1;
let next = chosen;
if (ev.key === "Home") {
next = 0;
} else if (ev.key === "End") {
next = last;
} else if (at >= 0 && ev.key === "ArrowDown") {
next = at === last ? 0 : at + 1;
} else if (at >= 0 && ev.key === "ArrowUp") {
next = at === 0 ? last : at - 1;
}
items[next].focus();
};
return (
<div
className={`${css.segmented} ${getSizeClasses(size)} ${auto ? css.auto : ""}`}
>
{(mode === "pills" || auto) && (
<div className={css.segmented_pills} role="group" aria-label={ariaLabel}>
{options.map((option) => {
const active = option === value;
return (
<button
key={option}
type="button"
className={`${css.pills_option} ${active ? css.active : ""}`}
aria-pressed={active}
onClick={() => select(option)}
>
{option}
</button>
);
})}
</div>
)}
{(mode === "dropdown" || auto) && (
<div ref={collapsed} className={css.segmented_collapsed} onKeyDown={onKeyDown}>
<Popover
trigger={
<button
type="button"
className={css.collapsed_trigger}
aria-label={`${ariaLabel}: ${value}`}
>
{value}
<CaretDownIcon className={css.trigger_caret} weight="bold" />
</button>
}
side="bottom"
align="end"
>
{(close) => (
<ul className={css.collapsed_menu}>
{options.map((option) => (
<li key={option}>
<button
type="button"
className={`${css.menu_item} ${option === value ? css.active : ""}`}
aria-pressed={option === value}
onClick={() => select(option, close)}
>
{option}
</button>
</li>
))}
</ul>
)}
</Popover>
</div>
)}
</div>
);
}
.segmented {
display: inline-block;
font-family: var(--font-body);
font-weight: var(--weight-500);
-webkit-tap-highlight-color: transparent;
/* Pill, trigger and menu item share one height, so collapsing never moves the row. */
&.xs {
--segmented-pill: var(--size-24);
--segmented-pill-touch: var(--size-28);
font-size: var(--text-14);
}
&.sm {
--segmented-pill: var(--size-28);
--segmented-pill-touch: var(--size-32);
font-size: var(--text-14);
}
&.md {
--segmented-pill: var(--size-32);
--segmented-pill-touch: var(--size-36);
font-size: var(--text-14);
}
&.xs_base {
--segmented-pill: var(--size-24);
--segmented-pill-touch: var(--size-28);
font-size: var(--text-14);
}
&.sm_base {
--segmented-pill: var(--size-28);
--segmented-pill-touch: var(--size-32);
font-size: var(--text-14);
}
&.md_base {
--segmented-pill: var(--size-32);
--segmented-pill-touch: var(--size-36);
font-size: var(--text-14);
}
@media (min-width: 480px) {
&.xs_mobile {
--segmented-pill: var(--size-24);
--segmented-pill-touch: var(--size-28);
font-size: var(--text-14);
}
&.sm_mobile {
--segmented-pill: var(--size-28);
--segmented-pill-touch: var(--size-32);
font-size: var(--text-14);
}
&.md_mobile {
--segmented-pill: var(--size-32);
--segmented-pill-touch: var(--size-36);
font-size: var(--text-14);
}
}
@media (min-width: 768px) {
&.xs_tablet {
--segmented-pill: var(--size-24);
--segmented-pill-touch: var(--size-28);
font-size: var(--text-14);
}
&.sm_tablet {
--segmented-pill: var(--size-28);
--segmented-pill-touch: var(--size-32);
font-size: var(--text-14);
}
&.md_tablet {
--segmented-pill: var(--size-32);
--segmented-pill-touch: var(--size-36);
font-size: var(--text-14);
}
}
@media (min-width: 1024px) {
&.xs_desktop {
--segmented-pill: var(--size-24);
--segmented-pill-touch: var(--size-28);
font-size: var(--text-14);
}
&.sm_desktop {
--segmented-pill: var(--size-28);
--segmented-pill-touch: var(--size-32);
font-size: var(--text-14);
}
&.md_desktop {
--segmented-pill: var(--size-32);
--segmented-pill-touch: var(--size-36);
font-size: var(--text-14);
}
}
@media (min-width: 1440px) {
&.xs_wide {
--segmented-pill: var(--size-24);
--segmented-pill-touch: var(--size-28);
font-size: var(--text-14);
}
&.sm_wide {
--segmented-pill: var(--size-28);
--segmented-pill-touch: var(--size-32);
font-size: var(--text-14);
}
&.md_wide {
--segmented-pill: var(--size-32);
--segmented-pill-touch: var(--size-36);
font-size: var(--text-14);
}
}
.segmented_pills {
display: inline-flex;
gap: var(--space-8);
}
.pills_option,
.collapsed_trigger {
border: var(--border-surface) solid var(--color-border-control);
}
.pills_option {
display: inline-flex;
align-items: center;
justify-content: center;
height: var(--segmented-pill);
padding: 0 calc(var(--segmented-pill) * 0.6);
border-radius: var(--radius-4);
background: none;
cursor: pointer;
font-family: inherit;
font-size: inherit;
font-weight: inherit;
/* The pill height owns the vertical rhythm; an inherited 1.5 would push the
text box past it. */
line-height: 1;
color: var(--color-text-secondary);
transition:
background 0.15s ease,
border-color 0.15s ease,
color 0.15s ease;
}
@media (hover: hover) {
.pills_option:not(.active):hover,
.collapsed_trigger:hover,
.menu_item:not(.active):hover {
background: color-mix(in srgb, var(--color-text) 8%, transparent);
color: var(--color-text);
}
}
.pills_option:not(.active):active,
.collapsed_trigger:active,
.menu_item:not(.active):active {
background: color-mix(in srgb, var(--color-text) 16%, transparent);
color: var(--color-text);
}
/* Offset 0: a negative offset would hide the ring under the selected item's fill. */
.menu_item:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 0;
}
.collapsed_trigger {
display: inline-flex;
align-items: center;
gap: var(--space-8);
height: var(--segmented-pill);
padding: 0 calc(var(--segmented-pill) * 0.6);
background: none;
border-radius: var(--radius-4);
cursor: pointer;
font-family: inherit;
font-size: inherit;
font-weight: inherit;
line-height: 1;
color: var(--color-text);
transition: background 0.15s ease;
.trigger_caret {
width: 1em;
height: 1em;
color: var(--color-text-secondary);
transition: transform 0.2s ease;
@media (prefers-reduced-motion: reduce) {
transition: none;
}
}
/* Popover injects aria-expanded, so the caret follows without state here. */
&[aria-expanded="true"] .trigger_caret {
transform: rotate(180deg);
}
}
.collapsed_menu {
display: flex;
flex-direction: column;
gap: var(--space-8);
}
.menu_item {
display: flex;
align-items: center;
width: 100%;
height: var(--segmented-pill);
padding: 0 calc(var(--segmented-pill) * 0.6);
border: 0;
border-radius: var(--radius-4);
background: none;
cursor: pointer;
font-family: inherit;
font-size: inherit;
font-weight: inherit;
line-height: 1;
color: var(--color-text-secondary);
transition:
background 0.15s ease,
color 0.15s ease;
}
/* After every part, so it wins over their heights at the same weight. */
@media (pointer: coarse) {
.pills_option,
.collapsed_trigger,
.menu_item {
height: var(--segmented-pill-touch);
padding: 0 calc(var(--segmented-pill-touch) * 0.6);
}
}
/* Last on purpose: same weight as .pills_option/.menu_item, so source order decides. */
.active {
background: var(--color-accent);
border-color: var(--color-accent);
color: var(--color-bg);
}
/* Container rules last so they win; with no container the viewport decides alone. */
&.auto {
.segmented_pills {
display: none;
@media (min-width: 480px) {
display: inline-flex;
}
@container (max-width: 479px) {
display: none;
}
}
.segmented_collapsed {
@media (min-width: 480px) {
display: none;
}
@container (max-width: 479px) {
display: block;
}
}
}
}
<script setup lang="ts">
import { computed, useTemplateRef } from "vue";
import { PhCaretDown } from "@phosphor-icons/vue";
import Popover from "@/components/library/vue/Popover/Popover.vue";
type Size = "xs" | "sm" | "md";
// Three values, not two booleans: "forced dropdown that also collapses" must not be expressible.
type Mode = "pills" | "dropdown" | "auto";
interface ResponsiveSize {
base: Size;
mobile?: Size;
tablet?: Size;
desktop?: Size;
wide?: Size;
}
interface SegmentedProps {
options: string[];
size: Size | ResponsiveSize;
mode: Mode;
// Required: collapsed, the trigger shows only the value, nothing says of what.
ariaLabel: string;
}
const { options, size, mode, ariaLabel } = defineProps<SegmentedProps>();
const model = defineModel<string>({ required: true });
const collapsed = useTemplateRef<HTMLDivElement>("collapsed");
const auto = computed(() => mode === "auto");
const sizeClasses = computed(() => {
if (typeof size === "string") {
return size;
}
const classes = [`${size.base}_base`];
if (size.mobile) classes.push(`${size.mobile}_mobile`);
if (size.tablet) classes.push(`${size.tablet}_tablet`);
if (size.desktop) classes.push(`${size.desktop}_desktop`);
if (size.wide) classes.push(`${size.wide}_wide`);
return classes.join(" ");
});
const select = (option: string, close?: () => void) => {
model.value = option;
close?.();
};
// Popover takes over the trigger's ref, so the keys are read from the collapsed box.
const onKeyDown = (ev: KeyboardEvent) => {
if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(ev.key)) return;
const box = collapsed.value!;
const trigger = box.querySelector<HTMLElement>(".collapsed_trigger")!;
const items = [...box.querySelectorAll<HTMLElement>(".menu_item")];
if (!items.length) return;
const chosen = Math.max(0, options.indexOf(model.value));
if (trigger.getAttribute("aria-expanded") !== "true") {
if (ev.key !== "ArrowDown" && ev.key !== "ArrowUp") return;
ev.preventDefault();
trigger.click();
// The box only shows after the re-render; focus waits for that frame.
requestAnimationFrame(() => items[chosen].focus());
return;
}
ev.preventDefault();
const at = items.indexOf(document.activeElement as HTMLElement);
const last = items.length - 1;
let next = chosen;
if (ev.key === "Home") {
next = 0;
} else if (ev.key === "End") {
next = last;
} else if (at >= 0 && ev.key === "ArrowDown") {
next = at === last ? 0 : at + 1;
} else if (at >= 0 && ev.key === "ArrowUp") {
next = at === 0 ? last : at - 1;
}
items[next].focus();
};
</script>
<template>
<div class="segmented" :class="[sizeClasses, { auto }]">
<div
v-if="mode === 'pills' || auto"
class="segmented_pills"
role="group"
:aria-label="ariaLabel"
>
<button
v-for="option in options"
:key="option"
type="button"
class="pills_option"
:class="{ active: option === model }"
:aria-pressed="option === model"
@click="select(option)"
>
{{ option }}
</button>
</div>
<div
v-if="mode === 'dropdown' || auto"
ref="collapsed"
class="segmented_collapsed"
@keydown="onKeyDown"
>
<Popover side="bottom" align="end">
<template #trigger>
<button
type="button"
class="collapsed_trigger"
:aria-label="`${ariaLabel}: ${model}`"
>
{{ model }}
<PhCaretDown class="trigger_caret" weight="bold" />
</button>
</template>
<template #default="{ close }">
<ul class="collapsed_menu">
<li v-for="option in options" :key="option">
<button
type="button"
class="menu_item"
:class="{ active: option === model }"
:aria-pressed="option === model"
@click="select(option, close)"
>
{{ option }}
</button>
</li>
</ul>
</template>
</Popover>
</div>
</div>
</template>
<style scoped>
.segmented {
display: inline-block;
font-family: var(--font-body);
font-weight: var(--weight-500);
-webkit-tap-highlight-color: transparent;
/* Pill, trigger and menu item share one height, so collapsing never moves the row. */
&.xs {
--segmented-pill: var(--size-24);
--segmented-pill-touch: var(--size-28);
font-size: var(--text-14);
}
&.sm {
--segmented-pill: var(--size-28);
--segmented-pill-touch: var(--size-32);
font-size: var(--text-14);
}
&.md {
--segmented-pill: var(--size-32);
--segmented-pill-touch: var(--size-36);
font-size: var(--text-14);
}
&.xs_base {
--segmented-pill: var(--size-24);
--segmented-pill-touch: var(--size-28);
font-size: var(--text-14);
}
&.sm_base {
--segmented-pill: var(--size-28);
--segmented-pill-touch: var(--size-32);
font-size: var(--text-14);
}
&.md_base {
--segmented-pill: var(--size-32);
--segmented-pill-touch: var(--size-36);
font-size: var(--text-14);
}
@media (min-width: 480px) {
&.xs_mobile {
--segmented-pill: var(--size-24);
--segmented-pill-touch: var(--size-28);
font-size: var(--text-14);
}
&.sm_mobile {
--segmented-pill: var(--size-28);
--segmented-pill-touch: var(--size-32);
font-size: var(--text-14);
}
&.md_mobile {
--segmented-pill: var(--size-32);
--segmented-pill-touch: var(--size-36);
font-size: var(--text-14);
}
}
@media (min-width: 768px) {
&.xs_tablet {
--segmented-pill: var(--size-24);
--segmented-pill-touch: var(--size-28);
font-size: var(--text-14);
}
&.sm_tablet {
--segmented-pill: var(--size-28);
--segmented-pill-touch: var(--size-32);
font-size: var(--text-14);
}
&.md_tablet {
--segmented-pill: var(--size-32);
--segmented-pill-touch: var(--size-36);
font-size: var(--text-14);
}
}
@media (min-width: 1024px) {
&.xs_desktop {
--segmented-pill: var(--size-24);
--segmented-pill-touch: var(--size-28);
font-size: var(--text-14);
}
&.sm_desktop {
--segmented-pill: var(--size-28);
--segmented-pill-touch: var(--size-32);
font-size: var(--text-14);
}
&.md_desktop {
--segmented-pill: var(--size-32);
--segmented-pill-touch: var(--size-36);
font-size: var(--text-14);
}
}
@media (min-width: 1440px) {
&.xs_wide {
--segmented-pill: var(--size-24);
--segmented-pill-touch: var(--size-28);
font-size: var(--text-14);
}
&.sm_wide {
--segmented-pill: var(--size-28);
--segmented-pill-touch: var(--size-32);
font-size: var(--text-14);
}
&.md_wide {
--segmented-pill: var(--size-32);
--segmented-pill-touch: var(--size-36);
font-size: var(--text-14);
}
}
.segmented_pills {
display: inline-flex;
gap: var(--space-8);
}
.pills_option,
.collapsed_trigger {
border: var(--border-surface) solid var(--color-border-control);
}
.pills_option {
display: inline-flex;
align-items: center;
justify-content: center;
height: var(--segmented-pill);
padding: 0 calc(var(--segmented-pill) * 0.6);
border-radius: var(--radius-4);
background: none;
cursor: pointer;
font-family: inherit;
font-size: inherit;
font-weight: inherit;
/* The pill height owns the vertical rhythm; an inherited 1.5 would push the
text box past it. */
line-height: 1;
color: var(--color-text-secondary);
transition:
background 0.15s ease,
border-color 0.15s ease,
color 0.15s ease;
}
@media (hover: hover) {
.pills_option:not(.active):hover,
.collapsed_trigger:hover,
.menu_item:not(.active):hover {
background: color-mix(in srgb, var(--color-text) 8%, transparent);
color: var(--color-text);
}
}
.pills_option:not(.active):active,
.collapsed_trigger:active,
.menu_item:not(.active):active {
background: color-mix(in srgb, var(--color-text) 16%, transparent);
color: var(--color-text);
}
/* Offset 0: a negative offset would hide the ring under the selected item's fill. */
.menu_item:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 0;
}
.collapsed_trigger {
display: inline-flex;
align-items: center;
gap: var(--space-8);
height: var(--segmented-pill);
padding: 0 calc(var(--segmented-pill) * 0.6);
background: none;
border-radius: var(--radius-4);
cursor: pointer;
font-family: inherit;
font-size: inherit;
font-weight: inherit;
line-height: 1;
color: var(--color-text);
transition: background 0.15s ease;
.trigger_caret {
width: 1em;
height: 1em;
color: var(--color-text-secondary);
transition: transform 0.2s ease;
@media (prefers-reduced-motion: reduce) {
transition: none;
}
}
/* Popover injects aria-expanded, so the caret follows without state here. */
&[aria-expanded="true"] .trigger_caret {
transform: rotate(180deg);
}
}
.collapsed_menu {
display: flex;
flex-direction: column;
gap: var(--space-8);
}
.menu_item {
display: flex;
align-items: center;
width: 100%;
height: var(--segmented-pill);
padding: 0 calc(var(--segmented-pill) * 0.6);
border: 0;
border-radius: var(--radius-4);
background: none;
cursor: pointer;
font-family: inherit;
font-size: inherit;
font-weight: inherit;
line-height: 1;
color: var(--color-text-secondary);
transition:
background 0.15s ease,
color 0.15s ease;
}
/* After every part, so it wins over their heights at the same weight. */
@media (pointer: coarse) {
.pills_option,
.collapsed_trigger,
.menu_item {
height: var(--segmented-pill-touch);
padding: 0 calc(var(--segmented-pill-touch) * 0.6);
}
}
/* Last on purpose: same weight as .pills_option/.menu_item, so source order decides. */
.active {
background: var(--color-accent);
border-color: var(--color-accent);
color: var(--color-bg);
}
/* Container rules last so they win; with no container the viewport decides alone. */
&.auto {
.segmented_pills {
display: none;
@media (min-width: 480px) {
display: inline-flex;
}
@container (max-width: 479px) {
display: none;
}
}
.segmented_collapsed {
@media (min-width: 480px) {
display: none;
}
@container (max-width: 479px) {
display: block;
}
}
}
}
</style>
Props
| Prop | Type | Required | Description |
|---|---|---|---|
| options | string[] | required | Opciones del control; el texto es a la vez el valor. |
| size | "xs" | "sm" | "md" | ResponsiveSize | required | Cada opción mide 24, 28 o 32 de alto, y un peldaño más en pantallas táctiles. El texto va a 14 en las tres. Fijo o responsive. |
| mode | "pills" | "dropdown" | "auto" | required | Forma del control. Con auto empieza como pills y colapsa a desplegable por debajo de 480px de viewport (el breakpoint mobile), o cuando su contenedor baja de 480px (solo si el consumidor declara container-type). |
| aria-label | string | required | Nombre del control para lectores de pantalla. No se muestra, pero al colapsar el disparador solo enseña la opción elegida y nada dice de qué es. |
| value | string | required | Opción seleccionada (controlada). |
| onChange | (value: string) => void | required | Callback con la opción elegida. |