元素

ColorPicker

颜色选择器持有一个颜色,放在其中的部件显示并修改它:饱和度与明度的 ColorArea,RGB、HSL、HSB 或 OKLCH 任一通道及透明度的 ColorSlider,以文本接受整个颜色或以数字接受一个通道的 ColorField,预设颜色的 ColorSwatch,以及在屏幕任意位置取色的 ColorEyeDropper。应用用自己的元素排布这些部件(每个通道一行,带上字母;用 Tabs 切换色彩空间;把选择器放进 Popover),并为每个部件设置样式:部件绘制通道的颜色,尺寸、圆角、边框和阴影都由应用决定。

el-color-picker.tsx
el-color-picker.tsx运行中
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 和 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

PropRust类型默认值说明
value.value(..)string无颜色(受控)。应用设置 onChange 报告的颜色。
defaultValue.default_value(..)string"#000000"未设置 value 时的初始颜色;此后由选择器自己保存。
format.format(..)"hex" | "rgb" | "hsl" | "hsb" | "oklch""hex"formatted 写出颜色的方式。
disabled.disabled(..)booleanfalse任何部件都不接受修改;disabled: 类作用于选择器及其部件。
children.child(..)JSX.Element无部件以及应用放在它们之间的元素。
class、style样式方法string、StyleProp<ViewStyle>无选择器本身:部件外面的列(或行)。

#ColorArea

PropRust类型默认值说明
panelClass、panelStyle.panel_style(|s| ..)string、StyleProp<ViewStyle>无填满区域的面板:色相的颜色,饱和度从左到右、明度从下到上,按面板圆角裁剪。
thumbClass、thumbStyle.thumb_style(|s| ..)string、StyleProp<ViewStyle>无以颜色为中心的滑块。
fillThumb.fill_thumb(..)booleanfalse滑块背景为当前颜色。
disabled.disabled(..)booleanfalse不接受指针、键盘或屏幕阅读器的修改。
accessibilityLabel.accessibility_label(..)string无屏幕阅读器为滑块读出的内容。
class、style样式方法string、StyleProp<ViewStyle>无区域,尺寸由应用决定。

#ColorSlider

PropRust类型默认值说明
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(..)booleanfalse滑块背景为当前颜色(透明度滑块上带透明度)。
checkerboardColor.checkerboard_color(..)string无轨道下棋盘格的颜色,透过透明度滑块的颜色显示出来。
checkerboardSize.checkerboard_size(..)number4棋盘格方块的边长。
disabled.disabled(..)booleanfalse不接受指针、键盘或屏幕阅读器的修改。
accessibilityLabel.accessibility_label(..)string无屏幕阅读器为滑块读出的内容。
class、style样式方法string、StyleProp<ViewStyle>无滑块:轨道外接受按压和拖动的区域。

#ColorField

PropRust类型默认值说明
channel.channel(..)ColorChannel无以数字接受的通道。不设置时,字段以文本接受整个颜色。
colorSpace.color_space(..)ColorSpace"hsb"channel 所在的空间。
format.format(..)ColorFormat"hex"字段写出整个颜色的方式;读取时接受任意 CSS 形式。
placeholder.placeholder(..)string无字段为空时显示。
disabled.disabled(..)booleanfalse不接受输入。
accessibilityLabel.accessibility_label(..)string无屏幕阅读器为字段读出的内容。
class、style样式方法string、StyleProp<TextStyle>无字段本身,一个文本输入框。

通道字段在输入数字时即修改颜色,数值限制在通道范围内;整个颜色的字段在失去焦点或提交时,若文本能读成颜色才修改。离开字段时,值会按字段的形式重新写出。

#ColorSwatch

PropRust类型默认值说明
color.color(..)string无显示的颜色;按下时在外层选择器中选中它。不设置时显示选择器自己的颜色,作为预览。
selectedStyle.selected_style(..)StyleProp<ViewStyle>无它是选择器当前颜色时的样式,与 selected: 类相同。
checkerboardColor.checkerboard_color(..)string无半透明颜色下的棋盘格。
checkerboardSize.checkerboard_size(..)number4棋盘格方块的边长。
disabled.disabled(..)booleanfalse不接受按压。
accessibilityLabel.accessibility_label(..)string十六进制颜色屏幕阅读器读出的内容。
children.child(..)JSX.Element无叠在颜色上的内容:选中色板上的对勾(group-selected:)。
class、style样式方法string、StyleProp<ViewStyle>无色板的尺寸、形状和边框;颜色按其圆角裁剪。

#ColorEyeDropper

PropRust类型默认值说明
disabled.disabled(..)booleanfalse不接受按压。平台没有吸管时也处于禁用状态。
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参数时机
onChangeColorPicker.on_change(|e: &ColorPickerEvent| ..)({ value: string, formatted: string })用户修改了颜色。
onChangeEndColorPicker.on_change_end(|e: &ColorPickerEvent| ..)({ value: string, formatted: string })一次修改结束,每次修改只报一次。
onPressColorSwatch.on_press(|color: &Hsla| ..)({ color: string })色板被按下;色板也可以单独使用,作为调色板的色块。
onPickColorEyeDropper.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。