@linhey/rhine-design 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/README.md +19 -8
- package/components/Actions.tsx +18 -1
- package/components/AppShell.tsx +46 -17
- package/components/Media.tsx +7 -7
- package/components/NeutronStarBackdrop.tsx +43 -0
- package/components/OrbitBackdrop.tsx +4 -2
- package/components/Overlays.tsx +37 -8
- package/components/README.md +27 -3
- package/components/StatusTimeline.tsx +72 -6
- package/components/StatusTimelineList.tsx +38 -0
- package/components/ThemePicker.tsx +49 -0
- package/components/assets/orbit-bird.svg +578 -0
- package/components/assets/orbit-crystal.svg +11 -0
- package/components/assets/orbit-donut.svg +16 -0
- package/components/assets/theme-previews/arknights.png +0 -0
- package/components/assets/theme-previews/manifest.json +24 -0
- package/components/assets/theme-previews/neo-brutalism.png +0 -0
- package/components/assets/theme-previews/rhine.png +0 -0
- package/components/common.css +3 -3
- package/components/components.tsx +3 -3
- package/components/dialog-settings.css +9 -0
- package/components/extensions.css +8 -32
- package/components/index.ts +11 -5
- package/components/media-parity.css +55 -2
- package/components/neutron.css +53 -0
- package/components/neutronRenderer.ts +327 -0
- package/components/orbit.css +79 -1
- package/components/orbitBirdFlight.ts +34 -0
- package/components/orbitBirdGeometry.ts +158 -0
- package/components/orbitDonut.ts +134 -0
- package/components/orbitDonutThree.ts +208 -0
- package/components/orbitObjectMaterials.ts +76 -0
- package/components/orbitObjects.ts +71 -0
- package/components/orbitObjectsThree.ts +215 -0
- package/components/orbitProjection.ts +21 -0
- package/components/orbitRenderer.ts +163 -46
- package/components/styles.css +3 -1
- package/components/theme-neo-brutalism.css +3 -2
- package/components/theme-picker.css +17 -0
- package/components/theme.tsx +2 -8
- package/components/themeMetadata.ts +7 -0
- package/components/timeline.css +17 -6
- package/components/useFloatingScale.ts +96 -0
- package/docs/APPLICATION_COMPONENTS.md +52 -3
- package/docs/COMPACT_ACCOUNT_PATTERNS.md +70 -0
- package/docs/DATE_RANGE_FILTER.md +60 -0
- package/docs/DESIGN_SYSTEM.md +7 -0
- package/docs/FLOATING_OVERLAYS.md +15 -0
- package/docs/INTERACTION_PATTERNS.md +5 -1
- package/docs/NEUTRON_STAR_BACKDROP.md +122 -0
- package/docs/ORBIT_BACKDROP.md +38 -46
- package/docs/SETTINGS_DIALOG.md +21 -0
- package/docs/THEME_PICKER.md +26 -0
- package/docs/THEMING.md +2 -0
- package/docs/assets/compact-account-neo.png +0 -0
- package/docs/assets/neutron-star-original.png +0 -0
- package/docs/assets/neutron-star-theme.png +0 -0
- package/docs/assets/orbit-bird.png +0 -0
- package/docs/assets/orbit-crystal.png +0 -0
- package/docs/assets/orbit-strawberry.png +0 -0
- package/package.json +8 -3
- package/preview/CompactAccountGallery.tsx +178 -0
- package/preview/DateRangeFilterGallery.tsx +109 -0
- package/preview/ExtensionsGallery.tsx +27 -18
- package/preview/GalleryShell.tsx +3 -3
- package/preview/MonitoringGallery.tsx +2 -0
- package/preview/NeutronStarGallery.tsx +56 -0
- package/preview/OrbitGallery.tsx +35 -32
- package/preview/OverlayBoundsGallery.tsx +49 -0
- package/preview/SettingsDialogGallery.tsx +33 -0
- package/preview/ThemePickerGallery.tsx +24 -0
- package/preview/ThemeSample.tsx +14 -0
- package/preview/TimelineListGallery.tsx +36 -0
- package/preview/extensions.css +9 -0
- package/preview/main.tsx +16 -3
- package/preview/preview.css +8 -0
- package/preview/theme-sample.html +1 -0
- package/scripts/check-overlay-bounds.js +76 -0
- package/scripts/check-settings-theme.js +88 -0
- package/skills/rhine-design/SKILL.md +3 -2
- package/skills/rhine-design/references/API_MAP.md +13 -2
- package/skills/rhine-design/references/TASK_MAP.md +8 -2
- package/skills/rhine-design/references/integration.md +1 -1
- package/tests/AppShellControls.test.tsx +82 -0
- package/tests/ButtonLink.test.tsx +94 -0
- package/tests/CompactAccountGallery.test.tsx +116 -0
- package/tests/DateRangeFilterGallery.test.tsx +85 -0
- package/tests/NeutronStarBackdrop.test.tsx +288 -0
- package/tests/NeutronStarGallery.test.tsx +62 -0
- package/tests/NeutronStarImport.test.tsx +15 -0
- package/tests/OrbitBackdrop.test.tsx +246 -9
- package/tests/OrbitDonutThree.test.tsx +91 -0
- package/tests/OrbitObjectsThree.test.tsx +164 -0
- package/tests/SettingsDialog.test.tsx +66 -0
- package/tests/StatusTimelineList.test.tsx +63 -0
- package/tests/ThemePicker.test.tsx +30 -0
- package/tests/TimelineLegend.test.tsx +60 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { useId, useLayoutEffect, useRef, useState } from 'react';
|
|
1
|
+
import { useEffect, useId, useLayoutEffect, useRef, useState, type RefObject } from 'react';
|
|
2
2
|
import * as PopoverBase from '@radix-ui/react-popover';
|
|
3
|
+
import { useFloatingScale } from './useFloatingScale';
|
|
3
4
|
import { Text } from './Typography';
|
|
4
5
|
import { KeyValue } from './Data';
|
|
5
6
|
import { Button } from './components';
|
|
@@ -18,6 +19,17 @@ export interface StatusTimelineProps {
|
|
|
18
19
|
}
|
|
19
20
|
export const timelineLabels: Record<TimelineStatus, string> = { healthy: '有输出·未见异常', recovered: '重试已恢复', degraded: '异常·结果待确认', failed: '存在最终失败', unknown: '未知 / 无数据' };
|
|
20
21
|
|
|
22
|
+
function useTimelineHoverExit(trigger: RefObject<HTMLElement | null>, popup: RefObject<HTMLDivElement | null>, close: () => void) {
|
|
23
|
+
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
24
|
+
const cancel = () => { clearTimeout(timer.current); timer.current = undefined; };
|
|
25
|
+
const leave = () => { cancel(); timer.current = setTimeout(() => {
|
|
26
|
+
const keyboardFocus = document.activeElement === trigger.current && trigger.current?.matches(':focus-visible');
|
|
27
|
+
if (!keyboardFocus && !popup.current?.contains(document.activeElement)) close();
|
|
28
|
+
}, 150); };
|
|
29
|
+
useEffect(() => cancel, []);
|
|
30
|
+
return { cancel, leave };
|
|
31
|
+
}
|
|
32
|
+
|
|
21
33
|
/** Host supplies ordered, validated evidence; this component never computes a health score. */
|
|
22
34
|
export function StatusTimeline({ label, segments, startLabel, endLabel, compact = false, disabled = false, activeId, defaultActiveId, onActiveChange, open: controlledOpen, defaultOpen = false, onOpenChange }: StatusTimelineProps) {
|
|
23
35
|
const [localId, setLocalId] = useState(defaultActiveId);
|
|
@@ -25,6 +37,7 @@ export function StatusTimeline({ label, segments, startLabel, endLabel, compact
|
|
|
25
37
|
const open = controlledOpen ?? localOpen;
|
|
26
38
|
const setOpen = (next: boolean) => { if (disabled && next) return; if (controlledOpen === undefined) setLocalOpen(next); if (next !== open) onOpenChange?.(next); };
|
|
27
39
|
const root = useRef<HTMLDivElement>(null), slider = useRef<HTMLInputElement>(null), popup = useRef<HTMLDivElement>(null), returning = useRef(false), focusDetail = useRef(false);
|
|
40
|
+
const detailHadFocus = useRef(false);
|
|
28
41
|
useLayoutEffect(() => { setContainer(root.current?.closest<HTMLElement>('.rhine-theme') ?? document.body); }, []);
|
|
29
42
|
const detailId = useId();
|
|
30
43
|
const matched = segments.findIndex(item => item.id === (activeId ?? localId));
|
|
@@ -33,7 +46,11 @@ export function StatusTimeline({ label, segments, startLabel, endLabel, compact
|
|
|
33
46
|
const segment = segments[selected];
|
|
34
47
|
const summary = segment ? `${segment.label} · ${timelineLabels[segment.status]}` : '暂无记录';
|
|
35
48
|
const hasPopup = !disabled && !!segment && (compact || !!segment.detail);
|
|
36
|
-
|
|
49
|
+
const attachPopup = useFloatingScale(popup);
|
|
50
|
+
const hover = useTimelineHoverExit(slider, popup, () => setOpen(false));
|
|
51
|
+
return <div ref={root} className={`rhine-timeline${compact ? ' rhine-timeline--compact' : ''}`}
|
|
52
|
+
onPointerEnter={hover.cancel}
|
|
53
|
+
onPointerLeave={event => { if (event.pointerType === 'mouse' && !(event.relatedTarget instanceof Node && popup.current?.contains(event.relatedTarget))) hover.leave(); }}>
|
|
37
54
|
<PopoverBase.Root open={open && hasPopup} onOpenChange={setOpen}><PopoverBase.Anchor asChild><div className="rhine-timeline__track">
|
|
38
55
|
<div className="rhine-timeline__bars" aria-hidden="true">{segments.map((item, i) => <span key={item.id} data-status={item.status} data-selected={i === selected} />)}</div>
|
|
39
56
|
<input ref={slider} type="range" min={0} max={Math.max(0, segments.length - 1)} value={Math.max(0, selected)} disabled={disabled || !segments.length}
|
|
@@ -47,8 +64,12 @@ export function StatusTimeline({ label, segments, startLabel, endLabel, compact
|
|
|
47
64
|
onChange={event => { select(Number(event.target.value)); setOpen(true); }}
|
|
48
65
|
onPointerDown={() => setOpen(true)}
|
|
49
66
|
onPointerMove={event => { if (disabled || event.pointerType !== 'mouse' || !segments.length) return; const rect = event.currentTarget.getBoundingClientRect(); if (rect.width) select(Math.min(segments.length - 1, Math.max(0, Math.floor((event.clientX - rect.left) / rect.width * segments.length)))); setOpen(true); }} />
|
|
50
|
-
</div></PopoverBase.Anchor><PopoverBase.Portal container={container}><PopoverBase.Content ref={
|
|
51
|
-
|
|
67
|
+
</div></PopoverBase.Anchor><PopoverBase.Portal container={container}><PopoverBase.Content ref={attachPopup} id={`${detailId}-popup`} className="rhine-timeline-popover" side="top" sideOffset={8} collisionPadding={12} aria-label={`${label}时段详情`}
|
|
68
|
+
onPointerEnter={hover.cancel}
|
|
69
|
+
onPointerLeave={event => { if (event.pointerType === 'mouse' && !(event.relatedTarget instanceof Node && root.current?.contains(event.relatedTarget))) hover.leave(); }}
|
|
70
|
+
onFocusCapture={() => { detailHadFocus.current = true; }}
|
|
71
|
+
onBlurCapture={event => { if (event.relatedTarget && event.relatedTarget !== document.body && !event.currentTarget.contains(event.relatedTarget)) detailHadFocus.current = false; }}
|
|
72
|
+
onOpenAutoFocus={event => { event.preventDefault(); if (focusDetail.current) { focusDetail.current = false; popup.current?.querySelector<HTMLButtonElement>('button')?.focus(); } }} onCloseAutoFocus={event => { event.preventDefault(); focusDetail.current = false; if (detailHadFocus.current && (document.activeElement === document.body || popup.current?.contains(document.activeElement))) { returning.current = true; slider.current?.focus(); } detailHadFocus.current = false; }}
|
|
52
73
|
onInteractOutside={event => { if (root.current?.contains(event.target as Node)) event.preventDefault(); }}>
|
|
53
74
|
<div className="rhine-dialog__head"><strong>{summary}</strong><PopoverBase.Close asChild><Button variant="quiet" aria-label="关闭时段详情">关闭</Button></PopoverBase.Close></div>
|
|
54
75
|
{segment?.detail && <><KeyValue items={[
|
|
@@ -62,6 +83,51 @@ export function StatusTimeline({ label, segments, startLabel, endLabel, compact
|
|
|
62
83
|
</div>;
|
|
63
84
|
}
|
|
64
85
|
|
|
65
|
-
export
|
|
66
|
-
|
|
86
|
+
export interface TimelineLegendItem { status: TimelineStatus; label: string; description?: string }
|
|
87
|
+
export interface TimelineLegendProps { label?: string; compact?: boolean; items?: TimelineLegendItem[] }
|
|
88
|
+
const defaultLegendItems: TimelineLegendItem[] = Object.entries(timelineLabels).map(([status, label]) => ({ status: status as TimelineStatus, label }));
|
|
89
|
+
|
|
90
|
+
/** Short labels describe evidence; hosts provide the precise rules rather than relying on color. */
|
|
91
|
+
export function TimelineLegend({ label = '健康记录图例', compact = false, items = defaultLegendItems }: TimelineLegendProps) {
|
|
92
|
+
const root = useRef<HTMLUListElement>(null);
|
|
93
|
+
const [container, setContainer] = useState<HTMLElement>();
|
|
94
|
+
const [active, setActive] = useState<TimelineStatus | null>(null);
|
|
95
|
+
useLayoutEffect(() => { setContainer(root.current?.closest<HTMLElement>('.rhine-theme') ?? document.body); }, []);
|
|
96
|
+
useEffect(() => { if (active && !items.some(item => item.status === active && item.description)) setActive(null); }, [active, items]);
|
|
97
|
+
return <ul ref={root} className={`rhine-timeline__legend${compact ? ' rhine-timeline__legend--compact' : ''}`} aria-label={label}>
|
|
98
|
+
{items.map(item => <LegendItem key={item.status} item={item} container={container} open={active === item.status}
|
|
99
|
+
onOpenChange={next => setActive(current => next ? item.status : current === item.status ? null : current)} />)}
|
|
100
|
+
</ul>;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function LegendItem({ item, container, open, onOpenChange }: { item: TimelineLegendItem; container?: HTMLElement; open: boolean; onOpenChange: (open: boolean) => void }) {
|
|
104
|
+
const id = useId(), trigger = useRef<HTMLButtonElement>(null), popup = useRef<HTMLDivElement>(null), returning = useRef(false);
|
|
105
|
+
const detailHadFocus = useRef(false);
|
|
106
|
+
const hover = useTimelineHoverExit(trigger, popup, () => onOpenChange(false));
|
|
107
|
+
const attachPopup = useFloatingScale(popup);
|
|
108
|
+
const marker = <span data-status={item.status} aria-hidden="true" />;
|
|
109
|
+
if (!item.description) return <li>{marker}{item.label}</li>;
|
|
110
|
+
return <li><PopoverBase.Root open={open} onOpenChange={onOpenChange}><PopoverBase.Anchor asChild>
|
|
111
|
+
<button ref={trigger} type="button" className="rhine-timeline__legend-trigger" aria-haspopup="dialog" aria-expanded={open} aria-controls={open ? id : undefined}
|
|
112
|
+
onFocus={() => { if (!returning.current) onOpenChange(true); returning.current = false; }}
|
|
113
|
+
onClick={() => onOpenChange(true)}
|
|
114
|
+
onPointerEnter={event => { if (event.pointerType === 'mouse') { hover.cancel(); onOpenChange(true); } }}
|
|
115
|
+
onPointerLeave={event => { if (event.pointerType === 'mouse' && !(event.relatedTarget instanceof Node && popup.current?.contains(event.relatedTarget))) hover.leave(); }}
|
|
116
|
+
onKeyDown={event => {
|
|
117
|
+
if (event.key === 'Escape' && open) { event.preventDefault(); event.stopPropagation(); onOpenChange(false); }
|
|
118
|
+
if ((event.key === 'Enter' || event.key === ' ') && open) { event.preventDefault(); popup.current?.querySelector<HTMLButtonElement>('button')?.focus(); }
|
|
119
|
+
}}>
|
|
120
|
+
{marker}{item.label}
|
|
121
|
+
</button>
|
|
122
|
+
</PopoverBase.Anchor><PopoverBase.Portal container={container}><PopoverBase.Content ref={attachPopup} id={id} className="rhine-timeline-popover" aria-label={`${item.label}规则`} side="top" sideOffset={8} collisionPadding={12}
|
|
123
|
+
onFocusCapture={() => { detailHadFocus.current = true; }}
|
|
124
|
+
onBlurCapture={event => { if (event.relatedTarget && event.relatedTarget !== document.body && !event.currentTarget.contains(event.relatedTarget)) detailHadFocus.current = false; }}
|
|
125
|
+
onOpenAutoFocus={event => event.preventDefault()}
|
|
126
|
+
onCloseAutoFocus={event => { event.preventDefault(); if (detailHadFocus.current && (popup.current?.contains(document.activeElement) || document.activeElement === document.body)) { returning.current = true; trigger.current?.focus(); } detailHadFocus.current = false; }}
|
|
127
|
+
onInteractOutside={event => { if (event.target === trigger.current || trigger.current?.contains(event.target as Node)) event.preventDefault(); }}
|
|
128
|
+
onPointerEnter={hover.cancel}
|
|
129
|
+
onPointerLeave={event => { if (event.pointerType === 'mouse' && !(event.relatedTarget instanceof Node && trigger.current?.contains(event.relatedTarget))) hover.leave(); }}>
|
|
130
|
+
<div className="rhine-dialog__head"><strong>{item.label}</strong><PopoverBase.Close asChild><Button variant="quiet" aria-label={`关闭${item.label}规则`}>关闭</Button></PopoverBase.Close></div>
|
|
131
|
+
<Text>{item.description}</Text>
|
|
132
|
+
</PopoverBase.Content></PopoverBase.Portal></PopoverBase.Root></li>;
|
|
67
133
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { useEffect, useState, type ReactNode } from 'react';
|
|
2
|
+
import { Text } from './Typography';
|
|
3
|
+
import { StatusTimeline, type StatusTimelineProps } from './StatusTimeline';
|
|
4
|
+
|
|
5
|
+
export interface StatusTimelineItem {
|
|
6
|
+
id: string;
|
|
7
|
+
title: string;
|
|
8
|
+
summary?: ReactNode;
|
|
9
|
+
note?: string;
|
|
10
|
+
timeline: Pick<StatusTimelineProps, 'segments' | 'startLabel' | 'endLabel' | 'disabled'> | null;
|
|
11
|
+
unavailableLabel?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface StatusTimelineListProps {
|
|
14
|
+
label: string;
|
|
15
|
+
items: StatusTimelineItem[];
|
|
16
|
+
emptyLabel?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Hosts supply ordering, summaries and evidence; the list only coordinates presentation. */
|
|
20
|
+
export function StatusTimelineList({ label, items, emptyLabel = '暂无历史记录' }: StatusTimelineListProps) {
|
|
21
|
+
const [openId, setOpenId] = useState<string | null>(null);
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
const item = items.find(item => item.id === openId);
|
|
24
|
+
if (openId !== null && (!item?.timeline || item.timeline.disabled || !item.timeline.segments.length)) setOpenId(null);
|
|
25
|
+
}, [items, openId]);
|
|
26
|
+
if (!items.length) return <Text variant="caption">{emptyLabel}</Text>;
|
|
27
|
+
return <ul className="rhine-timeline-list" aria-label={label}>
|
|
28
|
+
{items.map(item => <li className="rhine-timeline-list__item" key={item.id}>
|
|
29
|
+
<div className="rhine-timeline-list__heading"><Text as="h4" variant="body">{item.title}</Text>
|
|
30
|
+
{item.summary != null && <Text variant="caption" tone="muted">{item.summary}</Text>}
|
|
31
|
+
</div>
|
|
32
|
+
{item.note && <Text variant="caption" tone="muted">{item.note}</Text>}
|
|
33
|
+
{item.timeline ? <StatusTimeline {...item.timeline} compact label={`${item.title} 历史时段`}
|
|
34
|
+
open={openId === item.id} onOpenChange={next => setOpenId(current => next ? item.id : current === item.id ? null : current)} />
|
|
35
|
+
: <Text variant="caption" tone="muted">{item.unavailableLabel ?? '历史记录暂不可用 · 未知'}</Text>}
|
|
36
|
+
</li>)}
|
|
37
|
+
</ul>;
|
|
38
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { useId, useState } from 'react';
|
|
2
|
+
import { themes, type ThemeName } from './themeMetadata';
|
|
3
|
+
import rhine from './assets/theme-previews/rhine.png?no-inline';
|
|
4
|
+
import arknights from './assets/theme-previews/arknights.png?no-inline';
|
|
5
|
+
import neoBrutalism from './assets/theme-previews/neo-brutalism.png?no-inline';
|
|
6
|
+
import './theme-picker.css';
|
|
7
|
+
|
|
8
|
+
const sources: Record<ThemeName, string> = { rhine, arknights, 'neo-brutalism': neoBrutalism };
|
|
9
|
+
const descriptions: Record<ThemeName, string> = {
|
|
10
|
+
rhine: '暖白纸面、苔绿操作与细线边界。',
|
|
11
|
+
arknights: '深色工作区、明亮信号与切角边界。',
|
|
12
|
+
'neo-brutalism': '米白表面、亮橙操作与墨色硬阴影。',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export interface ThemePreviewProps { theme: ThemeName; src?: string }
|
|
16
|
+
function PreviewImage({ src }: { src: string }) {
|
|
17
|
+
const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading');
|
|
18
|
+
return <span className="rhine-theme-preview" aria-hidden="true" data-state={state}>
|
|
19
|
+
{state === 'error' ? <span>预览暂不可用</span> : <><img src={src} alt="" width={960} height={600} decoding="async" onLoad={() => setState('ready')} onError={() => setState('error')} />{state === 'loading' && <span className="rhine-theme-preview__loading">正在加载预览</span>}</>}
|
|
20
|
+
</span>;
|
|
21
|
+
}
|
|
22
|
+
/** Decorative, versioned snapshot; it never mounts a nested ThemeProvider. */
|
|
23
|
+
export function ThemePreview({ theme, src = sources[theme] }: ThemePreviewProps) {
|
|
24
|
+
return <PreviewImage key={src} src={src} />;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ThemePickerOption {
|
|
28
|
+
value: ThemeName; label?: string; description?: string; disabled?: boolean; previewSrc?: string;
|
|
29
|
+
}
|
|
30
|
+
export interface ThemePickerProps {
|
|
31
|
+
label?: string; value: ThemeName; onValueChange: (value: ThemeName) => void;
|
|
32
|
+
options?: readonly ThemePickerOption[]; disabled?: boolean;
|
|
33
|
+
}
|
|
34
|
+
/** Controlled native radios: the host owns applying and persisting the theme. */
|
|
35
|
+
export function ThemePicker({ label = '界面主题', value, onValueChange, options = themes.map(theme => ({ value: theme.name })), disabled = false }: ThemePickerProps) {
|
|
36
|
+
const id = useId();
|
|
37
|
+
return <fieldset className="rhine-theme-picker" disabled={disabled}><legend>{label}</legend>
|
|
38
|
+
<div className="rhine-theme-picker__options">{options.map(option => {
|
|
39
|
+
const title = option.label ?? themes.find(theme => theme.name === option.value)!.label;
|
|
40
|
+
const selected = value === option.value;
|
|
41
|
+
const descriptionId = `${id}-${option.value}`;
|
|
42
|
+
return <label className="rhine-theme-option" key={option.value}>
|
|
43
|
+
<input className="rhine-sr-only" type="radio" name={id} value={option.value} checked={selected} disabled={option.disabled} aria-label={title} aria-describedby={descriptionId} onChange={() => onValueChange(option.value)} />
|
|
44
|
+
<ThemePreview theme={option.value} src={option.previewSrc} />
|
|
45
|
+
<span className="rhine-theme-option__copy"><strong>{title}</strong><span className="rhine-theme-option__description" id={descriptionId}>{option.description ?? descriptions[option.value]}</span><span className="rhine-theme-option__status" aria-hidden="true">{selected ? '✓ 已使用' : option.disabled || disabled ? '暂不可用' : '选择应用'}</span></span>
|
|
46
|
+
</label>;
|
|
47
|
+
})}</div>
|
|
48
|
+
</fieldset>;
|
|
49
|
+
}
|