要素
ColorPicker
カラーピッカーは1つの色を保持し、その内部のパーツが色を表示・変更します。パーツは、彩度と明度のColorArea、RGB、HSL、HSB、OKLCHの任意のチャンネルとアルファのColorSlider、色をテキストとして、または1つのチャンネルを数値として受け取る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クレートから提供されます。Rustでは、color_picker()とそのパーツの関数が、プロパティごとのセッター(.channel(..)、.thumb_style(..))、イベントごとのリスナーメソッド(.on_change(..))、型付きのスタイルメソッドでこれらを生成します。色はHslaで、色空間、チャンネル、フォーマットはそれぞれColorSpace、ColorChannel、ColorFormatです。
#値
色はCSSカラーです。16進数(#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")で、保存したりトークンとして設定したりできます。onChangeEnd({ value, formatted })は、変更が終わった時点の色を1回だけ報告します。ポインターを離したとき、各キー入力の後、フィールドからフォーカスが外れたか送信されたとき、スウォッチが押されたときです。- ピッカーは、各色空間の色をユーザーが設定したとおりに保持します。グレーは色相と彩度を保持するため、色相スライダーはユーザーが残した位置にとどまります。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:クラスがピッカーとそのパーツに適用されます。 |
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 | サムの背景がその色になります(アルファスライダーではアルファを含みます)。 |
checkerboardColor | .checkerboard_color(..) | string | なし | トラックの下の市松模様の色。アルファスライダーの色を通して見えます。 |
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:クラスとして指定します。 |
checkerboardColor | .checkerboard_color(..) | string | なし | 半透明の色の下に敷く市松模様。 |
checkerboardSize | .checkerboard_size(..) | number | 4 | 正方形の一辺の長さ。 |
disabled | .disabled(..) | boolean | false | 押下を受け付けません。 |
accessibilityLabel | .accessibility_label(..) | string | 16進数の色 | スクリーンリーダーが読み上げる内容。 |
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のカラーサンプラー、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 }) | 変更が終了したとき。変更ごとに1回。 |
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 | 1ステップ増加 | Right: 彩度を上げる、Up: 明度を上げる |
| Left、Down | 1ステップ減少 | Left: 彩度を下げる、Down: 明度を下げる |
| Shiftと矢印キー | 10ステップ | 10ステップ |
| Page Up、Page Down | 10ステップ | 明度を10ステップ |
| Home、End | チャンネルの両端 |
右から左へのレイアウトでは、水平スライダーとエリアは右から始まり、最小値(最小の彩度)が右端になります。Leftが1ステップ増加、Rightが1ステップ減少になり、押下やドラッグでは描画されたとおりにポインター下の値が選ばれます。垂直スライダーでは、RightとUpが引き続き1ステップ増加です。エリアのUpとDownは明度を操作します。
スウォッチはSpaceとReturnを受け付けます。各キーはonChangeに続いてonChangeEndを報告します。矢印キーは押し続けると繰り返されます。
#アクセシビリティ
- エリアと各スライダーのサムは調整可能な要素(
"adjustable"、ブラウザではスライダー)で、ラベル、値、範囲を持ちます。スクリーンリーダーの増加・減少操作でステップ単位に変更できます。 - 各フィールドはラベル付きのテキストフィールドです。各スウォッチはボタンで、ピッカーの色である間は選択状態になり、
accessibilityLabelで名前を指定しない限り16進数の色が名前になります。スポイトは、アプリのラベルを持つボタンです。 - 無効の間、スクリーンリーダーはパーツが淡色表示であると伝え、操作は受け付けません。
ラベル、ロール、アクション全般については、アクセシビリティを参照してください。
#プラットフォームに関する注記
- どのプラットフォームでも、アプリがスタイルしたものが描画されます。トラックとパネルの色は、プラットフォームが描画するグラデーションです。押下とドラッグは、マウス、ペン、指のいずれでもコアで追跡され、ピッカーを囲むスクロールビューのスクロールは、ほかの場所から始まったドラッグに限られます。
ColorEyeDropperは、macOSではAppKitのカラーサンプラー、WebではブラウザのEyeDropperで色を選びます。色はsRGBで渡されます。