要素
Overlay
Overlay は、それが属するルートビュー(ウィンドウ、またはその周囲のダイアログ)内で、すべての要素の上に子要素を表示します。親のサイズ、overflow: hidden、スクロールビューによってクリップされることはありません。それ以外の点では、記述した位置に留まります。周囲のコンテキストを読み取り、ハンドラーはそのコンポーネントのものであり、親が消えると一緒に消えます。Popover、メニュー、ツールチップ、シートは Overlay を土台に作られています。nsh/base にはそれらが用意されており(Popover、Menu、Tooltip、Sheet)、独自のものを作るときに使うのが Overlay です。
import { mount, Overlay, signal, Text, View, type ViewRef } from "nsh";
import { button, buttonText, desktop, Inspector, Panel, Segmented, Stage, ui, wide } from "./palette";
type Placement = "top" | "bottom" | "left" | "right";
type Align = "start" | "center" | "end";
function App() {
const open = signal(false);
const placement = signal<Placement>("bottom");
const align = signal<Align>("center");
const position = signal("—");
const closed = signal("—");
let anchor: ViewRef | undefined;
const placements = () => (
<Segmented
label="placement"
options={["top", "bottom", "left", "right"] as Placement[]}
value={placement()}
onChange={(v) => (placement.set(v), open.set(true))}
/>
);
const aligns = () => (
<Segmented label="align" options={["start", "center", "end"] as Align[]} value={align()} onChange={(v) => (align.set(v), open.set(true))} />
);
const subject = () => (
<>
<View
style={{
width: 220,
height: 72,
overflow: "hidden",
borderRadius: 14,
borderWidth: 2,
borderStyle: "dashed",
borderColor: ui().faint,
alignItems: "center",
justifyContent: "center",
}}
>
<View
ref={anchor}
onPress={() => open.set(!open())}
{...button("accent", { align: "center" })}
>
<Text style={buttonText("accent")}>{open() ? "Close" : "Open"}</Text>
</View>
<If when={open()}>
<Overlay
anchor={anchor}
placement={placement()}
align={align()}
offset={8}
margin={12}
onPosition={(e) =>
position.set(
`${e.placement} at ${Math.round(e.bounds.origin.x)}, ${Math.round(e.bounds.origin.y)} · ${Math.round(e.bounds.size.width)}×${Math.round(e.bounds.size.height)} · room ${Math.round(e.available.width)}×${Math.round(e.available.height)}`,
)
}
onPressOutside={(e) => {
closed.set(`onPressOutside at ${Math.round(e.pagePosition.x)}, ${Math.round(e.pagePosition.y)}`);
open.set(false);
}}
onCancel={() => {
closed.set("onCancel");
open.set(false);
}}
style={{
...ui().card,
borderRadius: 12,
padding: 14,
gap: 4,
// Narrow beside the button, so it fits on a phone's side.
maxWidth: !wide() && (placement() === "left" || placement() === "right") ? 130 : 220,
}}
>
<Text style={{ fontSize: 15, fontWeight: "600", color: ui().text }}>On top of everything</Text>
<Text style={{ fontSize: 13, color: ui().muted }}>
Its box clips the button, not this overlay. Pick a side {wide() ? "on the right" : "above"}.
</Text>
</Overlay>
</If>
</View>
<Text style={{ marginTop: 6, fontSize: 12, color: ui().faint }}>The dashed box has overflow: hidden</Text>
</>
);
return (
<View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
<Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>overlay</Text>
<Text style={{ color: ui().muted }}>
{desktop ? "Click the button, then click outside or press Escape" : "Tap the button, then tap outside"}
</Text>
{() =>
wide() ? (
<View style={{ flex: 1, flexDirection: "row", gap: 16 }}>
<Stage>
<View style={{ alignItems: "center" }}>{subject()}</View>
</Stage>
<Inspector>
{placements()}
{aligns()}
<Panel title="onPosition">
<Text style={{ fontSize: 13, color: ui().text, fontVariant: ["tabular-nums"] }}>{position()}</Text>
</Panel>
<Panel title="closed by" grow>
<Text style={{ fontSize: 13, color: closed() === "—" ? ui().text : ui().accent }}>{closed()}</Text>
</Panel>
</Inspector>
</View>
) : (
<>
<View style={{ flexDirection: "row", gap: 12 }}>
<View style={{ flex: 3 }}>{placements()}</View>
<View style={{ flex: 2 }}>{aligns()}</View>
</View>
<View style={{ flex: 1, minHeight: 160, alignItems: "center", justifyContent: "center" }}>{subject()}</View>
<View style={{ ...ui().inset, borderRadius: 12, padding: 12, gap: 6 }}>
<Text style={{ fontSize: 12, color: ui().muted }}>
onPosition <Text style={{ color: ui().text }}>{position()}</Text>
</Text>
<Text style={{ fontSize: 12, color: ui().muted }}>
closed by <Text style={{ color: closed() === "—" ? ui().text : ui().accent }}>{closed()}</Text>
</Text>
</View>
</>
)
}
</View>
);
}
mount(App);use nsh_core::prelude::*;
fn more_button() -> impl IntoElement {
let open = signal(false);
let anchor = element_ref();
view()
.child(
view()
.element_ref(&anchor)
.on_press(move || open.update(|o| *o = !*o))
.px_3()
.py_2()
.child(text("More")),
)
.child(when(move || open.get(), move || {
overlay()
.anchor(&anchor)
.placement(Placement::Bottom)
.align(Align::Start)
.offset(px(6.0))
.margin(px(8.0))
.on_press_outside(move |_: &Point<Points>| open.set(false))
.on_cancel(move || open.set(false))
.rounded_lg()
.bg(white())
.p_3()
.shadow_lg()
.child(text("Shown above everything"))
}))
}import { Overlay, signal, Text, View, type ViewRef } from "nsh";
function MoreButton() {
const open = signal(false);
let anchor: ViewRef | undefined;
return (
<View>
<View ref={anchor} onPress={() => open.set(!open())} class="px-3 py-2">
<Text>More</Text>
</View>
<If when={open()}>
<Overlay
anchor={anchor}
placement="bottom"
align="start"
offset={6}
margin={8}
onPressOutside={() => open.set(false)}
onCancel={() => open.set(false)}
class="rounded-lg bg-white p-3 shadow-lg"
>
<Text>Shown above everything</Text>
</Overlay>
</If>
</View>
);
}TSX では Overlay は nsh から、Rust では overlay() は nsh_core::prelude から提供されます。Overlay はビューの中にある間だけ表示されます。開閉するには If(Rust では when(..))の中に置きます。後から表示された Overlay は先に表示されたものの上に重なり、別の Overlay の内側にある Overlay はその上に重なります。
#配置
anchor がない場合、Overlay はルートビューの position: fixed ボックスとしてレイアウトされます。top、left、inset、サイズ、マージンは、ウィンドウ(またはダイアログ)全体を基準にします。class="inset-0" は全体を覆うので、バックドロップやウィンドウ全面のレイヤーになります。class="bottom-0 left-0 right-0" は下端に沿ったバーです。
anchor を指定すると、NSH はアンカーの隣に Overlay を配置します。
anchorには、ビュー(ref)、ルートビューの座標系におけるPoint({ x, y })、タッチのpageX/pageY(ポインターの位置にメニューを出す場合)、またはそこにあるBounds({ origin, size })を指定できます。最後のものは入力欄のキャレット(onCaretMoveのpageBounds)で、入力中の内容に対する候補が開く位置になります。placementは配置する側で、"top"、"bottom"(デフォルト)、"left"、"right"のいずれかです。その側に収まらず反対側に収まる場合は反対側に配置されます。どちらにも収まらない場合は、より余裕のある側に配置されます。alignは、その側に沿った揃え方で、"start"、"center"(デフォルト)、"end"のいずれかです。アンカーの上または下では、start と end はレイアウト方向に従います。start は左端、右から左へのレイアウトでは右端です。offsetはアンカーと Overlay の間の隙間です。marginは Overlay がセーフエリアの端から保つ距離で、Overlay はセーフエリアの内側に留まり、収まるようにその側に沿ってスライドします。
アンカー付きの Overlay はアンカーに追従します。アンカーがスクロール、移動、またはサイズ変更されると、NSH がレイアウトを行う同じフレームで Overlay も一緒に動きます。onPosition は Overlay が配置された場所を伝えるため、矢印でアンカーを指したり、長いメニューの高さを制限したりできます。
<Overlay
anchor={anchor}
onPosition={(e) => {
side.set(e.placement); // "top" when it flipped
maxHeight.set(e.available.height);
}}
/>
#閉じる
Overlay は、ポップアップを閉じる一般的な操作を通知するだけで、閉じるかどうかの判断は開発者に任せます。
onPressOutsideは、Overlay、そのアンカー、およびその内側の Overlay の外側で、指またはマウスボタンが押されたときに発火します。押下は、その下にある要素にそのまま伝わります。onCancelは、Escape、Android の戻るボタン、iOS の VoiceOver のエスケープジェスチャーで発火し、これを受け取るのは最上位の Overlay だけです。ページやBackHandlerより先に受け取ります。
ポップアップの背後の画面をブロックするには、その下に class="inset-0" を指定した2つ目の Overlay を置き、両方を閉じる onPress を設定します。Sheet や Popover のスマートフォン向け表示は、この方法で動いています。
#Props
| Prop | Rust | 型 | デフォルト | 説明 |
|---|---|---|---|---|
anchor | .anchor(&element_ref), .anchor_point(point), .anchor_bounds(bounds) | ViewRef | Point | Bounds | null | なし | 配置の基準となるビュー、ポイント、またはボックス。指定しない場合は、ルートビューに対する固定ボックスになります。 |
placement | .placement(..) | "top" | "bottom" | "left" | "right" | "bottom" | アンカーに対して配置する側。 |
align | .align(..) | "start" | "center" | "end" | "center" | その側に沿ったアンカーとの揃え方。 |
offset | .offset(px(..)) | number | 0 | アンカーと Overlay の間の隙間。 |
margin | .margin(px(..)) | number | 0 | セーフエリアの端から保つ距離。 |
children | .child(..) | JSX.Element | なし | 表示する内容。 |
class, style | スタイルメソッド | string, StyleProp<ViewStyle> | なし | 見た目と、アンカーがない場合の位置。 |
ビューの共通 propsも使用できます。
#イベント
| イベント | Rust | 説明 |
|---|---|---|
onPressOutside | .on_press_outside(|point| ..) | Overlay、そのアンカー、およびその内側の Overlay(pagePosition、Point)の外側で押下が発生した。 |
onCancel | .on_cancel(|| ..) | Escape、Android の戻るボタン、または VoiceOver のエスケープジェスチャー。対象の Overlay が、これらを受け取る最上位の Overlay である場合に発生します。 |
onPosition | .on_position(|position| ..) | NSH が配置を行った。placement(配置された側)、ルートビューにおける bounds、available(Size:その側の余白)を含みます。 |
#プラットフォームに関する注意
- どのプラットフォームでも、Overlay はウィンドウ(またはダイアログ)のルートビューのネイティブビューとして表示され、周囲のビューと同じように描画およびヒットテストが行われます。対象は UIKit、Android views、AppKit、GTK、Win32、GPUI、DOM です。
- Dialog の内側にある Overlay は、そのダイアログ内でコンテンツの上に表示されます。
- Overlay はウィンドウの外には出ません。ウィンドウの端からはみ出すメニューは、内側に戻るようにスライドします。