元素
ColorPicker
顏色選擇器持有一個顏色,其內部的各個部件負責顯示並修改它:飽和度與亮度的 ColorArea、RGB、HSL、HSB 或 OKLCH 任一通道以及 alpha 的 ColorSlider、以文字接收顏色或以數字接收單一通道的 ColorField、預設顏色的 ColorSwatch,以及可在螢幕任意位置取色的 ColorEyeDropper。應用程式以自己的元素穿插排版這些部件(每個通道一列並附上其字母、以 Tabs 選取的色彩空間、放在 Popover 中的選擇器),並為每個部件設定樣式:部件負責繪製各通道的顏色,而所有尺寸、圓角、邊框與陰影都由應用程式決定。
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 與 ColorEyeDropper 來自 nsh/base,即 Rust 中的 nsh-base crate。在 Rust 中,color_picker() 及其各部件的函式以每個屬性一個 setter(.channel(..)、.thumb_style(..))、每個事件一個監聽器方法(.on_change(..))以及具型別的樣式方法來建立它們;顏色是 Hsla,色彩空間、通道與格式分別是 ColorSpace、ColorChannel 與 ColorFormat。
#值
顏色是一個 CSS 顏色:十六進位(#3b82f6、含 alpha 的 #3b82f680)、rgb()、hsl()、hsb()、oklch() 或 CSS 名稱;在 Rust 中為 Hsla。應用程式以 value 掌控顏色,並設定 onChange 所回報的值,與 nsh/base 的每個控制項相同;或者以 defaultValue 將顏色交給選擇器自行管理,同樣能收到變更通知。
onChange({ value, formatted })會回報使用者要求的每一個顏色:拖曳色域或滑桿的每一步、每次按鍵、欄位中正在輸入的數字、按下的色票。value是該顏色,formatted是依format所指定的方式寫成的同一個顏色("hex"、"rgb"、"hsl"、"hsb"或"oklch"),可直接儲存或設為 token。onChangeEnd({ value, formatted })只回報一次變更最終停留的顏色:指標放開時、每次按鍵之後、離開或送出欄位時、按下色票時。- 選擇器會在每個色彩空間中保留使用者所設定的顏色。灰色會保留其色相與飽和度,因此色相滑桿會停在使用者離開時的位置;在 OKLCH 中編輯的顏色會保留輸入的明度、彩度與色相,即使超出 sRGB 所能顯示的範圍(回報的顏色是 sRGB 能顯示的最接近顏色,而
"oklch"中的formatted會保留這些通道)。 - 應用程式設定的、且選擇器未曾回報過的
value,會取代每個色彩空間中的顏色。
#色彩空間與通道
colorSpace | 通道(channel) | 範圍 |
|---|---|---|
"rgb" | "red"、"green"、"blue" | 0 到 255 |
"hsl" | "hue"、"saturation"、"lightness" | 色相 0 到 360,其餘 0 到 100 |
"hsb" | "hue"、"saturation"、"brightness" | 色相 0 到 360,其餘 0 到 100 |
"oklch" | "lightness"、"chroma"、"hue" | 明度 0 到 100(步進 0.1),彩度 0 到 0.4(步進 0.001),色相 0 到 360(步進 0.1) |
| 任一 | "alpha" | 0 到 100 |
滑桿的軌道會在固定其他通道的情況下繪製該通道的顏色,因此能顯示每個值會通往什麼顏色。若指定的通道不屬於該色彩空間,則使用該色彩空間的第一個通道(並顯示警告)。
#屬性
#ColorPicker
| 屬性 | Rust | 型別 | 預設值 | 說明 |
|---|---|---|---|---|
value | .value(..) | string | 無 | 顏色(受控)。應用程式設定 onChange 所回報的值。 |
defaultValue | .default_value(..) | string | "#000000" | 在未設定 value 時的初始顏色;此時選擇器自行保存顏色。 |
format | .format(..) | "hex" | "rgb" | "hsl" | "hsb" | "oklch" | "hex" | formatted 如何寫出顏色。 |
disabled | .disabled(..) | boolean | false | 任何部件都無法變更顏色;disabled: class 會套用到選擇器及其部件。 |
children | .child(..) | JSX.Element | 無 | 各部件,以及穿插其間的應用程式元素。 |
class、style | 樣式方法 | string、StyleProp<ViewStyle> | 無 | 選擇器本身:包住各部件的欄(或列)。 |
#ColorArea
| 屬性 | Rust | 型別 | 預設值 | 說明 |
|---|---|---|---|---|
panelClass、panelStyle | .panel_style(|s| ..) | string、StyleProp<ViewStyle> | 無 | 面板,填滿色域:呈現該色相的顏色,飽和度由左至右(在由右至左的版面中則由右至左),亮度由下至上,並依其圓角裁切。 |
thumbClass、thumbStyle | .thumb_style(|s| ..) | string、StyleProp<ViewStyle> | 無 | 拇指滑塊,置中於該顏色上。 |
fillThumb | .fill_thumb(..) | boolean | false | 拇指滑塊的背景為該顏色。 |
disabled | .disabled(..) | boolean | false | 指標、鍵盤或螢幕閱讀器都無法變更顏色。 |
accessibilityLabel | .accessibility_label(..) | string | 無 | 螢幕閱讀器為拇指滑塊朗讀的內容。 |
class、style | 樣式方法 | string、StyleProp<ViewStyle> | 無 | 色域:其大小由應用程式決定。 |
#ColorSlider
| 屬性 | Rust | 型別 | 預設值 | 說明 |
|---|---|---|---|---|
colorSpace | .color_space(..) | "rgb" | "hsl" | "hsb" | "oklch" | "hsb" | 通道所屬的色彩空間。 |
channel | .channel(..) | ColorChannel | "hue" | 它所變更的通道:該色彩空間的其中一個通道,或 "alpha"。 |
axis | .axis(..) | "horizontal" | "vertical" | "horizontal" | 軌道的方向:最小值位於起點(左側,在由右至左的版面中為右側),或位於底部。於建立滑桿時讀取。 |
trackClass、trackStyle | .track_style(|s| ..) | string、StyleProp<ViewStyle> | 無 | 軌道:沿軌道呈現該通道的顏色,並依其圓角裁切;拇指滑塊放置在軌道上。 |
thumbClass、thumbStyle | .thumb_style(|s| ..) | string、StyleProp<ViewStyle> | 無 | 拇指滑塊,置中於該值上。 |
fillThumb | .fill_thumb(..) | boolean | false | 拇指滑塊的背景為該顏色(在 alpha 滑桿上包含其 alpha)。 |
checkerboardColor | .checkerboard_color(..) | string | 無 | 軌道下方棋盤格的顏色,會透過 alpha 滑桿的顏色顯現。 |
checkerboardSize | .checkerboard_size(..) | number | 4 | 棋盤格方塊的邊長。 |
disabled | .disabled(..) | boolean | false | 指標、鍵盤或螢幕閱讀器都無法變更顏色。 |
accessibilityLabel | .accessibility_label(..) | string | 無 | 螢幕閱讀器為拇指滑塊朗讀的內容。 |
class、style | 樣式方法 | string、StyleProp<ViewStyle> | 無 | 滑桿:軌道周圍接收按壓與拖曳的區域。 |
#ColorField
| 屬性 | Rust | 型別 | 預設值 | 說明 |
|---|---|---|---|---|
channel | .channel(..) | ColorChannel | 無 | 以數字接收的通道。若未設定,欄位會以文字接收整個顏色。 |
colorSpace | .color_space(..) | ColorSpace | "hsb" | channel 所屬的色彩空間。 |
format | .format(..) | ColorFormat | "hex" | 欄位如何寫出整個顏色。可讀取任何 CSS 形式。 |
placeholder | .placeholder(..) | string | 無 | 欄位為空時顯示。 |
disabled | .disabled(..) | boolean | false | 無法輸入。 |
accessibilityLabel | .accessibility_label(..) | string | 無 | 螢幕閱讀器為欄位朗讀的內容。 |
class、style | 樣式方法 | string、StyleProp<TextStyle> | 無 | 欄位,即文字輸入框。 |
通道欄位會在輸入數字時變更顏色,並限制在該通道的範圍內;整個顏色的欄位則在離開或送出欄位時,若文字可讀取為顏色才變更顏色。離開欄位時,會以其格式重新寫出欄位的值。
#ColorSwatch
| 屬性 | Rust | 型別 | 預設值 | 說明 |
|---|---|---|---|---|
color | .color(..) | string | 無 | 它所顯示的顏色;按下後會在其所屬的選擇器中選取該顏色。若未設定,則顯示選擇器本身的顏色,作為預覽。 |
selectedStyle | .selected_style(..) | StyleProp<ViewStyle> | 無 | 當它是選擇器目前的顏色時所套用的樣式,以 selected: class 表示。 |
checkerboardColor | .checkerboard_color(..) | string | 無 | 半透明顏色下方的棋盤格。 |
checkerboardSize | .checkerboard_size(..) | number | 4 | 方塊的邊長。 |
disabled | .disabled(..) | boolean | false | 無法按壓。 |
accessibilityLabel | .accessibility_label(..) | string | 十六進位的顏色 | 螢幕閱讀器朗讀的內容。 |
children | .child(..) | JSX.Element | 無 | 顯示在顏色之上的內容:例如已選取色票上的勾號(group-selected:)。 |
class、style | 樣式方法 | string、StyleProp<ViewStyle> | 無 | 色票:其大小、形狀與邊框;其圓角會裁切顏色。 |
#ColorEyeDropper
| 屬性 | Rust | 型別 | 預設值 | 說明 |
|---|---|---|---|---|
disabled | .disabled(..) | boolean | false | 無法按壓。在平台沒有滴管功能時也會停用。 |
accessibilityLabel | .accessibility_label(..) | string | 無 | 螢幕閱讀器朗讀的內容。 |
children | .child(..) | JSX.Element | 無 | 其內容:應用程式的圖示或標籤。 |
class、style | 樣式方法 | string、StyleProp<ViewStyle> | 無 | 按鈕。 |
按下後會顯示系統的滴管:在 macOS 上為 AppKit 的取色器,在網頁上為瀏覽器的 EyeDropper(Chrome 與 Edge)。取得的顏色會成為選擇器的顏色(先觸發 onChange,再觸發 onChangeEnd);按下 Escape 則不選取任何顏色。在平台沒有滴管功能時,按鈕會被停用,而 disabled:hidden 會在該處將其隱藏。
#事件
| 事件 | 所屬 | Rust | Payload | 時機 |
|---|---|---|---|---|
onChange | ColorPicker | .on_change(|e: &ColorPickerEvent| ..) | ({ value: string, formatted: string }) | 使用者變更了顏色。 |
onChangeEnd | ColorPicker | .on_change_end(|e: &ColorPickerEvent| ..) | ({ value: string, formatted: string }) | 變更結束。每次變更只觸發一次。 |
onPress | ColorSwatch | .on_press(|color: &Hsla| ..) | ({ color: string }) | 色票被按下;也可單獨使用,例如作為調色盤的色塊。 |
onPick | ColorEyeDropper | .on_pick(|color: &Hsla| ..) | ({ color: string }) | 使用者在螢幕上選取了顏色;也可單獨使用。 |
在 Rust 中,ColorPickerEvent::Change(Hsla, SharedString) 與 ColorPickerEvent::ChangeEnd(Hsla, SharedString) 帶有顏色及其 formatted 文字。
#部件
| 部件 | 位於 | 樣式設定方式 |
|---|---|---|
ColorArea | 選擇器內 | class、style |
| 色域的面板 | 色域內,填滿色域 | panelClass、panelStyle |
| 色域的拇指滑塊 | 色域內,置中於該顏色 | thumbClass、thumbStyle |
ColorSlider | 選擇器內 | class、style |
| 滑桿的軌道 | 滑桿內 | trackClass、trackStyle |
| 滑桿的拇指滑塊 | 滑桿內,置中於軌道上該值的位置 | thumbClass、thumbStyle |
ColorField | 選擇器內 | class、style |
ColorSwatch | 選擇器內,或單獨使用 | class、style、selectedStyle |
ColorEyeDropper | 選擇器內,或單獨使用 | class、style |
滑桿的拇指滑塊在通道的兩端會置中於軌道的兩端。在滑桿上設定相當於拇指滑塊一半寬度的內距(h-3 軌道周圍使用 h-6 justify-center px-2.5),可讓整個拇指滑塊都留在滑桿內,使其在兩端都能接收按壓。部件會顯示的狀態包括:拇指滑塊被拖曳時的 active:、擁有鍵盤焦點時的 focus:、色票的 selected:,以及每個部件的 disabled:。
#鍵盤
在 macOS 與網頁上,每個拇指滑塊都是一個鍵盤焦點停駐點(Tab):
| 按鍵 | 在滑桿上 | 在色域上 |
|---|---|---|
| 右、上 | 增加一步 | 右:飽和度增加;上:亮度增加 |
| 左、下 | 減少一步 | 左:飽和度減少;下:亮度減少 |
| Shift 加方向鍵 | 十步 | 十步 |
| Page Up、Page Down | 十步 | 亮度十步 |
| Home、End | 通道的兩端 |
在由右至左的版面中,水平滑桿與色域由右側開始,最小值(最低飽和度)位於右端:按左鍵為增加一步,按右鍵為減少一步,而按壓或拖曳會選取指標在畫面上所指的位置的值。垂直滑桿仍以右與上為增加一步;色域上的上與下則維持亮度的方向。
色票接受 Space 與 Return。每次按鍵會先回報 onChange,再回報 onChangeEnd;按住方向鍵時會重複觸發。
#無障礙功能
- 色域與每個滑桿的拇指滑塊都是可調整的元素(
"adjustable",在瀏覽器中為 slider),具有各自的標籤、值與範圍;螢幕閱讀器的增加與減少操作可逐步調整它們。 - 每個欄位都是帶有標籤的文字欄位;每個色票都是按鈕,在它是選擇器目前的顏色時為已選取,並以其十六進位顏色命名,除非由
accessibilityLabel指定名稱。滴管是帶有應用程式所設標籤的按鈕。 - 停用時,螢幕閱讀器會朗讀這些部件為暗淡狀態,且它們不接受任何動作。
關於標籤、角色與動作的一般說明,請參閱 無障礙功能。
#平台注意事項
- 每個平台都會繪製應用程式所設定的樣式;軌道與面板的顏色是由平台繪製的漸層。按壓與拖曳由核心追蹤,適用於滑鼠、手寫筆或手指,而選擇器外圍的捲動檢視則只在拖曳從其他位置開始時才會捲動。
ColorEyeDropper在 macOS 上以 AppKit 的取色器取色,在網頁上則使用瀏覽器的EyeDropper;取得的顏色以 sRGB 傳回。