元素
ColorPicker
颜色选择器持有一个颜色,放在其中的部件显示并修改它:饱和度与明度的 ColorArea,RGB、HSL、HSB 或 OKLCH 任一通道及透明度的
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() 及各部件的函数创建它们,每个 prop 有一个 setter(.channel(..)、.thumb_style(..)),每个事件有一个监听方法(.on_change(..)),另有类型化的样式方法;颜色是
Hsla,色彩空间、通道和格式分别是 ColorSpace、ColorChannel 和 ColorFormat。
#值
颜色是 CSS 颜色:十六进制(#3b82f6,带透明度的 #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 |
滑块的轨道在其他通道不变的前提下绘制本通道的颜色,因此能看出每个值通向哪里。空间里没有的通道按该空间的第一个通道处理(并给出警告)。
#Props
#ColorPicker
| Prop | 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: 类作用于选择器及其部件。 |
children | .child(..) | JSX.Element | 无 | 部件以及应用放在它们之间的元素。 |
class、style | 样式方法 | string、StyleProp<ViewStyle> | 无 | 选择器本身:部件外面的列(或行)。 |
#ColorArea
| Prop | 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
| Prop | 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 | 滑块背景为当前颜色(透明度滑块上带透明度)。 |
checkerboardColor | .checkerboard_color(..) | string | 无 | 轨道下棋盘格的颜色,透过透明度滑块的颜色显示出来。 |
checkerboardSize | .checkerboard_size(..) | number | 4 | 棋盘格方块的边长。 |
disabled | .disabled(..) | boolean | false | 不接受指针、键盘或屏幕阅读器的修改。 |
accessibilityLabel | .accessibility_label(..) | string | 无 | 屏幕阅读器为滑块读出的内容。 |
class、style | 样式方法 | string、StyleProp<ViewStyle> | 无 | 滑块:轨道外接受按压和拖动的区域。 |
#ColorField
| Prop | 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
| Prop | Rust | 类型 | 默认值 | 说明 |
|---|---|---|---|---|
color | .color(..) | string | 无 | 显示的颜色;按下时在外层选择器中选中它。不设置时显示选择器自己的颜色,作为预览。 |
selectedStyle | .selected_style(..) | StyleProp<ViewStyle> | 无 | 它是选择器当前颜色时的样式,与 selected: 类相同。 |
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
| Prop | Rust | 类型 | 默认值 | 说明 |
|---|---|---|---|---|
disabled | .disabled(..) | boolean | false | 不接受按压。平台没有吸管时也处于禁用状态。 |
accessibilityLabel | .accessibility_label(..) | string | 无 | 屏幕阅读器读出的内容。 |
children | .child(..) | JSX.Element | 无 | 按钮的内容:应用的图标或文字。 |
class、style | 样式方法 | string、StyleProp<ViewStyle> | 无 | 按钮本身。 |
按下时显示系统的吸管:macOS 上是 AppKit 的取色器,Web 上是浏览器的 EyeDropper(Chrome 和 Edge)。取到的颜色成为选择器的颜色(先报告
onChange,再报告 onChangeEnd);按 Escape 则不取色。平台没有吸管时按钮处于禁用状态,用 disabled:hidden 即可在这些平台上隐藏它。
#事件
| 事件 | 所属 | Rust | 参数 | 时机 |
|---|---|---|---|---|
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 和 Web 上,每个拇指都是键盘焦点的一站(Tab):
| 按键 | 滑块上 | 区域上 |
|---|---|---|
| Right、Up | 增加一步 | Right:饱和度增加;Up:明度增加 |
| Left、Down | 减少一步 | Left:饱和度减少;Down:明度减少 |
| Shift 加方向键 | 十步 | 十步 |
| Page Up、Page Down | 十步 | 明度十步 |
| Home、End | 通道的两端 |
色板接受 Space 和 Return。每次按键先报告 onChange,再报告 onChangeEnd;按住方向键会重复。
#无障碍
- 区域和每个滑块的拇指都是可调节元素(
"adjustable",浏览器中为 slider),带有标签、数值和范围;屏幕阅读器的增加、减少操作会逐步调节它们。 - 每个字段是带标签的文本框;每个色板是按钮,是选择器当前颜色时为选中状态,未设置
accessibilityLabel时以十六进制颜色命名。吸管是带应用所给标签的按钮。 - 禁用时,屏幕阅读器会说明部件已变暗,部件不接受任何操作。
标签、角色和操作的通用说明见无障碍。
#平台说明
- 每个平台都绘制应用设置的样式;轨道和面板的颜色是平台绘制的渐变。按压和拖动由核心跟踪,鼠标、触控笔和手指都适用;外层的滚动视图只处理从别处开始的拖动。
ColorEyeDropper在 macOS 上使用 AppKit 的取色器,在 Web 上使用浏览器的EyeDropper;取到的颜色为 sRGB。