Esta página se tradujo automáticamente y puede contener errores. Leer el original en inglés

Elementos

ColorPicker

Un color picker guarda un color, y las partes que contiene lo muestran y lo cambian: un ColorArea de saturación y brillo, ColorSliders de cualquier canal de RGB, HSL, HSB u OKLCH y del alfa, ColorFields que toman el color como texto o un canal como número, ColorSwatches de colores predefinidos y un ColorEyeDropper que elige un color en cualquier punto de la pantalla. La app dispone las partes con sus propios elementos intercalados (una fila por canal con su letra, el espacio elegido con Tabs, el picker dentro de un Popover) y da estilo a cada una: las partes dibujan los colores de los canales, y cada tamaño, radio, borde y sombra es de la app.

el-color-picker.tsx
el-color-picker.tsxLive
import { mount, signal, Text, View } from "nsh";
import { type ColorChannel, ColorArea, ColorEyeDropper, ColorField, ColorPicker, ColorSlider, type ColorSpace, ColorSwatch } from "nsh/base";
import { desktop, field, Icon, Log, Segmented, Split, ui } from "./palette";

// `ColorPicker` holds the color; its parts, laid out and styled here, read
// and change it: the saturation and brightness area, the hue and alpha
// sliders, a row per channel of the space picked (its slider and its
// number), the color typed in any CSS form, the browser's eyedropper where
// it has one, and a palette of swatches.
const thumb = "rounded-full border-2 border-white";
const thumbStyle = { boxShadow: "0 0 0 1px rgba(0,0,0,0.25), 0 1px 3px rgba(0,0,0,0.3)" };
const spaces = ["rgb", "hsl", "hsb", "oklch"] as const;
const channels: Record<ColorSpace, [ColorChannel, string][]> = {
  rgb: [["red", "R"], ["green", "G"], ["blue", "B"]],
  hsl: [["hue", "H"], ["saturation", "S"], ["lightness", "L"]],
  hsb: [["hue", "H"], ["saturation", "S"], ["brightness", "B"]],
  oklch: [["lightness", "L"], ["chroma", "C"], ["hue", "H"]],
};
const palette = ["#ef4444", "#f97316", "#eab308", "#22c55e", "#06b6d4", "#3b82f6", "#8b5cf6", "#ec4899", "#0f172a80"];

/** A track `h` points high, half a thumb free at each end for the thumb. */
const track = (h: number) => ({
  class: `h-[${h + 8}px] justify-center px-[${(h + 6) / 2}px]`,
  trackClass: `h-[${h}px] rounded-full`,
  thumbClass: `w-[${h + 6}px] h-[${h + 6}px] ${thumb}`,
  thumbStyle,
  fillThumb: true,
});

