feat: scaffold blueprints SPA with List functional-blueprint illustration
This commit is contained in:
579
src/components/ListSpecimen.vue
Normal file
579
src/components/ListSpecimen.vue
Normal file
@@ -0,0 +1,579 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue"
|
||||
import { SAMPLE, type SampleItem } from "@/data/listBlueprint"
|
||||
|
||||
const props = defineProps<{ activeId: string }>()
|
||||
|
||||
interface FrameToolbar {
|
||||
search?: { q?: string; hl?: boolean }
|
||||
sort?: { label: string; hl?: boolean }
|
||||
select?: { meta: string; hl?: boolean }
|
||||
badge?: string
|
||||
badgeNote?: string
|
||||
}
|
||||
interface FrameRow {
|
||||
key: string
|
||||
item?: SampleItem
|
||||
skeleton?: boolean
|
||||
check?: boolean
|
||||
dim?: boolean
|
||||
sel?: boolean
|
||||
win?: boolean
|
||||
ghost?: boolean
|
||||
hlTag?: string
|
||||
hlChev?: boolean
|
||||
swiped?: boolean
|
||||
}
|
||||
interface FrameScrollbar {
|
||||
hl?: boolean
|
||||
top: number
|
||||
h: number
|
||||
}
|
||||
interface Frame {
|
||||
kind: "list" | "display"
|
||||
note?: string
|
||||
toolbar?: FrameToolbar
|
||||
rows: FrameRow[]
|
||||
scrollbar?: FrameScrollbar
|
||||
footer?: { text: string; hl?: boolean }
|
||||
actWrap?: boolean
|
||||
}
|
||||
|
||||
function row(i: number, extra: Partial<FrameRow> = {}): FrameRow {
|
||||
return { key: `r${i}`, item: SAMPLE[i], ...extra }
|
||||
}
|
||||
function skel(i: number): FrameRow {
|
||||
return { key: `s${i}`, skeleton: true }
|
||||
}
|
||||
|
||||
const frame = computed<Frame>(() => {
|
||||
switch (props.activeId) {
|
||||
case "full":
|
||||
return {
|
||||
kind: "list",
|
||||
toolbar: {
|
||||
search: { hl: true },
|
||||
sort: { label: "name ↑", hl: true },
|
||||
select: { meta: "multi", hl: true },
|
||||
},
|
||||
rows: [
|
||||
row(0, { check: false, hlTag: "◈ DISPLAY" }),
|
||||
row(1, { check: true, sel: true }),
|
||||
row(2, { check: false, hlChev: true }),
|
||||
row(3, { check: false }),
|
||||
row(4, { check: false }),
|
||||
],
|
||||
scrollbar: { hl: true, top: 12, h: 44 },
|
||||
footer: { text: "↻ loadMore ▾ · items 5 / 42", hl: true },
|
||||
}
|
||||
case "display":
|
||||
return {
|
||||
kind: "display",
|
||||
note: "Four states, rendered distinctly. The skeleton mirrors item shape so the transition to Idle causes no layout shift.",
|
||||
rows: [],
|
||||
}
|
||||
case "scroll":
|
||||
return {
|
||||
kind: "list",
|
||||
note: "▓ render window — only these rows exist in the DOM. Faded rows are virtualized: not rendered, view references released.",
|
||||
rows: [
|
||||
row(0, { ghost: true }),
|
||||
row(1, { ghost: true }),
|
||||
row(2, { win: true }),
|
||||
row(3, { win: true }),
|
||||
row(4, { win: true }),
|
||||
row(5, { win: true }),
|
||||
row(6, { ghost: true }),
|
||||
row(7, { ghost: true }),
|
||||
],
|
||||
scrollbar: { hl: true, top: 30, h: 40 },
|
||||
}
|
||||
case "load":
|
||||
return {
|
||||
kind: "list",
|
||||
toolbar: { badge: "loadState = LoadingMore", badgeNote: "appending page 2…" },
|
||||
rows: [row(0), row(1), row(2), skel(3), skel(4)],
|
||||
footer: { text: "↻ loadMore ▾ · items 3 / 42", hl: true },
|
||||
}
|
||||
case "filter":
|
||||
return {
|
||||
kind: "list",
|
||||
toolbar: { search: { q: '"b"', hl: true } },
|
||||
note: 'visible = { x ∈ items | name matches "b" } · the underlying items sequence is untouched.',
|
||||
rows: [
|
||||
row(0, { dim: true }),
|
||||
row(1, { hlTag: "◈ visible" }),
|
||||
row(2, { dim: true }),
|
||||
row(3, { dim: true }),
|
||||
row(4, { dim: true }),
|
||||
],
|
||||
}
|
||||
case "sort":
|
||||
return {
|
||||
kind: "list",
|
||||
toolbar: { sort: { label: "name ↓", hl: true } },
|
||||
note: "Same members, new order (name ↓). Identity is unchanged — only the sequence is reordered.",
|
||||
rows: [row(7), row(6), row(5), row(4), row(3)],
|
||||
}
|
||||
case "select":
|
||||
return {
|
||||
kind: "list",
|
||||
toolbar: { select: { meta: "MULTI · 2 selected", hl: true } },
|
||||
note: "selectionMode = Multi · selected = { Item B, Item D } ⊆ visible",
|
||||
rows: [
|
||||
row(0, { check: false }),
|
||||
row(1, { check: true, sel: true }),
|
||||
row(2, { check: false }),
|
||||
row(3, { check: true, sel: true }),
|
||||
row(4, { check: false }),
|
||||
],
|
||||
}
|
||||
case "act":
|
||||
return {
|
||||
kind: "list",
|
||||
note: "swipeAction(Item C, left) → reveals consumer-defined contextual actions. The list does not define them; it exposes the hook.",
|
||||
rows: [row(0), row(1), row(2, { swiped: true }), row(3)],
|
||||
actWrap: true,
|
||||
}
|
||||
default:
|
||||
return { kind: "list", rows: [] }
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="specimen">
|
||||
<!-- Display: distinct states -->
|
||||
<template v-if="frame.kind === 'display'">
|
||||
<p class="note">{{ frame.note }}</p>
|
||||
<div class="display-grid">
|
||||
<div class="mini">
|
||||
<div class="mh">Empty · items = ∅</div>
|
||||
<div class="mb">
|
||||
<div class="empty-state">
|
||||
<div class="big">∅</div>
|
||||
no items — valid state, not an error
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mini">
|
||||
<div class="mh">Loading</div>
|
||||
<div class="mb">
|
||||
<div class="row">
|
||||
<div class="txt">
|
||||
<div class="skel w1" />
|
||||
<div class="skel w2" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="txt">
|
||||
<div class="skel w1" />
|
||||
<div class="skel w2" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="txt">
|
||||
<div class="skel w1" />
|
||||
<div class="skel w2" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mini">
|
||||
<div class="mh">Error</div>
|
||||
<div class="mb">
|
||||
<div class="error-state">⚠ failed to load<br /><span class="retry">↻ retry</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mini">
|
||||
<div class="mh">Idle</div>
|
||||
<div class="mb">
|
||||
<div v-for="i in 3" :key="i" class="mrow">
|
||||
<span class="n">{{ SAMPLE[i - 1].n }}</span
|
||||
><span class="mchev">></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- List-shaped frames -->
|
||||
<template v-else>
|
||||
<p v-if="frame.note" class="note">{{ frame.note }}</p>
|
||||
|
||||
<div v-if="frame.toolbar" class="spec-toolbar">
|
||||
<div v-if="frame.toolbar.badge" class="badge">{{ frame.toolbar.badge }}</div>
|
||||
<span v-if="frame.toolbar.badgeNote" class="badge-note">{{ frame.toolbar.badgeNote }}</span>
|
||||
<div
|
||||
v-if="frame.toolbar.search"
|
||||
class="search"
|
||||
:class="{ hl: frame.toolbar.search.hl }"
|
||||
:data-tag="frame.toolbar.search.hl ? '◈ FILTER' : undefined"
|
||||
>
|
||||
⌕
|
||||
<span v-if="frame.toolbar.search.q" class="q">{{ frame.toolbar.search.q }}</span>
|
||||
<span v-else class="ph">search…</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="frame.toolbar.sort"
|
||||
class="ctl"
|
||||
:class="frame.toolbar.sort.hl ? ['hl', 'tag-r'] : []"
|
||||
:data-tag="frame.toolbar.sort.hl ? '◈ SORT' : undefined"
|
||||
>
|
||||
sort ▾ {{ frame.toolbar.sort.label }}
|
||||
</div>
|
||||
<div
|
||||
v-if="frame.toolbar.select"
|
||||
class="ctl"
|
||||
:class="frame.toolbar.select.hl ? ['hl', 'tag-r'] : []"
|
||||
:data-tag="frame.toolbar.select.hl ? '◈ SELECT' : undefined"
|
||||
>
|
||||
☰ {{ frame.toolbar.select.meta }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="listbox"
|
||||
:class="frame.actWrap ? ['hl', 'tag-b'] : []"
|
||||
:data-tag="frame.actWrap ? '◈ ACT' : undefined"
|
||||
>
|
||||
<div class="listwrap">
|
||||
<div class="rows">
|
||||
<div
|
||||
v-for="r in frame.rows"
|
||||
:key="r.key"
|
||||
class="row"
|
||||
:class="{ dim: r.dim, sel: r.sel, win: r.win, ghost: r.ghost, swiped: r.swiped }"
|
||||
>
|
||||
<span v-if="r.check !== undefined" class="chk" :class="{ on: r.check }">{{
|
||||
r.check ? "✓" : ""
|
||||
}}</span>
|
||||
<div v-if="r.skeleton" class="txt">
|
||||
<div class="skel w1" />
|
||||
<div class="skel w2" />
|
||||
</div>
|
||||
<div v-else class="txt" :class="{ hl: r.hlTag }" :data-tag="r.hlTag">
|
||||
<div class="n">{{ r.item?.n }}</div>
|
||||
<div class="s">{{ r.item?.s }}</div>
|
||||
</div>
|
||||
<template v-if="r.swiped">
|
||||
<span class="swipehint">◂ swiped</span>
|
||||
<div class="actions">
|
||||
<span class="act archive">Archive</span><span class="act delete">Delete</span>
|
||||
</div>
|
||||
</template>
|
||||
<span
|
||||
v-else
|
||||
class="chev"
|
||||
:class="r.hlChev ? ['hl', 'tag-r'] : []"
|
||||
:data-tag="r.hlChev ? '◈ ACT' : undefined"
|
||||
>></span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="frame.scrollbar"
|
||||
class="scrollbar"
|
||||
:class="frame.scrollbar.hl ? ['hl', 'tag-r'] : []"
|
||||
:data-tag="frame.scrollbar.hl ? '◈ SCROLL' : undefined"
|
||||
>
|
||||
<div
|
||||
class="thumb"
|
||||
:style="{ top: frame.scrollbar.top + '%', height: frame.scrollbar.h + '%' }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="frame.footer"
|
||||
class="footer-load"
|
||||
:class="frame.footer.hl ? ['hl', 'tag-b'] : []"
|
||||
:data-tag="frame.footer.hl ? '◈ LOAD' : undefined"
|
||||
>
|
||||
{{ frame.footer.text }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.specimen {
|
||||
--ink: var(--color-base-content);
|
||||
--ink-faint: #4f7099;
|
||||
--amber: var(--color-accent);
|
||||
--amber-dim: color-mix(in srgb, var(--color-accent) 14%, transparent);
|
||||
--cyan: var(--color-secondary);
|
||||
--red: var(--color-error);
|
||||
--paper: var(--color-base-100);
|
||||
--line: rgba(200, 226, 255, 0.14);
|
||||
--line-strong: rgba(200, 226, 255, 0.3);
|
||||
font-size: 13px;
|
||||
}
|
||||
.note {
|
||||
color: var(--ink-faint);
|
||||
font-size: 11px;
|
||||
font-style: italic;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
.spec-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
color: color-mix(in srgb, var(--ink) 60%, transparent);
|
||||
font-size: 12px;
|
||||
}
|
||||
.badge-note {
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
.search {
|
||||
flex: 1;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--paper);
|
||||
padding: 5px 9px;
|
||||
color: var(--ink);
|
||||
}
|
||||
.ph {
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
.q {
|
||||
color: var(--amber);
|
||||
}
|
||||
.ctl {
|
||||
border: 1px solid var(--line);
|
||||
padding: 5px 9px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.badge {
|
||||
border: 1px solid var(--line-strong);
|
||||
color: var(--cyan);
|
||||
padding: 1px 7px;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.listbox {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--paper);
|
||||
position: relative;
|
||||
}
|
||||
.listwrap {
|
||||
display: flex;
|
||||
}
|
||||
.rows {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 11px;
|
||||
border-bottom: 1px dashed var(--line);
|
||||
position: relative;
|
||||
}
|
||||
.row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.chk {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 1px solid var(--ink-faint);
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
color: var(--amber);
|
||||
}
|
||||
.chk.on {
|
||||
border-color: var(--amber);
|
||||
background: var(--amber-dim);
|
||||
}
|
||||
.txt {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.txt .n {
|
||||
color: var(--ink);
|
||||
}
|
||||
.txt .s {
|
||||
color: var(--ink-faint);
|
||||
font-size: 11px;
|
||||
}
|
||||
.chev {
|
||||
color: color-mix(in srgb, var(--ink) 60%, transparent);
|
||||
}
|
||||
.row.dim {
|
||||
opacity: 0.32;
|
||||
}
|
||||
.row.dim .n {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
.row.sel {
|
||||
background: var(--amber-dim);
|
||||
}
|
||||
.row.win {
|
||||
background: rgba(143, 208, 255, 0.07);
|
||||
}
|
||||
.row.ghost {
|
||||
opacity: 0.4;
|
||||
}
|
||||
.row.ghost .n,
|
||||
.row.ghost .s {
|
||||
color: transparent;
|
||||
}
|
||||
.skel {
|
||||
height: 10px;
|
||||
background: repeating-linear-gradient(
|
||||
90deg,
|
||||
var(--ink-faint),
|
||||
var(--ink-faint) 6px,
|
||||
transparent 6px,
|
||||
transparent 12px
|
||||
);
|
||||
opacity: 0.5;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.skel.w1 {
|
||||
width: 60%;
|
||||
}
|
||||
.skel.w2 {
|
||||
width: 40%;
|
||||
margin-top: 5px;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
flex: none;
|
||||
}
|
||||
.actions .act {
|
||||
padding: 9px 12px;
|
||||
font-size: 11px;
|
||||
border-left: 1px solid var(--line);
|
||||
}
|
||||
.actions .archive {
|
||||
background: rgba(143, 208, 255, 0.12);
|
||||
color: var(--cyan);
|
||||
}
|
||||
.actions .delete {
|
||||
background: rgba(255, 143, 143, 0.14);
|
||||
color: var(--red);
|
||||
}
|
||||
.swipehint {
|
||||
color: var(--ink-faint);
|
||||
font-size: 10px;
|
||||
margin-left: auto;
|
||||
padding-right: 8px;
|
||||
}
|
||||
.row.swiped {
|
||||
padding-right: 0;
|
||||
}
|
||||
.scrollbar {
|
||||
width: 8px;
|
||||
flex: none;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-left: 1px solid var(--line);
|
||||
position: relative;
|
||||
}
|
||||
.scrollbar .thumb {
|
||||
position: absolute;
|
||||
left: 1px;
|
||||
right: 1px;
|
||||
background: var(--ink-faint);
|
||||
opacity: 0.6;
|
||||
}
|
||||
.footer-load {
|
||||
padding: 8px 11px;
|
||||
border-top: 1px solid var(--line);
|
||||
text-align: center;
|
||||
color: var(--cyan);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.display-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
.mini {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--paper);
|
||||
}
|
||||
.mini .mh {
|
||||
padding: 4px 8px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
color: color-mix(in srgb, var(--ink) 60%, transparent);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.mini .mb {
|
||||
padding: 8px;
|
||||
}
|
||||
.mini .mrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 5px 4px;
|
||||
border-bottom: 1px dashed var(--line);
|
||||
font-size: 11px;
|
||||
}
|
||||
.mini .mrow:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.mini .mrow .n {
|
||||
color: var(--ink);
|
||||
}
|
||||
.mini .mchev {
|
||||
margin-left: auto;
|
||||
color: color-mix(in srgb, var(--ink) 60%, transparent);
|
||||
}
|
||||
.empty-state {
|
||||
padding: 20px 12px;
|
||||
text-align: center;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
.empty-state .big {
|
||||
font-size: 22px;
|
||||
color: color-mix(in srgb, var(--ink) 60%, transparent);
|
||||
}
|
||||
.error-state {
|
||||
padding: 18px 12px;
|
||||
text-align: center;
|
||||
color: var(--red);
|
||||
line-height: 1.9;
|
||||
}
|
||||
.error-state .retry {
|
||||
color: var(--cyan);
|
||||
}
|
||||
|
||||
/* callouts */
|
||||
.hl {
|
||||
outline: 1.5px dashed var(--amber);
|
||||
outline-offset: 3px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
.hl[data-tag]::after {
|
||||
content: attr(data-tag);
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
left: 8px;
|
||||
background: var(--paper);
|
||||
color: var(--amber);
|
||||
font-size: 9px;
|
||||
padding: 0 5px;
|
||||
border: 1px solid var(--amber);
|
||||
white-space: nowrap;
|
||||
letter-spacing: 0.1em;
|
||||
line-height: 1.5;
|
||||
z-index: 3;
|
||||
}
|
||||
.hl.tag-r[data-tag]::after {
|
||||
left: auto;
|
||||
right: 8px;
|
||||
}
|
||||
.hl.tag-b[data-tag]::after {
|
||||
top: auto;
|
||||
bottom: -10px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user