Skip to content

Commit f18aa7a

Browse files
authored
Merge pull request #12 from shellui-dev:feat/api-reference-primitives
feat: api-reference primitives : TypeTable + ComponentPreview
2 parents 0f8e3fb + 8852f71 commit f18aa7a

12 files changed

Lines changed: 538 additions & 15 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
title: ComponentPreview
3+
description: Live-render a registered component by name with declarative props and a reveal-on-click source view.
4+
category: Components
5+
order: 65
6+
---
7+
8+
# ComponentPreview
9+
10+
`<ComponentPreview>` is the declarative-prop cousin of the `razor:preview` fence. Instead of authoring a full razor snippet inside a fenced code block, you pass the target component's **name** as a string plus its props as attributes, and ShellDocs renders it live — the source view is reconstructed from those same props on demand.
11+
12+
## Basic
13+
14+
<ComponentPreview Component="Callout" Variant="info" Title="Heads up">
15+
Body content that becomes the Callout's ChildContent.
16+
</ComponentPreview>
17+
18+
## Self-closing
19+
20+
<ComponentPreview Component="LinkCard" Title="Getting started" Description="Install ShellDocs and scaffold your first docs site." Href="/docs/quick-start" />
21+
22+
## Props
23+
24+
- `Component` — required. The registered tag name (e.g. `"Callout"`, `"Card"`, `"LinkCard"`) to render. Resolved through the same `TypeRegistry` that backs `razor:preview`, so any component `AddShellDocs` registers works here.
25+
- Any other attribute — forwarded to the target component. Attribute values are strings in the markdown; ShellDocs coerces them to each target property's declared type (`bool`, `int`, enums, etc.) at render time.
26+
- `ChildContent` — the tag body becomes the target's `ChildContent` render fragment.
27+
28+
## Notes
29+
30+
- The reconstructed source string is sorted by attribute name for stability and shows the tag as self-closing when there's no body.
31+
- If `Component` doesn't resolve, the render slot shows an inline `Unknown component:` error instead of throwing.
32+
- Prefer `razor:preview` fences for multi-component demos; `<ComponentPreview>` is optimised for single-component prop-focused examples.
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
{
22
"title": "Components",
3-
"pages": ["callout", "card", "code-block", "code-group", "steps", "tabs", "filetree"]
3+
"pages": ["callout", "card", "code-block", "code-group", "steps", "tabs", "filetree", "type-table", "component-preview"]
44
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
title: TypeTable
3+
description: Structured props / API reference table for a component or type.
4+
category: Components
5+
order: 60
6+
---
7+
8+
# TypeTable
9+
10+
`<TypeTable>` is the props / API reference primitive. Nest `<TypeRow>` children — one per prop — and the parent table renders a clean four-column layout (Prop / Type / Default / Description) with type-code chips and a `required` badge.
11+
12+
## Basic
13+
14+
<TypeTable>
15+
<TypeRow Name="Variant" Type="string" Default="info" Description="One of info | warning | danger | tip." />
16+
<TypeRow Name="Title" Type="string" Description="Bold heading line above the body." />
17+
<TypeRow Name="ChildContent" Type="RenderFragment" Description="Body content — markdown or nested Razor." Required="true" />
18+
</TypeTable>
19+
20+
## Props
21+
22+
- `Name` — the prop name shown in the first column (renders as `<code>`)
23+
- `Type` — the type signature, e.g. `string`, `bool`, `int?`, `RenderFragment`
24+
- `Default` — literal default value, omit for none (renders as ``)
25+
- `Description` — free-text explanation, right-aligned column
26+
- `Required` — badge next to the name when the prop must be supplied
27+
28+
## Notes
29+
30+
- Rows render in source order, deduplicated by `Name` — repeated names silently drop.
31+
- Type auto-generation from XML doc comments ships in v2 via `ShellDocs.Xml`.
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
@namespace ShellDocs.Components.Content
2+
@using System.Reflection
3+
@using System.Text
4+
@using ShellDocs.Markdown
5+
@inject TypeRegistry Registry
6+
@inject IJSRuntime JS
7+
8+
<div class="component-preview @(_showSource ? "expanded" : "collapsed")">
9+
<div class="component-preview-render">
10+
@if (_target is not null && _targetParams is not null)
11+
{
12+
<DynamicComponent Type="_target" Parameters="_targetParams" />
13+
}
14+
else
15+
{
16+
<div class="component-preview-error">
17+
Unknown component: <code>@Component</code>
18+
</div>
19+
}
20+
</div>
21+
<div class="component-preview-source-wrap">
22+
<pre class="component-preview-source language-razor" @ref="_sourceEl"><code class="language-razor">@_source</code></pre>
23+
@if (!_showSource)
24+
{
25+
<div class="component-preview-fade">
26+
<button type="button" class="component-preview-expand" @onclick="Show">View source</button>
27+
</div>
28+
}
29+
else
30+
{
31+
<div class="component-preview-actions">
32+
<button type="button" class="component-preview-copy @(_copied ? "copied" : "")" @onclick="Copy" aria-label="Copy source">
33+
@if (_copied)
34+
{
35+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
36+
}
37+
else
38+
{
39+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
40+
}
41+
</button>
42+
<button type="button" class="component-preview-hide" @onclick="Hide">Hide</button>
43+
</div>
44+
}
45+
</div>
46+
</div>
47+
48+
@code {
49+
[Parameter, EditorRequired] public string? Component { get; set; }
50+
[Parameter] public RenderFragment? ChildContent { get; set; }
51+
/* Threaded in by SlotRenderer alongside ChildContent when the tag has body
52+
content — used to reconstruct the source-view string. */
53+
[Parameter] public string? ChildContentSource { get; set; }
54+
[Parameter(CaptureUnmatchedValues = true)]
55+
public IReadOnlyDictionary<string, object>? ExtraProps { get; set; }
56+
57+
private Type? _target;
58+
private IDictionary<string, object>? _targetParams;
59+
private string? _source;
60+
private bool _showSource;
61+
private bool _copied;
62+
private bool _highlighted;
63+
private ElementReference _sourceEl;
64+
65+
protected override void OnParametersSet()
66+
{
67+
_target = Component is null ? null : Registry.Resolve(Component);
68+
_targetParams = _target is null ? null : BuildTargetParams(_target);
69+
_source = _target is null ? null : BuildSource();
70+
}
71+
72+
private IDictionary<string, object> BuildTargetParams(Type target)
73+
{
74+
var dict = new Dictionary<string, object>(StringComparer.Ordinal);
75+
var props = SlotRenderer.GetParameterProps(target);
76+
if (ExtraProps is not null)
77+
{
78+
foreach (var (k, v) in ExtraProps)
79+
{
80+
dict[k] = (v is string s && props.TryGetValue(k, out var prop))
81+
? SlotRenderer.Coerce(s, prop.PropertyType)
82+
: v;
83+
}
84+
}
85+
if (ChildContent is not null) dict["ChildContent"] = ChildContent;
86+
return dict;
87+
}
88+
89+
private string BuildSource()
90+
{
91+
var sb = new StringBuilder();
92+
sb.Append('<').Append(Component);
93+
if (ExtraProps is not null)
94+
{
95+
foreach (var (k, v) in ExtraProps.OrderBy(x => x.Key, StringComparer.Ordinal))
96+
{
97+
sb.Append(' ').Append(k).Append("=\"").Append(v).Append('"');
98+
}
99+
}
100+
var body = ChildContentSource?.Trim();
101+
if (string.IsNullOrEmpty(body))
102+
{
103+
sb.Append(" />");
104+
}
105+
else
106+
{
107+
sb.Append('>').Append(body).Append("</").Append(Component).Append('>');
108+
}
109+
return sb.ToString();
110+
}
111+
112+
private void Show() => _showSource = true;
113+
private void Hide() => _showSource = false;
114+
115+
protected override async Task OnAfterRenderAsync(bool firstRender)
116+
{
117+
if (firstRender && !_highlighted && _source is not null)
118+
{
119+
_highlighted = true;
120+
try { await JS.InvokeVoidAsync("shelldocsHighlightElement", _sourceEl); } catch { }
121+
}
122+
}
123+
124+
private async Task Copy()
125+
{
126+
if (_source is null) return;
127+
try
128+
{
129+
await JS.InvokeVoidAsync("navigator.clipboard.writeText", _source);
130+
_copied = true;
131+
StateHasChanged();
132+
await Task.Delay(1400);
133+
_copied = false;
134+
StateHasChanged();
135+
}
136+
catch { }
137+
}
138+
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
.component-preview {
2+
border: 1px solid var(--border);
3+
border-radius: calc(var(--radius) + 2px);
4+
background: var(--card);
5+
overflow: hidden;
6+
margin: 1.5rem 0;
7+
}
8+
9+
.component-preview-render {
10+
display: flex;
11+
align-items: center;
12+
justify-content: center;
13+
gap: 1rem;
14+
flex-wrap: wrap;
15+
min-height: 12rem;
16+
padding: 2rem 1.5rem;
17+
background:
18+
repeating-linear-gradient(45deg,
19+
color-mix(in oklch, var(--foreground) 2.5%, transparent) 0,
20+
color-mix(in oklch, var(--foreground) 2.5%, transparent) 1px,
21+
transparent 1px, transparent 8px);
22+
}
23+
24+
.component-preview-error {
25+
color: var(--destructive, oklch(0.577 0.245 27.325));
26+
font-family: var(--font-mono);
27+
font-size: 0.8125rem;
28+
}
29+
.component-preview-error code {
30+
background: color-mix(in oklch, var(--destructive, oklch(0.577 0.245 27.325)) 12%, transparent);
31+
padding: 0.1rem 0.4rem;
32+
border-radius: calc(var(--radius) - 4px);
33+
}
34+
35+
.component-preview-source-wrap {
36+
position: relative;
37+
border-top: 1px solid var(--border);
38+
background: color-mix(in oklch, var(--card) 55%, var(--background));
39+
overflow: hidden;
40+
transition: max-height 300ms ease;
41+
}
42+
.component-preview.collapsed .component-preview-source-wrap { max-height: 6rem; }
43+
.component-preview.expanded .component-preview-source-wrap { max-height: none; }
44+
45+
.component-preview-source {
46+
margin: 0;
47+
padding: 1.15rem 1.25rem;
48+
background: transparent;
49+
font-family: var(--font-mono);
50+
font-size: 0.8125rem;
51+
line-height: 1.65;
52+
color: var(--foreground);
53+
overflow-x: auto;
54+
}
55+
.component-preview-source code {
56+
background: transparent !important;
57+
border: 0 !important;
58+
padding: 0 !important;
59+
font-family: var(--font-mono) !important;
60+
font-size: inherit !important;
61+
color: inherit !important;
62+
}
63+
64+
.component-preview-fade {
65+
position: absolute;
66+
inset: 0;
67+
display: flex;
68+
align-items: center;
69+
justify-content: center;
70+
background: linear-gradient(
71+
to bottom,
72+
transparent 0%,
73+
color-mix(in oklch, var(--card) 40%, transparent) 35%,
74+
var(--card) 75%);
75+
pointer-events: none;
76+
}
77+
78+
.component-preview-expand {
79+
pointer-events: auto;
80+
background: var(--card);
81+
border: 1px solid var(--border);
82+
border-radius: calc(var(--radius) - 2px);
83+
color: var(--foreground);
84+
padding: 0.45rem 1rem;
85+
font-family: inherit;
86+
font-size: 0.8125rem;
87+
font-weight: 500;
88+
cursor: pointer;
89+
box-shadow: 0 1px 2px color-mix(in oklch, var(--foreground) 8%, transparent);
90+
transition: background 150ms, border-color 150ms;
91+
}
92+
.component-preview-expand:hover {
93+
background: var(--muted);
94+
border-color: color-mix(in oklch, var(--border) 60%, var(--foreground));
95+
}
96+
97+
.component-preview-actions {
98+
position: absolute;
99+
top: 0.55rem;
100+
right: 0.6rem;
101+
display: flex;
102+
align-items: center;
103+
gap: 0.35rem;
104+
z-index: 1;
105+
}
106+
107+
.component-preview-copy,
108+
.component-preview-hide {
109+
display: inline-flex;
110+
align-items: center;
111+
gap: 0.3rem;
112+
padding: 0.3rem 0.55rem;
113+
background: color-mix(in oklch, var(--card) 92%, var(--foreground));
114+
border: 1px solid var(--border);
115+
border-radius: calc(var(--radius) - 3px);
116+
color: var(--muted-foreground);
117+
font-family: inherit;
118+
font-size: 0.75rem;
119+
font-weight: 500;
120+
cursor: pointer;
121+
transition: color 150ms, background 150ms, border-color 150ms;
122+
}
123+
.component-preview-copy:hover,
124+
.component-preview-hide:hover {
125+
color: var(--foreground);
126+
background: var(--muted);
127+
border-color: color-mix(in oklch, var(--border) 60%, var(--foreground));
128+
}
129+
.component-preview-copy.copied { color: var(--success, oklch(0.723 0.219 149.579)); }
130+
.component-preview-copy svg { width: 0.8125rem; height: 0.8125rem; }

src/ShellDocs.Components/Content/SlotRenderer.cs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,13 +83,19 @@ public static IDictionary<string, object> BuildParameters(
8383
if (!string.IsNullOrWhiteSpace(childContentRaw))
8484
{
8585
dict["ChildContent"] = FromMarkup(renderer, childContentRaw);
86+
/* If the target declares a ChildContentSource [Parameter] (as
87+
ComponentPreview does for reconstructing its source view),
88+
pass the raw markup through unchanged in addition to the
89+
RenderFragment above. */
90+
if (props.ContainsKey("ChildContentSource"))
91+
dict["ChildContentSource"] = childContentRaw;
8692
}
8793
return dict;
8894
}
8995

9096
private static readonly Dictionary<Type, Dictionary<string, PropertyInfo>> _propCache = new();
9197

92-
private static Dictionary<string, PropertyInfo> GetParameterProps(Type t)
98+
internal static Dictionary<string, PropertyInfo> GetParameterProps(Type t)
9399
{
94100
lock (_propCache)
95101
{
@@ -104,7 +110,7 @@ private static Dictionary<string, PropertyInfo> GetParameterProps(Type t)
104110
}
105111
}
106112

107-
private static object Coerce(string raw, Type target)
113+
internal static object Coerce(string raw, Type target)
108114
{
109115
var underlying = Nullable.GetUnderlyingType(target) ?? target;
110116
if (underlying == typeof(string)) return raw;

0 commit comments

Comments
 (0)