function App() {
  const color = signal("#3b82f6ff");
  const text = signal("#3b82f6");
  const space = signal<ColorSpace>("oklch");
  const events = signal<string[]>([]);
  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>color picker</Text>
      <Text style={{ color: ui().muted }}>
        {desktop ? "Drag the area or a slider, type a color, or pick a swatch" : "Drag the area or a slider, or tap a swatch"}
      </Text>
      <Split
        gap={14}
        inspector={
<Log title="onChangeEnd({ value, formatted })" lines={events()} minHeight={92} />
        }
      >
        <View style={{ ...ui().card, borderRadius: 16, padding: 16 }}>
          <ColorPicker
            value={color()}
            onChange={({ value, formatted }) => {
              color.set(value);
              text.set(formatted);
            }}
            onChangeEnd={({ formatted }) => events.set([formatted, ...events()].slice(0, 5))}
            style={{ gap: 12 }}
          >
            <ColorArea
              accessibilityLabel="Saturation and brightness"
              style={{ height: desktop ? 160 : 140 }}
              panelClass="rounded-lg"
              thumbClass={`w-[18px] h-[18px] ${thumb}`}
              thumbStyle={thumbStyle}
              fillThumb
            />
            <View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
              <ColorSwatch accessibilityLabel="Current color" style={{ width: 36, height: 36, borderRadius: 18 }} checkerboardColor="#d4d4d8" />
              <View style={{ flex: 1, gap: 8 }}>
                <ColorSlider colorSpace="hsb" channel="hue" accessibilityLabel="Hue" {...track(12)} />
                <ColorSlider channel="alpha" accessibilityLabel="Alpha" checkerboardColor="#d4d4d8" {...track(12)} />
              </View>
            </View>
            <Text style={{ fontSize: 13, color: ui().muted, fontVariant: ["tabular-nums"] }}>{text()}</Text>
            <Segmented options={spaces} value={space()} onChange={(next) => space.set(next)} />
            {() =>
              channels[space()].map(([channel, letter]) => (
                <View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
                  <Text style={{ width: 14, fontSize: 13, color: ui().muted }}>{letter}</Text>
                  <ColorSlider colorSpace={space()} channel={channel} accessibilityLabel={`${space()} ${channel}`} style={{ flex: 1 }} {...track(10)} />
                  <ColorField
                    colorSpace={space()}
                    channel={channel}
                    accessibilityLabel={`${space()} ${channel} value`}
                    {...field({ style: { width: 64, textAlign: "right" } })}
                  />
                </View>
              ))
            }
            <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
              <ColorField
                accessibilityLabel="Color"
                placeholder="#rrggbb, rgb(), hsl(), oklch() or a name"
                {...field({ style: { flex: 1 } })}
              />
              <ColorEyeDropper
                accessibilityLabel="Pick a color from the screen"
                class="disabled:hidden w-9 h-9 items-center justify-center rounded-md border border-border active:opacity-70"
              >
                <Icon name="pipette" size={16} tint={ui().text} />
              </ColorEyeDropper>
            </View>
            <View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8 }}>
              {palette.map((c) => (
                <ColorSwatch
                  color={c}
                  style={{ width: 24, height: 24, borderRadius: 6 }}
                  selectedStyle={{ borderWidth: 2, borderColor: ui().text }}
                  checkerboardColor="#d4d4d8"
                  checkerboardSize={6}
                />
              ))}
            </View>
          </ColorPicker>
        </View>
      </Split>
    </View>
  );
}

mount(App);
use nsh_base::{
    ColorChannel, ColorPickerEvent, ColorSpace, color_area, color_field, color_picker,
    color_slider, color_swatch, theme,
};
use nsh_core::prelude::*;

fn primary(color: Signal<Hsla>) -> impl IntoElement {
    // A channel of OKLCH: its letter, its slider, its number.
    let channel = move |channel: ColorChannel, letter: &'static str| {
        view()
            .flex_row()
            .items_center()
            .gap_2()
            .child(text(letter).w(px(14.0)).text_sm().text_color(move || theme().muted_foreground))
            .child(
                color_slider()
                    .color_space(ColorSpace::Oklch)
                    .channel(channel)
                    .accessibility_label(letter)
                    .flex_1()
                    .h(px(18.0))
                    .justify_center()
                    .px(px(8.0))
                    .track_style(|s| s.h(px(10.0)).rounded_full())
                    .thumb_style(|s| s.size(px(16.0)).rounded_full().border_2().border_color(white()))
                    .fill_thumb(true),
            )
            .child(
                color_field()
                    .color_space(ColorSpace::Oklch)
                    .channel(channel)
                    .accessibility_label(letter)
                    .w(px(64.0))
                    .h(px(28.0))
                    .px_2()
                    .rounded_md()
                    .border_1()
                    .border_color(move || theme().border)
                    .text_sm(),
            )
    };
    color_picker()
        .value(move || Some(color.get()))
        .on_change(move |e: &ColorPickerEvent| {
            if let ColorPickerEvent::Change(value, _) = e {
                color.set(*value);
            }
        })
        .gap_3()
        .child(
            color_area()
                .accessibility_label("Saturation and brightness")
                .h(px(160.0))
                .panel_style(|s| s.rounded_lg())
                .thumb_style(|s| s.size(px(18.0)).rounded_full().border_2().border_color(white()))
                .fill_thumb(true),
        )
        .child(channel(ColorChannel::Lightness, "L"))
        .child(channel(ColorChannel::Chroma, "C"))
        .child(channel(ColorChannel::Hue, "H"))
        .child(color_swatch().size(px(28.0)).rounded_md())
}

