Éléments
ColorPicker
Un sélecteur de couleur détient une couleur, et les parties qu’il contient l’affichent et la modifient : une ColorArea de saturation et de luminosité, des ColorSlider pour n’importe quel canal de RGB, HSL, HSB ou OKLCH et pour l’alpha, des ColorField qui prennent la couleur sous forme de texte ou un canal sous forme de nombre, des ColorSwatch de couleurs prédéfinies et une ColorEyeDropper qui choisit une couleur n’importe où à l’écran. L’app dispose les parties avec ses propres éléments intercalés (une ligne par canal avec sa lettre, l’espace choisi avec Tabs, le sélecteur dans un Popover), et style chacune d’elles : les parties dessinent les couleurs des canaux, et chaque taille, rayon, bordure et ombre revient à l’app.
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())
}import { mount, signal, Text, View } from "nsh";
import { type ColorChannel, ColorArea, ColorField, ColorPicker, ColorSlider, ColorSwatch } from "nsh/base";
const thumb = "rounded-full border-2 border-white";
function Channel(props: { channel: ColorChannel; letter: string }) {
return (
<View class="flex-row items-center gap-2">
<Text class="w-[14px] text-sm text-muted-foreground">{props.letter}</Text>
<ColorSlider
colorSpace="oklch"
channel={props.channel}
accessibilityLabel={props.letter}
class="flex-1 h-[18px] justify-center px-2"
trackClass="h-[10px] rounded-full"
thumbClass={`size-4 ${thumb}`}
fillThumb
/>
<ColorField
colorSpace="oklch"
channel={props.channel}
accessibilityLabel={props.letter}
class="w-16 h-7 px-2 rounded-md border border-border text-sm text-right"
/>
</View>
);
}
function App() {
const primary = signal("oklch(54.6% 0.215 262.9)");
return (
<ColorPicker value={primary()} format="oklch" onChange={({ formatted }) => primary.set(formatted)} class="gap-3">
<ColorArea accessibilityLabel="Saturation and brightness" class="h-40" panelClass="rounded-lg" thumbClass={`size-[18px] ${thumb}`} fillThumb />
<Channel channel="lightness" letter="L" />
<Channel channel="chroma" letter="C" />
<Channel channel="hue" letter="H" />
<View class="flex-row items-center gap-2">
<ColorSwatch class="size-7 rounded-md" />
<Text class="font-mono text-sm">{`--primary: ${primary()}`}</Text>
</View>
</ColorPicker>
);
}
mount(App);ColorPicker, ColorArea, ColorSlider, ColorField, ColorSwatch et ColorEyeDropper proviennent de nsh/base, le crate nsh-base en Rust. En Rust, color_picker() et les fonctions de ses parties les construisent avec un setter par prop (.channel(..), .thumb_style(..)), une méthode d’écoute par événement (.on_change(..)) et les méthodes de style typées ; la couleur est un Hsla, les espaces, canaux et formats sont des ColorSpace, ColorChannel et ColorFormat.
#Valeur
La couleur est une couleur CSS : hexadécimal (#3b82f6, #3b82f680 avec son alpha), rgb(), hsl(), hsb(), oklch() ou un nom CSS ; en Rust, un Hsla. L’app la possède avec value et définit celle que onChange signale, comme pour tout contrôle de nsh/base ; ou bien elle laisse la couleur au sélecteur avec defaultValue et entend malgré tout les changements.
onChange({ value, formatted })signale chaque couleur que l’utilisateur demande : à chaque étape d’un glissement sur la zone ou sur un curseur, à chaque touche, au fil de la saisie du nombre d’un champ, à l’appui sur un échantillon.valueest la couleur,formattedla même couleur écrite comme l’indiqueformat("hex","rgb","hsl","hsb"ou"oklch"), prête à être enregistrée ou définie comme token.onChangeEnd({ value, formatted })signale la couleur à laquelle le changement s’est terminé, une seule fois : quand le pointeur se relève, après chaque touche, quand un champ est quitté ou validé, quand un échantillon est pressé.- Le sélecteur conserve la couleur dans chaque espace telle que l’utilisateur l’a définie. Un gris garde sa teinte et sa saturation, de sorte que le curseur de teinte reste là où l’utilisateur l’a laissé ; une couleur modifiée en OKLCH garde sa luminosité, sa chroma et sa teinte telles que saisies, y compris au-delà de ce que sRGB affiche (la couleur signalée est la plus proche que sRGB affiche, et
formatteddans"oklch"conserve les canaux). - Une
valuedéfinie par l’app et que le sélecteur n’a pas signalée remplace la couleur dans tous les espaces.
#Espaces et canaux
colorSpace | Canaux (channel) | Plages |
|---|---|---|
"rgb" | "red", "green", "blue" | 0 à 255 |
"hsl" | "hue", "saturation", "lightness" | teinte 0 à 360, les autres 0 à 100 |
"hsb" | "hue", "saturation", "brightness" | teinte 0 à 360, les autres 0 à 100 |
"oklch" | "lightness", "chroma", "hue" | luminosité 0 à 100 (par pas de 0,1), chroma 0 à 0,4 (par pas de 0,001), teinte 0 à 360 (par pas de 0,1) |
| tous | "alpha" | 0 à 100 |
La piste d’un curseur dessine les couleurs de son canal, les autres canaux restant fixes, afin de montrer où mène chaque valeur. Un canal que son espace n’a pas est remplacé par le premier canal de l’espace (un avertissement le signale).
#Props
#ColorPicker
| Prop | Rust | Type | Défaut | Description |
|---|---|---|---|---|
value | .value(..) | string | aucun | La couleur (contrôlée). L’app définit celle que onChange signale. |
defaultValue | .default_value(..) | string | "#000000" | La couleur initiale tant que value n’est pas définie ; le sélecteur garde alors la sienne. |
format | .format(..) | "hex" | "rgb" | "hsl" | "hsb" | "oklch" | "hex" | La façon dont formatted écrit la couleur. |
disabled | .disabled(..) | boolean | false | Aucun changement depuis aucune partie ; les classes disabled: s’appliquent au sélecteur et à ses parties. |
children | .child(..) | JSX.Element | aucun | Les parties et les éléments de l’app placés entre elles. |
class, style | méthodes de style | string, StyleProp<ViewStyle> | aucun | Le sélecteur lui-même : la colonne (ou la ligne) autour de ses parties. |
#ColorArea
| Prop | Rust | Type | Défaut | Description |
|---|---|---|---|---|
panelClass, panelStyle | .panel_style(|s| ..) | string, StyleProp<ViewStyle> | aucun | Le panneau, qui remplit la zone : les couleurs de la teinte, la saturation de gauche à droite (de droite à gauche dans une disposition de droite à gauche) et la luminosité de bas en haut, rognées à ses coins. |
thumbClass, thumbStyle | .thumb_style(|s| ..) | string, StyleProp<ViewStyle> | aucun | Le pouce, centré sur la couleur. |
fillThumb | .fill_thumb(..) | boolean | false | L’arrière-plan du pouce est la couleur. |
disabled | .disabled(..) | boolean | false | Aucun changement par pointeur, clavier ou lecteur d’écran. |
accessibilityLabel | .accessibility_label(..) | string | aucun | Ce que les lecteurs d’écran annoncent pour le pouce. |
class, style | méthodes de style | string, StyleProp<ViewStyle> | aucun | La zone : sa taille revient à l’app. |
#ColorSlider
| Prop | Rust | Type | Défaut | Description |
|---|---|---|---|---|
colorSpace | .color_space(..) | "rgb" | "hsl" | "hsb" | "oklch" | "hsb" | L’espace du canal. |
channel | .channel(..) | ColorChannel | "hue" | Le canal qu’il modifie : l’un de ceux de l’espace, ou "alpha". |
axis | .axis(..) | "horizontal" | "vertical" | "horizontal" | La direction de la piste : la plus petite valeur au début (à gauche, à droite dans une disposition de droite à gauche), ou en bas. Lue à la création du curseur. |
trackClass, trackStyle | .track_style(|s| ..) | string, StyleProp<ViewStyle> | aucun | La piste : les couleurs du canal le long de celle-ci, rognées à ses coins ; le pouce y est placé. |
thumbClass, thumbStyle | .thumb_style(|s| ..) | string, StyleProp<ViewStyle> | aucun | Le pouce, centré sur la valeur. |
fillThumb | .fill_thumb(..) | boolean | false | L’arrière-plan du pouce est la couleur (avec son alpha sur un curseur d’alpha). |
checkerboardColor | .checkerboard_color(..) | string | aucun | La couleur d’un damier sous la piste, qui transparaît à travers les couleurs d’un curseur d’alpha. |
checkerboardSize | .checkerboard_size(..) | number | 4 | Le côté des carrés du damier. |
disabled | .disabled(..) | boolean | false | Aucun changement par pointeur, clavier ou lecteur d’écran. |
accessibilityLabel | .accessibility_label(..) | string | aucun | Ce que les lecteurs d’écran annoncent pour le pouce. |
class, style | méthodes de style | string, StyleProp<ViewStyle> | aucun | Le curseur : la bande autour de la piste qui reçoit les appuis et les glissements. |
#ColorField
| Prop | Rust | Type | Défaut | Description |
|---|---|---|---|---|
channel | .channel(..) | ColorChannel | aucun | Le canal qu’il prend sous forme de nombre. Sans cette prop, le champ prend la couleur entière sous forme de texte. |
colorSpace | .color_space(..) | ColorSpace | "hsb" | L’espace de channel. |
format | .format(..) | ColorFormat | "hex" | La façon dont le champ écrit la couleur entière. Il lit toute forme CSS. |
placeholder | .placeholder(..) | string | aucun | Affiché tant que le champ est vide. |
disabled | .disabled(..) | boolean | false | Aucune saisie. |
accessibilityLabel | .accessibility_label(..) | string | aucun | Ce que les lecteurs d’écran annoncent pour le champ. |
class, style | méthodes de style | string, StyleProp<TextStyle> | aucun | Le champ, une zone de saisie de texte. |
Le champ d’un canal modifie la couleur au fil de la saisie d’un nombre, borné à la plage du canal ; le champ de la couleur entière la modifie quand il est quitté ou validé, si le texte se lit comme une couleur. Quitter un champ réécrit sa valeur dans sa forme.
#ColorSwatch
| Prop | Rust | Type | Défaut | Description |
|---|---|---|---|---|
color | .color(..) | string | aucun | La couleur qu’il montre ; un appui la choisit dans le sélecteur qui l’entoure. Sans cette prop, la couleur propre du sélecteur : un aperçu. |
selectedStyle | .selected_style(..) | StyleProp<ViewStyle> | aucun | Le style appliqué quand il porte la couleur du sélecteur, sous forme de classes selected:. |
checkerboardColor | .checkerboard_color(..) | string | aucun | Un damier sous une couleur translucide. |
checkerboardSize | .checkerboard_size(..) | number | 4 | Le côté de ses carrés. |
disabled | .disabled(..) | boolean | false | Aucun appui. |
accessibilityLabel | .accessibility_label(..) | string | la couleur en hexadécimal | Ce que les lecteurs d’écran annoncent. |
children | .child(..) | JSX.Element | aucun | Contenu par-dessus la couleur : une coche sur l’échantillon sélectionné (group-selected:). |
class, style | méthodes de style | string, StyleProp<ViewStyle> | aucun | L’échantillon : sa taille, sa forme et sa bordure ; ses coins rognent la couleur. |
#ColorEyeDropper
| Prop | Rust | Type | Défaut | Description |
|---|---|---|---|---|
disabled | .disabled(..) | boolean | false | Aucun appui. Il est aussi désactivé là où la plateforme n’a pas de pipette. |
accessibilityLabel | .accessibility_label(..) | string | aucun | Ce que les lecteurs d’écran annoncent. |
children | .child(..) | JSX.Element | aucun | Son contenu : l’icône ou le libellé de l’app. |
class, style | méthodes de style | string, StyleProp<ViewStyle> | aucun | Le bouton. |
Un appui affiche la pipette du système : le sélecteur de couleur d’AppKit sur macOS, EyeDropper du navigateur sur le web (Chrome et Edge). La couleur choisie devient celle du sélecteur (onChange, puis onChangeEnd) ; Escape n’en choisit aucune. Là où la plateforme n’a pas de pipette, le bouton est désactivé, et disabled:hidden le masque.
#Événements
| Événement | De | Rust | Charge utile | Quand |
|---|---|---|---|---|
onChange | ColorPicker | .on_change(|e: &ColorPickerEvent| ..) | ({ value: string, formatted: string }) | L’utilisateur a changé la couleur. |
onChangeEnd | ColorPicker | .on_change_end(|e: &ColorPickerEvent| ..) | ({ value: string, formatted: string }) | Le changement est terminé. Une fois par changement. |
onPress | ColorSwatch | .on_press(|color: &Hsla| ..) | ({ color: string }) | L’échantillon a été pressé ; il fonctionne aussi seul, comme pastille d’une palette. |
onPick | ColorEyeDropper | .on_pick(|color: &Hsla| ..) | ({ color: string }) | L’utilisateur a choisi une couleur à l’écran ; fonctionne aussi seul. |
En Rust, ColorPickerEvent::Change(Hsla, SharedString) et ColorPickerEvent::ChangeEnd(Hsla, SharedString) portent la couleur et son texte formatted.
#Parties
| Partie | Dans | Stylée avec |
|---|---|---|
ColorArea | le sélecteur | class, style |
| Le panneau de la zone | la zone, qu’il remplit | panelClass, panelStyle |
| Le pouce de la zone | la zone, centré sur la couleur | thumbClass, thumbStyle |
ColorSlider | le sélecteur | class, style |
| La piste du curseur | le curseur | trackClass, trackStyle |
| Le pouce du curseur | le curseur, centré sur la valeur sur la piste | thumbClass, thumbStyle |
ColorField | le sélecteur | class, style |
ColorSwatch | le sélecteur, ou seul | class, style, selectedStyle |
ColorEyeDropper | le sélecteur, ou seul | class, style |
Le pouce d’un curseur est centré sur les extrémités de la piste aux extrémités du canal. Un padding sur le curseur égal à la moitié de la largeur du pouce (h-6 justify-center px-2.5 autour d’une piste h-3) garde le pouce entier à l’intérieur du curseur, où il reçoit les appuis aux deux extrémités. États que montrent les parties : active: d’un pouce pendant qu’il est glissé et focus: avec le focus clavier, selected: d’un échantillon, et disabled: de chaque partie.
#Clavier
Sur macOS et le web, chaque pouce est un arrêt du focus clavier (Tab) :
| Touche | Sur un curseur | Sur la zone |
|---|---|---|
| Droite, Haut | Un pas en plus | Droite : plus de saturation ; Haut : plus de luminosité |
| Gauche, Bas | Un pas en moins | Gauche : moins de saturation ; Bas : moins de luminosité |
| Maj avec une flèche | Dix pas | Dix pas |
| Page Up, Page Down | Dix pas | Dix pas de luminosité |
| Début, Fin | Les extrémités du canal |
Dans une disposition de droite à gauche, un curseur horizontal et la zone vont à partir de la droite, la plus petite valeur (la moindre saturation) se trouvant à l’extrémité droite : Gauche est un pas en plus et Droite un pas en moins, et un appui ou un glissement choisit la valeur sous le pointeur telle qu’elle est dessinée. Un curseur vertical garde Droite et Haut comme un pas en plus ; Haut et Bas sur la zone gardent la luminosité.
Un échantillon réagit à Espace et Retour. Chaque touche signale onChange puis onChangeEnd ; les flèches se répètent tant qu’elles sont maintenues.
#Accessibilité
- Le pouce de la zone et celui de chaque curseur sont des éléments ajustables (
"adjustable", un slider dans le navigateur) avec leur libellé, leur valeur et leur plage ; l’incrémentation et la décrémentation des lecteurs d’écran les font avancer par pas. - Chaque champ est un champ de texte avec son libellé ; chaque échantillon est un bouton, sélectionné tant qu’il porte la couleur du sélecteur, nommé par sa couleur en hexadécimal sauf si
accessibilityLabelle nomme. La pipette est un bouton avec le libellé de l’app. - Lorsqu’elles sont désactivées, les lecteurs d’écran annoncent les parties comme grisées, et elles n’acceptent aucune action.
Voir Accessibilité pour les libellés, les rôles et les actions en général.
#Notes sur les plateformes
- Chaque plateforme dessine ce que l’app style ; les couleurs des pistes et du panneau sont des dégradés dessinés par la plateforme. Les appuis et les glissements sont suivis dans le cœur, pour une souris, un stylet ou un doigt, et une vue défilante autour du sélecteur conserve son défilement pour les glissements qui commencent ailleurs.
ColorEyeDropperchoisit avec le sélecteur de couleur d’AppKit sur macOS etEyeDropperdu navigateur sur le web ; la couleur arrive en sRGB.