要素
Link
Link は、ユーザーをどこかへ移動させます。移動先は Web ページ、別のアプリ(mailto:、tel:)、アプリ自身の画面などです。Link が受け持つのは動作です。具体的には、プレス、キーボードフォーカス中の Return と Space、無効状態、スクリーンリーダー向けの link ロールです。見た目はアプリ側が決めます。Link 自体には色も下線もラベルもなく、表示されるのは子要素です。href は移動先を示します。これは開くためのデータであり、画面に表示されるテキストではありません。
import { mount, signal, Text, View } from "nsh";
import { Link } from "nsh/base";
import { desktop, focusRing, Icon, Log, Panel, size, Split, tint, ui } from "./palette";
// Links of the app's look: the accent with a quiet underline that fills in
// under the pointer, violet once visited (the app remembers what it
// opened), the palette's focus ring, dimmed while disabled. The color and
// the underline move with `transition-colors`, still with reduced motion.
const visited = signal(new Set<string>());
const events = signal<string[]>([]);
const route = signal("/docs/link");
const opened = ({ href }: { href: string }) => {
visited.set(new Set(visited()).add(href));
events.set([`onOpen ${href}`, ...events()].slice(0, 4));
};
function DocLink(props: { href: string; label: string; openWith?: "system" | "app"; disabled?: boolean; current?: boolean; icon?: boolean; inline?: boolean }) {
const seen = () => visited().has(props.href);
const color = () => (props.disabled ? ui().faint : props.current ? ui().text : seen() ? ui().violet : ui().accent);
return (
<Link
href={props.href}
openWith={props.openWith ?? "app"}
disabled={props.disabled}
current={props.current}
onOpen={(e: { href: string }) => {
opened(e);
if ((props.openWith ?? "app") === "app") route.set(e.href);
}}
class={`flex-row items-center gap-1 rounded-sm transition-colors motion-reduce:transition-none active:opacity-60 ${props.inline ? "align-text-bottom" : "self-start"}`}
focusStyle={focusRing(2)}
>
<Text
class="transition-colors motion-reduce:transition-none"
style={{
fontSize: props.inline ? 15 : size.label,
fontWeight: props.current ? "600" : "500",
color: color(),
textDecorationLine: props.current || props.disabled ? "none" : "underline",
textDecorationColor: tint(color(), 0.4),
}}
hoverStyle={props.disabled || props.current ? undefined : { textDecorationColor: color() }}
>
{props.label}
</Text>
{props.icon ? <Icon name="external" size={13} tint={color()} /> : null}
</Link>
);
}
function App() {
const crumb = (href: string, label: string) => <DocLink href={href} label={label} current={route() === href} />;
const slash = () => <Text style={{ color: ui().faint, fontSize: size.label }}>/</Text>;
return (
<View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
<Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>link</Text>
<Text style={{ color: ui().muted }}>{desktop ? "Click a link, or Tab to one and press Return" : "Tap a link"}</Text>
<Split
gap={14}
inspector={
<>
<Panel title="route · visited">
<Text style={{ fontSize: 13, color: ui().text }}>
{route()} · {visited().size} visited
</Text>
</Panel>
<Log title="onOpen({ href })" lines={events()} minHeight={110} />
</>
}
>
<View style={{ ...ui().card, borderRadius: 16, padding: desktop ? 16 : 18, gap: 14 }}>
<View style={{ flexDirection: "row", alignItems: "center", flexWrap: "wrap", gap: 6 }}>
{crumb("/docs", "Docs")}
{slash()}
{crumb("/docs/elements", "Elements")}
{slash()}
{crumb("/docs/link", "Link")}
</View>
<Text style={{ fontSize: 15, lineHeight: 22, color: ui().text }}>
{"Read the "}
<DocLink href="/docs/release-notes" label="release notes" inline />
{" and the "}
<DocLink href="/docs/migration" label="migration guide" inline />
{" before you update."}
</Text>
<View style={{ height: 1, backgroundColor: ui().line }} />
<View style={{ gap: 10 }}>
<DocLink href="https://example.com/" label="example.com" openWith="system" icon />
<DocLink href="/docs/archive" label="Archived docs" disabled />
</View>
</View>
</Split>
</View>
);
}
mount(App);use nsh_base::{link, theme};
use nsh_core::prelude::*;
fn docs() -> impl IntoElement {
link()
.href("https://example.com/docs")
.text_color(move || theme().primary)
.underline()
.hover(|s| s.opacity(0.8))
.focus(|s| s.rounded_sm().outline(px(2.0)).outline_color(move || theme().ring))
.child(text("Read the docs"))
}import { mount, Text } from "nsh";
import { Link } from "nsh/base";
function App() {
return (
<Link
href="https://example.com/docs"
class="self-start text-primary underline hover:opacity-80 focus:rounded-sm focus:outline-2 focus:outline-ring"
>
<Text>Read the docs</Text>
</Link>
);
}
mount(App);Link は Rust の nsh-base クレートである nsh/base に由来します。Rust では、link() が Link を作成します。プロパティごとのセッター(.href(..)、.open_with(..))、イベントごとのリスナー(.on_open(..)、.on_press(..))、Link 自体の型付きスタイルメソッド、そのコンテンツ用の .child(..) が用意されています。
#開く
フォーカス中の Link に対するプレス、クリック、または Return か Space で、Link は href を開きます。
onOpen({ href })が移動先を通知します。openWith="system"(デフォルト)の場合、別のアプリやサイトの URL、つまりスキームを持つ URL(https:、mailto:、tel:、他のアプリが登録したスキーム)は、openUrlと同じ方法でアプリの外で開かれます。まず組み込み先アプリのルーターが処理し、その後でシステムが処理します。それ以外のhref、つまりアプリ内のパス(/orders/42)や#anchorは、通知されるだけでアプリ側に残ります。onPress()がプレスを通知します。
openWith="app" を指定すると、Link をどう開くかはアプリの onOpen ハンドラーが決めます。アプリ独自のルート、アプリ内 Web ビューのページ、先に確認を挟む処理などが可能です。
import { Link } from "nsh/base";
<Link href={`/orders/${order.id}`} openWith="app" onOpen={({ href }) => router.push(href)} class="text-primary">
<Text>Order #{order.id}</Text>
</Link>
アプリが nsh.json("openUrl": true または { "schemes": ["https", "mailto"] })で capability を宣言している場合、URL をアプリの外で開くには openUrl capability が必要です。
#プロパティ
| プロパティ | Rust | 型 | デフォルト | 説明 |
|---|---|---|---|---|
href | .href(..) | string | なし | Link の移動先。URL またはアプリ内のパスです。onOpen で通知され、表示はされません。 |
openWith | .open_with(LinkOpenWith::App) | "system" | "app" (LinkOpenWith) | "system" | href を誰が開くか。別のアプリやサイトの URL はシステムが開き、"app" では onOpen に任せます。 |
disabled | .disabled(..) | boolean | false | プレス、プレスのフィードバック、キーボードフォーカスがすべて無効になります。disabled: スタイルは適用され、スクリーンリーダーは淡色表示(dimmed)であると伝えます。 |
current | .current(..) | boolean | false | ナビゲーションやパンくずリストで、ユーザーが現在いるページへの Link。スクリーンリーダーは現在のページであると伝えます。 |
accessibilityLabel | .accessibility_label(..) | string | なし | コンテンツだけでは情報が足りない場合(アイコンなど)に、スクリーンリーダーが読み上げる内容。 |
hitSlop | .hit_slop(..) | number | Partial<Edges> | なし | 境界の外側でどこまでプレスを有効とみなすか(ポイント単位)。全辺共通の数値か、辺ごとの指定ができます。ビューの hitSlop と同じです。 |
children | .child(..) | JSX.Element | なし | コンテンツ。ラベル、アイコンなど、アプリが表示する任意のもの。 |
class、style | スタイルメソッド | string、StyleProp<ViewStyle> | なし | Link の見た目。色、下線、パディングなど。テキストプロパティは内部のテキストに引き継がれます。 |
activeStyle、hoverStyle、focusStyle、disabledStyle | .active(|s| ..)、.hover(..)、.focus(..)、.disabled_style(..) | StyleProp<ViewStyle> | なし | プレス中、ホバー中、フォーカス中、無効時の Link のスタイル。<state>: クラスと同様に指定します。 |
#イベント
| イベント | Rust | 説明 |
|---|---|---|
onOpen | .on_open(|href: &SharedString| ..) | Link が href を開くときに発生します。openWith="app" ではハンドラーが開き、"system" ではシステムが開くよう求められた内容を受け取ります。onPress より前に発生します。 |
onPress | .on_press(|| ..) | Link がプレスされたときに発生します。タップ、クリック、またはキーボードフォーカス中の Return か Space が対象です。 |
無効な Link は何も通知せず、その Link へのプレスは周囲のプレス可能なビューにも届きません。
#状態
アプリは、クラスまたはスタイルプロパティで各状態のスタイルを指定します。
<Link
href="https://example.com/changelog"
class="self-start text-primary underline decoration-primary/40 transition-colors hover:decoration-primary active:opacity-70 focus:outline-2 focus:outline-ring disabled:text-muted-foreground disabled:no-underline"
>
<Text>Changelog</Text>
</Link>
訪問済みの Link は、アプリ側で記憶します。開いた href を保持し、独自の状態に基づいてそれらの Link をスタイルします。
const visited = signal(new Set<string>());
<Link
href={href}
onOpen={({ href }) => visited.set(new Set(visited()).add(href))}
class={visited().has(href) ? "text-violet-600 underline" : "text-primary underline"}
>
<Text>{label}</Text>
</Link>
#テキストの中
<Text> の中に置いた Link は、テキスト内のあらゆるビューと同様に、段落のインラインボックスになります。行の中に配置され、独自の状態、フォーカス、ロールを持ち、1 行に収まったままになります。align-text-bottom は、1 行のテキストを持つ Link を段落のベースラインに揃えます。
<Text class="text-[15px] text-foreground">
By continuing you accept the{" "}
<Link href="https://example.com/terms" class="align-text-bottom">
<Text class="text-[15px] text-primary underline">terms of service</Text>
</Link>
.
</Text>
段落の行に合わせて折り返す Link は、role="link" と onPress を指定した、入れ子の <Text> にします(詳しくは Text を参照)。TextView がドキュメント内の Link を描画するのも、この方法です。
#hitSlop で小さなターゲットを扱う
Link は指より小さくなることがあります。アイコンや短い単語などです。hitSlop を使うと、Link の近くに落ちたプレスも有効になります。範囲は各辺の外側へのポイント数で指定し、レイアウトや見た目は変わりません。全辺共通の数値か、辺ごとの指定({ top: 8, bottom: 8 })ができます。これは ビューの hitSlop と同じ指定方法です。
<Link href="https://example.com/help" hitSlop={12} accessibilityLabel="Help" class="size-5 items-center justify-center">
<Text>?</Text>
</Link>
#アクセシビリティ
- Link はスクリーンリーダーに対して 1 つの要素となり、ロールは
"link"です。ラベルは子要素のテキスト、またはaccessibilityLabelです。hrefはデータのままで、スクリーンリーダーはラベルを読み上げます。 currentは現在のページとして読み上げられ(ARIA のaria-current="page")、disabledはプラットフォームごとの淡色表示要素の読み上げ方で伝えられます。- macOS と Web では、Link はキーボードフォーカスの停止位置になります。Tab で到達でき、Return と Space で開き、
focus:スタイルがフォーカス位置を示します。
ラベル、ロール、アナウンス全般については アクセシビリティ を参照してください。
#プラットフォームに関する注意
- iOS、Android、Mac Catalyst: プレスは Link の上で終わるタップです。
- デスクトップ(macOS、Windows、Linux、GPUI)と Web: プレスはクリックです。macOS と Web では、キーボードフォーカス中の Link を Return と Space でも開けます。
- Web では、Link を
role="link"を持つ要素としてレンダリングします。 - どのプラットフォームでも、アプリがスタイルした内容がそのまま描画されます。