ColorPicker, ColorArea, ColorSlider, ColorField, ColorSwatch y ColorEyeDropper provienen de nsh/base, el crate nsh-base de Rust. En Rust, color_picker() y las funciones de sus partes los crean con un setter por prop (.channel(..), .thumb_style(..)), un método de escucha por evento (.on_change(..)) y los métodos de estilo tipados; el color es un Hsla, y los espacios, canales y formatos son ColorSpace, ColorChannel y ColorFormat.

#Valor

El color es un color CSS: hex (#3b82f6, #3b82f680 con su alfa), rgb(), hsl(), hsb(), oklch() o un nombre CSS; en Rust, un Hsla. La app lo posee con value y establece el que informa onChange, como en cada control de nsh/base; o bien deja el color en manos del picker con defaultValue y aun así recibe los cambios.

  • onChange({ value, formatted }) informa de cada color que pide el usuario: en cada paso de un arrastre sobre el área o un slider, con cada tecla, con el número de un campo a medida que se escribe, al pulsar un swatch. value es el color, formatted el mismo color escrito como indica format ("hex", "rgb", "hsl", "hsb" o "oklch"), listo para guardar o para usar como token.
  • onChangeEnd({ value, formatted }) informa una sola vez del color en el que terminó el cambio: cuando se levanta el puntero, después de cada tecla, cuando se sale de un campo o se envía, cuando se pulsa un swatch.
  • El picker conserva el color en cada espacio tal como lo fijó el usuario. Un gris conserva su matiz y su saturación, de modo que el slider de matiz se queda donde el usuario lo dejó; un color editado en OKLCH conserva su luminosidad, croma y matiz tal como se escribieron, también más allá de lo que muestra sRGB (el color informado es el más cercano que muestra sRGB, y formatted en "oklch" conserva los canales).
  • Un value que establece la app y que el picker no informó reemplaza el color en todos los espacios.

#Espacios y canales

colorSpace Canales (channel) Rangos
"rgb" "red", "green", "blue" 0 a 255
"hsl" "hue", "saturation", "lightness" matiz 0 a 360, los demás 0 a 100
"hsb" "hue", "saturation", "brightness" matiz 0 a 360, los demás 0 a 100
"oklch" "lightness", "chroma", "hue" luminosidad 0 a 100 (de 0.1 en 0.1), croma 0 a 0.4 (de 0.001 en 0.001), matiz 0 a 360 (de 0.1 en 0.1)
cualquiera "alpha" 0 a 100

La pista de un slider dibuja los colores de su canal manteniendo fijos los demás canales, de modo que muestra adónde lleva cada valor. Un canal que su espacio no tiene se sustituye por el primero del espacio (una advertencia lo indica).

#Props

#ColorPicker

Prop Rust Tipo Predeterminado Descripción
value .value(..) string ninguno El color (controlado). La app establece el que informa onChange.
defaultValue .default_value(..) string "#000000" El color inicial mientras value no esté establecido; el picker conserva entonces el suyo.
format .format(..) "hex" | "rgb" | "hsl" | "hsb" | "oklch" "hex" Cómo escribe el color formatted.
disabled .disabled(..) boolean false Sin cambios desde ninguna parte; las clases disabled: se aplican al picker y a sus partes.
children .child(..) JSX.Element ninguno Las partes y los elementos de la app entre ellas.
class, style métodos de estilo string, StyleProp<ViewStyle> ninguno El propio picker: la columna (o fila) que rodea sus partes.

#ColorArea

Prop Rust Tipo Predeterminado Descripción
panelClass, panelStyle .panel_style(|s| ..) string, StyleProp<ViewStyle> ninguno El panel, que llena el área: los colores del matiz, con la saturación de izquierda a derecha (de derecha a izquierda en un diseño de derecha a izquierda) y el brillo de abajo hacia arriba, recortado en sus esquinas.
thumbClass, thumbStyle .thumb_style(|s| ..) string, StyleProp<ViewStyle> ninguno El thumb, centrado en el color.
fillThumb .fill_thumb(..) boolean false El fondo del thumb es el color.
disabled .disabled(..) boolean false Sin cambios por puntero, teclado ni lector de pantalla.
accessibilityLabel .accessibility_label(..) string ninguno Lo que dicen los lectores de pantalla para el thumb.
class, style métodos de estilo string, StyleProp<ViewStyle> ninguno El área: su tamaño lo define la app.

#ColorSlider

Prop Rust Tipo Predeterminado Descripción
colorSpace .color_space(..) "rgb" | "hsl" | "hsb" | "oklch" "hsb" El espacio del canal.
channel .channel(..) ColorChannel "hue" El canal que cambia: uno de los del espacio, o "alpha".
axis .axis(..) "horizontal" | "vertical" "horizontal" La dirección de la pista: el valor más pequeño al inicio (la izquierda, la derecha en un diseño de derecha a izquierda), o abajo. Se lee cuando se crea el slider.
trackClass, trackStyle .track_style(|s| ..) string, StyleProp<ViewStyle> ninguno La pista: los colores del canal a lo largo de ella, recortados en sus esquinas; el thumb se coloca sobre ella.
thumbClass, thumbStyle .thumb_style(|s| ..) string, StyleProp<ViewStyle> ninguno El thumb, centrado en el valor.
fillThumb .fill_thumb(..) boolean false El fondo del thumb es el color (con su alfa en un slider de alfa).
checkerboardColor .checkerboard_color(..) string ninguno El color de un tablero de ajedrez bajo la pista, que se transparenta a través de los colores de un slider de alfa.
checkerboardSize .checkerboard_size(..) number 4 El lado de los cuadros del tablero.
disabled .disabled(..) boolean false Sin cambios por puntero, teclado ni lector de pantalla.
accessibilityLabel .accessibility_label(..) string ninguno Lo que dicen los lectores de pantalla para el thumb.
class, style métodos de estilo string, StyleProp<ViewStyle> ninguno El slider: la banda alrededor de la pista que recibe las pulsaciones y los arrastres.

#ColorField

Prop Rust Tipo Predeterminado Descripción
channel .channel(..) ColorChannel ninguno El canal que toma como número. Sin ella, el campo toma el color completo como texto.
colorSpace .color_space(..) ColorSpace "hsb" El espacio de channel.
format .format(..) ColorFormat "hex" Cómo escribe el campo el color completo. Lee cualquier forma CSS.
placeholder .placeholder(..) string ninguno Se muestra mientras el campo está vacío.
disabled .disabled(..) boolean false Sin escritura.
accessibilityLabel .accessibility_label(..) string ninguno Lo que dicen los lectores de pantalla para el campo.
class, style métodos de estilo string, StyleProp<TextStyle> ninguno El campo, un input de texto.

El campo de un canal cambia el color a medida que se escribe un número, limitado al rango del canal; el campo del color completo lo cambia cuando se sale del campo o se envía, si el texto se lee como un color. Al salir de un campo, su valor se vuelve a escribir en su formato.

#ColorSwatch

Prop Rust Tipo Predeterminado Descripción
color .color(..) string ninguno El color que muestra; una pulsación lo elige en el picker que lo rodea. Sin ella, el color del propio picker: una vista previa.
selectedStyle .selected_style(..) StyleProp<ViewStyle> ninguno El estilo mientras es el color del picker, como clases selected:.
checkerboardColor .checkerboard_color(..) string ninguno Un tablero de ajedrez bajo un color translúcido.
checkerboardSize .checkerboard_size(..) number 4 El lado de sus cuadros.
disabled .disabled(..) boolean false Sin pulsaciones.
accessibilityLabel .accessibility_label(..) string el color en hex Lo que dicen los lectores de pantalla.
children .child(..) JSX.Element ninguno Contenido sobre el color: una marca de verificación en el swatch seleccionado (group-selected:).
class, style métodos de estilo string, StyleProp<ViewStyle> ninguno El swatch: su tamaño, forma y borde; sus esquinas recortan el color.

#ColorEyeDropper

Prop Rust Tipo Predeterminado Descripción
disabled .disabled(..) boolean false Sin pulsaciones. También está deshabilitado donde la plataforma no tiene cuentagotas.
accessibilityLabel .accessibility_label(..) string ninguno Lo que dicen los lectores de pantalla.
children .child(..) JSX.Element ninguno Su contenido: el icono o la etiqueta de la app.
class, style métodos de estilo string, StyleProp<ViewStyle> ninguno El botón.

Una pulsación muestra el cuentagotas del sistema: el color sampler de AppKit en macOS, el EyeDropper del navegador en la web (Chrome y Edge). El color elegido pasa a ser el del picker (onChange, luego onChangeEnd); Escape no elige ninguno. Donde la plataforma no tiene cuentagotas el botón está deshabilitado, y disabled:hidden lo oculta allí.

#Eventos

Evento De Rust Carga Cuándo
onChange ColorPicker .on_change(|e: &ColorPickerEvent| ..) ({ value: string, formatted: string }) El usuario cambió el color.
onChangeEnd ColorPicker .on_change_end(|e: &ColorPickerEvent| ..) ({ value: string, formatted: string }) El cambio terminó. Una vez por cambio.
onPress ColorSwatch .on_press(|color: &Hsla| ..) ({ color: string }) Se pulsó el swatch; funciona también solo, como chip de una paleta.
onPick ColorEyeDropper .on_pick(|color: &Hsla| ..) ({ color: string }) El usuario eligió un color en la pantalla; funciona también solo.

En Rust, ColorPickerEvent::Change(Hsla, SharedString) y ColorPickerEvent::ChangeEnd(Hsla, SharedString) llevan el color y su texto formatted.

#Partes

Parte Dentro de Estilizada con
ColorArea el picker class, style
El panel del área el área, llenándola panelClass, panelStyle
El thumb del área el área, centrado en el color thumbClass, thumbStyle
ColorSlider el picker class, style
La pista del slider el slider trackClass, trackStyle
El thumb del slider el slider, centrado en el valor sobre la pista thumbClass, thumbStyle
ColorField el picker class, style
ColorSwatch el picker, o solo class, style, selectedStyle
ColorEyeDropper el picker, o solo class, style

El thumb de un slider se centra en los extremos de la pista cuando el canal está en sus extremos. Un padding en el slider de la mitad del ancho del thumb (h-6 justify-center px-2.5 alrededor de una pista h-3) mantiene todo el thumb dentro del slider, donde recibe las pulsaciones en ambos extremos. Estados que muestran las partes: el active: de un thumb mientras se arrastra y focus: con el foco del teclado, el selected: de un swatch y el disabled: de cada parte.

#Teclado

En macOS y en la web cada thumb es una parada del foco del teclado (Tab):

Tecla En un slider En el área
Derecha, Arriba Un paso hacia arriba Derecha: más saturación; Arriba: más brillo
Izquierda, Abajo Un paso hacia abajo Izquierda: menos saturación; Abajo: menos brillo
Mayús con una flecha Diez pasos Diez pasos
Page Up, Page Down Diez pasos Diez pasos de brillo
Home, End Los extremos del canal

En un diseño de derecha a izquierda, un slider horizontal y el área corren desde la derecha, con el valor más pequeño (la menor saturación) en el extremo derecho: Izquierda es un paso hacia arriba y Derecha un paso hacia abajo, y una pulsación o un arrastre elige el valor que hay bajo el puntero tal como está dibujado. Un slider vertical mantiene Derecha y Arriba como un paso hacia arriba; Arriba y Abajo en el área mantienen el brillo.

Un swatch acepta Espacio y Retorno. Cada tecla informa onChange y luego onChangeEnd; las flechas se repiten mientras se mantienen pulsadas.

#Accesibilidad

  • El thumb del área y el de cada slider son elementos ajustables ("adjustable", un slider en el navegador) con su etiqueta, valor y rango; el incremento y el decremento de los lectores de pantalla los mueven paso a paso.
  • Cada campo es un campo de texto con su etiqueta; cada swatch, un botón, seleccionado mientras es el color del picker, nombrado por su color en hex a menos que accessibilityLabel le dé nombre. El cuentagotas es un botón con la etiqueta de la app.
  • Mientras están deshabilitadas, los lectores de pantalla indican que las partes están atenuadas, y no aceptan acciones.

Consulta Accesibilidad para ver etiquetas, roles y acciones en general.

#Notas de plataforma

  • Cada plataforma dibuja lo que la app estiliza; los colores de las pistas y del panel son degradados dibujados por la plataforma. Las pulsaciones y los arrastres se siguen en el núcleo, para un ratón, un lápiz o un dedo, y una scroll view alrededor del picker limita su desplazamiento a los arrastres que empiezan en otro lugar.
  • ColorEyeDropper elige con el color sampler de AppKit en macOS y con el EyeDropper del navegador en la web; el color llega en sRGB.