Elementi
ColorPicker
Un color picker contiene un colore, e le parti al suo interno lo mostrano e lo modificano: un ColorArea di saturazione e luminosità, ColorSlider per qualsiasi canale di RGB, HSL, HSB o OKLCH e per l’alpha, ColorField che accettano il colore come testo o un canale come numero, ColorSwatch di colori predefiniti e un ColorEyeDropper che preleva un colore ovunque sullo schermo. L’app dispone le parti con i propri elementi frapposti (una riga per canale con la sua lettera, lo spazio scelto con Tabs, il picker in un Popover) e ne definisce lo stile: le parti disegnano i colori dei canali, mentre ogni dimensione, raggio, bordo e ombra è a cura dell’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 e ColorEyeDropper provengono da nsh/base,
il crate nsh-base in Rust. In Rust, color_picker() e le funzioni delle sue parti li creano con un setter per ogni prop
(.channel(..), .thumb_style(..)), un metodo listener per ogni evento (.on_change(..)) e i metodi di stile tipizzati; il
colore è un Hsla, mentre spazi, canali e formati sono ColorSpace, ColorChannel e ColorFormat.
#Valore
Il colore è un colore CSS: esadecimale (#3b82f6, #3b82f680 con il suo alpha), rgb(), hsl(), hsb(), oklch() o un nome CSS; in Rust un Hsla. L’app lo gestisce con value e imposta quello che onChange segnala, come per ogni controllo di
nsh/base; oppure lascia il colore al picker con defaultValue e ascolta comunque le modifiche.
onChange({ value, formatted })segnala ogni colore richiesto dall’utente: a ogni passo di un trascinamento sull’area o su uno slider, a ogni tasto, il numero di un campo mentre viene digitato, una campione premuto.valueè il colore,formattedlo stesso colore scritto come indicato daformat("hex","rgb","hsl","hsb"o"oklch"), pronto per essere salvato o impostato come token.onChangeEnd({ value, formatted })segnala il colore a cui la modifica è terminata, una sola volta: quando il puntatore si solleva, dopo ogni tasto, quando si esce da un campo o lo si invia, quando si preme un campione.- Il picker conserva il colore in ogni spazio così come l’utente lo ha impostato. Un grigio mantiene la sua tonalità e saturazione, così lo slider della tonalità
resta dove l’utente lo ha lasciato; un colore modificato in OKLCH mantiene luminosità, croma e tonalità come digitati, anche oltre ciò che
sRGB mostra (il colore segnalato è il più vicino tra quelli che sRGB mostra, e
formattedin"oklch"conserva i canali). - Un
valueimpostato dall’app e non segnalato dal picker sostituisce il colore in ogni spazio.
#Spazi e canali
colorSpace | Canali (channel) | Intervalli |
|---|---|---|
"rgb" | "red", "green", "blue" | da 0 a 255 |
"hsl" | "hue", "saturation", "lightness" | tonalità da 0 a 360, gli altri da 0 a 100 |
"hsb" | "hue", "saturation", "brightness" | tonalità da 0 a 360, gli altri da 0 a 100 |
"oklch" | "lightness", "chroma", "hue" | luminosità da 0 a 100 (a passi di 0,1), croma da 0 a 0,4 (a passi di 0,001), tonalità da 0 a 360 (a passi di 0,1) |
| qualsiasi | "alpha" | da 0 a 100 |
La traccia di uno slider disegna i colori del suo canale tenendo fermi gli altri canali, così mostra dove porta ogni valore. Un canale che il suo spazio non possiede viene sostituito dal primo canale dello spazio (un avviso lo segnala).
#Props
#ColorPicker
| Prop | Rust | Tipo | Predefinito | Descrizione |
|---|---|---|---|---|
value | .value(..) | string | nessuno | Il colore (controllato). L’app imposta quello che onChange segnala. |
defaultValue | .default_value(..) | string | "#000000" | Il colore iniziale finché value non è impostato; il picker poi mantiene il proprio. |
format | .format(..) | "hex" | "rgb" | "hsl" | "hsb" | "oklch" | "hex" | Come formatted scrive il colore. |
disabled | .disabled(..) | boolean | false | Nessuna modifica da alcuna parte; le classi disabled: si applicano al picker e alle sue parti. |
children | .child(..) | JSX.Element | nessuno | Le parti e gli elementi dell’app tra di esse. |
class, style | metodi di stile | string, StyleProp<ViewStyle> | nessuno | Il picker stesso: la colonna (o riga) attorno alle sue parti. |
#ColorArea
| Prop | Rust | Tipo | Predefinito | Descrizione |
|---|---|---|---|---|
panelClass, panelStyle | .panel_style(|s| ..) | string, StyleProp<ViewStyle> | nessuno | Il pannello, che riempie l’area: i colori della tonalità, la saturazione da sinistra a destra (da destra a sinistra in un layout da destra a sinistra) e la luminosità dal basso verso l’alto, ritagliati agli angoli. |
thumbClass, thumbStyle | .thumb_style(|s| ..) | string, StyleProp<ViewStyle> | nessuno | Il thumb, centrato sul colore. |
fillThumb | .fill_thumb(..) | boolean | false | Lo sfondo del thumb è il colore. |
disabled | .disabled(..) | boolean | false | Nessuna modifica da puntatore, tastiera o screen reader. |
accessibilityLabel | .accessibility_label(..) | string | nessuno | Ciò che gli screen reader dicono per il thumb. |
class, style | metodi di stile | string, StyleProp<ViewStyle> | nessuno | L’area: la sua dimensione spetta all’app. |
#ColorSlider
| Prop | Rust | Tipo | Predefinito | Descrizione |
|---|---|---|---|---|
colorSpace | .color_space(..) | "rgb" | "hsl" | "hsb" | "oklch" | "hsb" | Lo spazio del canale. |
channel | .channel(..) | ColorChannel | "hue" | Il canale che modifica: uno di quelli dello spazio, oppure "alpha". |
axis | .axis(..) | "horizontal" | "vertical" | "horizontal" | La direzione della traccia: il valore minimo all’inizio (a sinistra, a destra in un layout da destra a sinistra) o in basso. Letta quando lo slider viene creato. |
trackClass, trackStyle | .track_style(|s| ..) | string, StyleProp<ViewStyle> | nessuno | La traccia: i colori del canale lungo di essa, ritagliati agli angoli; il thumb è posizionato su di essa. |
thumbClass, thumbStyle | .thumb_style(|s| ..) | string, StyleProp<ViewStyle> | nessuno | Il thumb, centrato sul valore. |
fillThumb | .fill_thumb(..) | boolean | false | Lo sfondo del thumb è il colore (con il suo alpha in uno slider alpha). |
checkerboardColor | .checkerboard_color(..) | string | nessuno | Il colore di una scacchiera sotto la traccia, che traspare attraverso i colori di uno slider alpha. |
checkerboardSize | .checkerboard_size(..) | number | 4 | Il lato dei quadrati della scacchiera. |
disabled | .disabled(..) | boolean | false | Nessuna modifica da puntatore, tastiera o screen reader. |
accessibilityLabel | .accessibility_label(..) | string | nessuno | Ciò che gli screen reader dicono per il thumb. |
class, style | metodi di stile | string, StyleProp<ViewStyle> | nessuno | Lo slider: la fascia attorno alla traccia che riceve pressioni e trascinamenti. |
#ColorField
| Prop | Rust | Tipo | Predefinito | Descrizione |
|---|---|---|---|---|
channel | .channel(..) | ColorChannel | nessuno | Il canale che accetta come numero. Senza di esso, il campo accetta l’intero colore come testo. |
colorSpace | .color_space(..) | ColorSpace | "hsb" | Lo spazio di channel. |
format | .format(..) | ColorFormat | "hex" | Come il campo scrive l’intero colore. Legge qualsiasi forma CSS. |
placeholder | .placeholder(..) | string | nessuno | Mostrato mentre il campo è vuoto. |
disabled | .disabled(..) | boolean | false | Nessuna digitazione. |
accessibilityLabel | .accessibility_label(..) | string | nessuno | Ciò che gli screen reader dicono per il campo. |
class, style | metodi di stile | string, StyleProp<TextStyle> | nessuno | Il campo, un input di testo. |
Il campo di un canale modifica il colore mentre si digita un numero, limitato all’intervallo del canale; il campo dell’intero colore lo modifica quando si esce dal campo o lo si invia, se il testo è leggibile come colore. Uscendo da un campo, il suo valore viene riscritto nella sua forma.
#ColorSwatch
| Prop | Rust | Tipo | Predefinito | Descrizione |
|---|---|---|---|---|
color | .color(..) | string | nessuno | Il colore che mostra; una pressione lo sceglie nel picker che lo contiene. Senza di esso, il colore del picker stesso: un’anteprima. |
selectedStyle | .selected_style(..) | StyleProp<ViewStyle> | nessuno | Lo stile mentre è il colore del picker, come classi selected:. |
checkerboardColor | .checkerboard_color(..) | string | nessuno | Una scacchiera sotto un colore traslucido. |
checkerboardSize | .checkerboard_size(..) | number | 4 | Il lato dei suoi quadrati. |
disabled | .disabled(..) | boolean | false | Nessuna pressione. |
accessibilityLabel | .accessibility_label(..) | string | il colore in esadecimale | Ciò che gli screen reader dicono. |
children | .child(..) | JSX.Element | nessuno | Contenuto sopra il colore: un segno di spunta sul campione selezionato (group-selected:). |
class, style | metodi di stile | string, StyleProp<ViewStyle> | nessuno | Il campione: dimensione, forma e bordo; i suoi angoli ritagliano il colore. |
#ColorEyeDropper
| Prop | Rust | Tipo | Predefinito | Descrizione |
|---|---|---|---|---|
disabled | .disabled(..) | boolean | false | Nessuna pressione. È disabilitato anche dove la piattaforma non ha un contagocce. |
accessibilityLabel | .accessibility_label(..) | string | nessuno | Ciò che gli screen reader dicono. |
children | .child(..) | JSX.Element | nessuno | Il suo contenuto: l’icona o l’etichetta dell’app. |
class, style | metodi di stile | string, StyleProp<ViewStyle> | nessuno | Il pulsante. |
Una pressione mostra il contagocce del sistema: il color sampler di AppKit su macOS, EyeDropper del browser sul web (Chrome
ed Edge). Il colore prelevato diventa quello del picker (onChange, poi onChangeEnd); Escape non preleva nulla. Dove la piattaforma
non ha un contagocce il pulsante è disabilitato, e disabled:hidden lo nasconde in quel caso.
#Eventi
| Evento | Di | Rust | Payload | Quando |
|---|---|---|---|---|
onChange | ColorPicker | .on_change(|e: &ColorPickerEvent| ..) | ({ value: string, formatted: string }) | L’utente ha modificato il colore. |
onChangeEnd | ColorPicker | .on_change_end(|e: &ColorPickerEvent| ..) | ({ value: string, formatted: string }) | La modifica è terminata. Una volta per modifica. |
onPress | ColorSwatch | .on_press(|color: &Hsla| ..) | ({ color: string }) | Il campione è stato premuto; funziona anche da solo, come chip di una palette. |
onPick | ColorEyeDropper | .on_pick(|color: &Hsla| ..) | ({ color: string }) | L’utente ha prelevato un colore sullo schermo; funziona anche da solo. |
In Rust, ColorPickerEvent::Change(Hsla, SharedString) e ColorPickerEvent::ChangeEnd(Hsla, SharedString) contengono il
colore e il suo testo formatted.
#Parti
| Parte | Dentro | Stile con |
|---|---|---|
ColorArea | il picker | class, style |
| Il pannello dell’area | l’area, che riempie | panelClass, panelStyle |
| Il thumb dell’area | l’area, centrato sul colore | thumbClass, thumbStyle |
ColorSlider | il picker | class, style |
| La traccia dello slider | lo slider | trackClass, trackStyle |
| Il thumb dello slider | lo slider, centrato sul valore sulla traccia | thumbClass, thumbStyle |
ColorField | il picker | class, style |
ColorSwatch | il picker, oppure da solo | class, style, selectedStyle |
ColorEyeDropper | il picker, oppure da solo | class, style |
Il thumb di uno slider è centrato sulle estremità della traccia in corrispondenza delle estremità del canale. Un padding sullo slider pari a metà della larghezza del thumb
(h-6 justify-center px-2.5 attorno a una traccia h-3) mantiene l’intero thumb dentro lo slider, dove riceve
le pressioni a entrambe le estremità. Gli stati mostrati dalle parti: active: di un thumb mentre viene trascinato e focus: con il
focus da tastiera, selected: di un campione, e disabled: di ogni parte.
#Tastiera
Su macOS e sul web ogni thumb è una tappa del focus da tastiera (Tab):
| Tasto | Su uno slider | Sull’area |
|---|---|---|
| Destra, Su | Un passo in su | Destra: più saturazione; Su: più luminosità |
| Sinistra, Giù | Un passo in giù | Sinistra: meno saturazione; Giù: meno luminosità |
| Maiusc con una freccia | Dieci passi | Dieci passi |
| Pag Su, Pag Giù | Dieci passi | Dieci passi di luminosità |
| Home, Fine | Le estremità del canale |
In un layout da destra a sinistra uno slider orizzontale e l’area partono da destra, con il valore minimo (la minima saturazione) all’estremità destra: Sinistra è un passo in su e Destra un passo in giù, e una pressione o un trascinamento sceglie il valore sotto il puntatore così come è disegnato. Uno slider verticale mantiene Destra e Su come passo in su; Su e Giù sull’area mantengono la luminosità.
Un campione accetta Spazio e Invio. Ogni tasto segnala onChange e poi onChangeEnd; le frecce si ripetono se tenute premute.
#Accessibilità
- Il thumb dell’area e quello di ogni slider sono elementi regolabili (
"adjustable", uno slider nel browser) con la loro etichetta, valore e intervallo; l’incremento e il decremento degli screen reader li fanno avanzare di un passo. - Ogni campo è un campo di testo con la sua etichetta; ogni campione un pulsante, selezionato mentre è il colore del picker, denominato dal
suo colore in esadecimale a meno che
accessibilityLabelnon lo denomini. Il contagocce è un pulsante con l’etichetta dell’app. - Quando sono disabilitate, gli screen reader indicano che le parti sono attenuate, ed esse non accettano azioni.
Vedi Accessibilità per etichette, ruoli e azioni in generale.
#Note sulle piattaforme
- Ogni piattaforma disegna ciò che l’app definisce nello stile; i colori delle tracce e del pannello sono gradienti disegnati dalla piattaforma. Pressioni e trascinamenti sono seguiti nel core, per un mouse, una penna o un dito, e una scroll view attorno al picker limita il proprio scorrimento ai trascinamenti che iniziano altrove.
ColorEyeDropperpreleva con il color sampler di AppKit su macOS e conEyeDropperdel browser sul web; il colore arriva in sRGB.