brustjs 0.1.28-alpha → 0.1.29-alpha
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/example/pokedex/app.css +8 -1712
- package/example/pokedex/components/AddToTeamButton.tsx +36 -19
- package/example/pokedex/components/AppLayout.tsx +48 -50
- package/example/pokedex/components/Breadcrumb.tsx +49 -0
- package/example/pokedex/components/DexFilter.tsx +108 -0
- package/example/pokedex/components/HeroSearch.tsx +51 -0
- package/example/pokedex/components/NavLink.tsx +16 -23
- package/example/pokedex/components/NavPreloader.tsx +7 -3
- package/example/pokedex/components/TeamBuilder.tsx +48 -131
- package/example/pokedex/components/ThemeToggle.tsx +22 -11
- package/example/pokedex/lib/loaders.ts +125 -116
- package/example/pokedex/lib/pokeapi.ts +21 -21
- package/example/pokedex/lib/team-store.ts +1 -1
- package/example/pokedex/lib/types.ts +72 -94
- package/example/pokedex/pages/BrowsePage.tsx +30 -0
- package/example/pokedex/pages/DetailPage.tsx +176 -91
- package/example/pokedex/pages/HomePage.tsx +229 -0
- package/example/pokedex/pages/TypeChart.tsx +46 -27
- package/example/pokedex/routes.tsx +9 -20
- package/example/pokedex/stores/team.ts +1 -1
- package/package.json +8 -7
- package/runtime/cli/native-routes-emit.ts +223 -63
- package/runtime/cli/templates.ts +7 -4
- package/runtime/index.js +52 -52
- package/example/pokedex/pages/ListPage.tsx +0 -76
|
@@ -1,36 +1,50 @@
|
|
|
1
1
|
// NATIVE INTERACTIVE COMPONENT (Spec B dogfood) — the "Add to team / In your
|
|
2
|
-
// team" toggle on the detail page.
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
2
|
+
// team" toggle on the detail page. A single-file native directive component: a
|
|
3
|
+
// co-located `export const behavior` (client logic, react-free) + a JSX
|
|
4
|
+
// `default` export (the native template the compiler lowers to minijinja). The
|
|
5
|
+
// build bundles ONLY `behavior` into _directives.js; the JSX default is
|
|
6
|
+
// tree-shaken out so react never leaks into the client bundle.
|
|
7
7
|
//
|
|
8
8
|
// The behavior is react-free: `signal`/`computed` from brustjs/store (the window
|
|
9
9
|
// singleton on the client), `client` from brustjs/client (the treaty action
|
|
10
10
|
// client — also react-free), and the shared teamStore. NO react imports.
|
|
11
|
+
import { Plus } from 'lucide-react'
|
|
11
12
|
import { client } from 'brustjs/client'
|
|
12
13
|
import { computed, signal } from 'brustjs/store'
|
|
13
14
|
import type { Actions } from '../actions'
|
|
14
|
-
import type {
|
|
15
|
+
import type { TeamMember } from '../lib/types'
|
|
15
16
|
import { teamStore } from '../stores/team'
|
|
16
17
|
|
|
17
18
|
const api = client<Actions>()
|
|
18
19
|
|
|
20
|
+
interface AddToTeamProps {
|
|
21
|
+
id: number
|
|
22
|
+
name: string
|
|
23
|
+
displayName: string
|
|
24
|
+
num: string
|
|
25
|
+
types: string[]
|
|
26
|
+
artwork: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const BASE =
|
|
30
|
+
'inline-flex w-full items-center justify-center rounded-lg bg-brand-500 px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-brand-600 disabled:opacity-50'
|
|
31
|
+
const IN_TEAM =
|
|
32
|
+
'inline-flex w-full items-center justify-center rounded-lg bg-slate-200 px-4 py-2.5 text-sm font-semibold text-slate-700 transition-colors disabled:opacity-50 dark:bg-slate-700 dark:text-slate-100'
|
|
33
|
+
|
|
19
34
|
// behavior → client bundle, registered as "addToTeamButton" (camelCase filename).
|
|
20
35
|
// `props` is the JSON parsed out of the element's x-props attribute (precomputed
|
|
21
36
|
// by the loader as a JSON string — native templates can't call JSON.stringify).
|
|
22
37
|
export const behavior = ({ props }: { props: AddToTeamProps }) => {
|
|
23
|
-
// Shared store (GAP S4): writing teamStore.members here is observed by the
|
|
24
|
-
// TeamBuilder island — they resolve the same window singleton. A native
|
|
25
|
-
// x-on-click mutation is therefore seen reactively by a React island.
|
|
26
38
|
const busy = signal(false)
|
|
27
39
|
const full = signal(false)
|
|
28
|
-
const inTeam = computed(() =>
|
|
29
|
-
|
|
30
|
-
inTeam() ? '✓ In your team' : disabled() || full() ? 'Team Full' : '+ Add to team',
|
|
40
|
+
const inTeam = computed(() =>
|
|
41
|
+
((teamStore.members() ?? []) as TeamMember[]).some((m) => m.id === props.id),
|
|
31
42
|
)
|
|
32
|
-
const btnClass = computed(() => `aa-btn aa-btn--full${inTeam() ? ' aa-btn--secondary' : ''}`)
|
|
33
43
|
const disabled = computed(() => busy() || ((teamStore.members()?.length ?? 0) >= 6 && !inTeam()))
|
|
44
|
+
const label = computed(() =>
|
|
45
|
+
inTeam() ? 'In your team' : disabled() || full() ? 'Team Full' : 'Add to team',
|
|
46
|
+
)
|
|
47
|
+
const btnClass = computed(() => (inTeam() ? IN_TEAM : BASE))
|
|
34
48
|
|
|
35
49
|
async function init() {
|
|
36
50
|
const r = await api.team.get()
|
|
@@ -41,7 +55,6 @@ export const behavior = ({ props }: { props: AddToTeamProps }) => {
|
|
|
41
55
|
busy.set(true)
|
|
42
56
|
try {
|
|
43
57
|
if (inTeam()) {
|
|
44
|
-
// Bodyless DELETE is OK now (GAPS S12 fixed) — no more `.delete({})`.
|
|
45
58
|
const { data } = await api.team({ id: props.id }).delete()
|
|
46
59
|
if (data) teamStore.members.set(data.team)
|
|
47
60
|
} else {
|
|
@@ -57,7 +70,7 @@ export const behavior = ({ props }: { props: AddToTeamProps }) => {
|
|
|
57
70
|
full.set(false)
|
|
58
71
|
teamStore.members.set(data.team)
|
|
59
72
|
} else if (error?.value.code === 'TEAM_FULL') {
|
|
60
|
-
full.set(true)
|
|
73
|
+
full.set(true)
|
|
61
74
|
}
|
|
62
75
|
}
|
|
63
76
|
} finally {
|
|
@@ -74,17 +87,21 @@ export const behavior = ({ props }: { props: AddToTeamProps }) => {
|
|
|
74
87
|
// string, emitted by the compiler as x-props="{{ (data) | e }}" (XSS-safe).
|
|
75
88
|
export default function AddToTeamButton({ data }: { data: string }) {
|
|
76
89
|
return (
|
|
77
|
-
<div x-data="addToTeamButton" x-props={data}
|
|
90
|
+
<div x-data="addToTeamButton" x-props={data} className="relative">
|
|
91
|
+
<Plus
|
|
92
|
+
size={16}
|
|
93
|
+
className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-white"
|
|
94
|
+
isr={{ key: 'LcIconPlus' }}
|
|
95
|
+
/>
|
|
78
96
|
<button
|
|
79
97
|
type="button"
|
|
80
98
|
x-text="label"
|
|
81
99
|
x-bind-class="btnClass"
|
|
82
100
|
x-bind-disabled="disabled"
|
|
83
101
|
x-on-click="toggle"
|
|
84
|
-
className="
|
|
85
|
-
style={{ width: '100%' }}
|
|
102
|
+
className="inline-flex w-full items-center justify-center rounded-lg bg-brand-500 px-4 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-brand-600 disabled:opacity-50"
|
|
86
103
|
>
|
|
87
|
-
|
|
104
|
+
Add to team
|
|
88
105
|
</button>
|
|
89
106
|
</div>
|
|
90
107
|
)
|
|
@@ -1,20 +1,15 @@
|
|
|
1
1
|
// Router-level native layout (Approach a: nested routes + <Outlet/>). Written
|
|
2
2
|
// ONCE and nested as the parent of every page route in routes.tsx. The synth
|
|
3
|
-
// wrapper the compiler builds is `<AppLayout native><Leaf native/></AppLayout
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// the
|
|
8
|
-
// below are destructured so the native compiler resolves them as member-paths
|
|
9
|
-
// ({{ title }}, active === 'list', <TeamBuilder props={teamProps}/>).
|
|
3
|
+
// wrapper the compiler builds is `<AppLayout native><Leaf native/></AppLayout>`.
|
|
4
|
+
// The chrome data it renders (title / crumb / teamProps / mode) comes from the
|
|
5
|
+
// MERGED LOADER CONTEXT: each leaf loader returns those fields and the
|
|
6
|
+
// chain-loader merge folds them into one flat jinja context; the names below are
|
|
7
|
+
// destructured so the native compiler resolves them as member-paths.
|
|
10
8
|
//
|
|
11
|
-
// MUST stay single-return with
|
|
9
|
+
// MUST stay single-return with NO local bindings above it — a local `const`
|
|
12
10
|
// would make the compiler soft-fall-back to an SSR component (no <html> shell).
|
|
13
|
-
//
|
|
14
|
-
|
|
15
|
-
// (no SSR `active` conditional, no data-brust-active-nav magic). AppLayout owns the
|
|
16
|
-
// single <main> (leaves are fragments in the <Outlet/> slot) — a leaf adding its
|
|
17
|
-
// own <main> breaks SPA-nav extraction. See ../FRAMEWORK-GAPS.md.
|
|
11
|
+
// AppLayout owns the single <main> (leaves are fragments in the <Outlet/> slot).
|
|
12
|
+
import { GitFork } from 'lucide-react'
|
|
18
13
|
import { BrustPage, Island, Outlet } from 'brustjs'
|
|
19
14
|
import type { TeamMember } from '../lib/types'
|
|
20
15
|
import NavLink from './NavLink'
|
|
@@ -24,12 +19,10 @@ import ThemeToggle from './ThemeToggle'
|
|
|
24
19
|
|
|
25
20
|
export default function AppLayout({
|
|
26
21
|
title,
|
|
27
|
-
crumb,
|
|
28
22
|
teamProps,
|
|
29
23
|
mode,
|
|
30
24
|
}: {
|
|
31
25
|
title: string
|
|
32
|
-
crumb: string
|
|
33
26
|
teamProps: { teamInitial: TeamMember[] }
|
|
34
27
|
mode: 'dark' | 'light'
|
|
35
28
|
}) {
|
|
@@ -40,48 +33,53 @@ export default function AppLayout({
|
|
|
40
33
|
title={title}
|
|
41
34
|
head={[{ tag: 'link', rel: 'icon', href: '/favicon.svg' }]}
|
|
42
35
|
>
|
|
43
|
-
<div className="
|
|
44
|
-
<
|
|
45
|
-
<
|
|
46
|
-
<
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
<span className="aa-sidebar__env">native</span>
|
|
52
|
-
</div>
|
|
53
|
-
<nav className="aa-sidebar__nav">
|
|
54
|
-
<div className="aa-sidebar__group-title">Pokédex</div>
|
|
55
|
-
<NavLink native href="/" label="All Pokémon" />
|
|
56
|
-
<NavLink native href="/type-chart" label="Type chart" count="native" />
|
|
57
|
-
</nav>
|
|
58
|
-
<div className="aa-sidebar__user">
|
|
59
|
-
<span className="aa-avatar aa-avatar--sm dex-brand-avatar">B</span>
|
|
60
|
-
<div className="grow truncate dex-user">
|
|
61
|
-
<div className="dex-user__name">brust dev</div>
|
|
62
|
-
<div className="dex-user__host">localhost</div>
|
|
63
|
-
</div>
|
|
64
|
-
</div>
|
|
65
|
-
</aside>
|
|
66
|
-
|
|
67
|
-
<main className="aa-main">
|
|
68
|
-
<header className="aa-topbar">
|
|
69
|
-
<div className="aa-topbar__crumb">
|
|
70
|
-
<a href="/" className="dex-crumb__root">
|
|
36
|
+
<div className="min-h-screen bg-slate-50 text-slate-900 dark:bg-slate-950 dark:text-slate-100">
|
|
37
|
+
<header className="sticky top-0 z-50 border-b border-slate-200 bg-white/80 backdrop-blur-md dark:border-slate-800 dark:bg-slate-950/80">
|
|
38
|
+
<nav className="mx-auto flex h-16 max-w-6xl items-center gap-2 px-4">
|
|
39
|
+
<a href="/" className="mr-2 flex items-center gap-2 no-underline">
|
|
40
|
+
<span className="grid h-8 w-8 place-items-center rounded-lg bg-brand-500 text-sm font-extrabold text-white">
|
|
41
|
+
P
|
|
42
|
+
</span>
|
|
43
|
+
<span className="text-base font-extrabold tracking-tight text-slate-900 dark:text-white">
|
|
71
44
|
PokéDex
|
|
72
|
-
</
|
|
73
|
-
|
|
74
|
-
|
|
45
|
+
</span>
|
|
46
|
+
</a>
|
|
47
|
+
<NavLink native href="/" label="Home" />
|
|
48
|
+
<NavLink native href="/pokedex" label="Pokédex" />
|
|
49
|
+
<NavLink native href="/type-chart" label="Type chart" />
|
|
50
|
+
<div className="ml-auto flex items-center gap-2">
|
|
51
|
+
<ThemeToggle native />
|
|
52
|
+
{/* Team dock opens via the TeamBuilder island's own floating trigger
|
|
53
|
+
(the island owns its open state). A navbar button here would be a
|
|
54
|
+
dead affordance — cross-chunk toggle wiring is out of scope. */}
|
|
75
55
|
</div>
|
|
76
|
-
|
|
77
|
-
|
|
56
|
+
</nav>
|
|
57
|
+
</header>
|
|
78
58
|
|
|
79
|
-
|
|
59
|
+
<main className="mx-auto max-w-6xl px-4 py-8">
|
|
60
|
+
<div className="container">
|
|
80
61
|
<Outlet />
|
|
81
62
|
</div>
|
|
82
63
|
</main>
|
|
83
64
|
|
|
84
|
-
<
|
|
65
|
+
<footer className="flex items-center justify-center gap-3 border-t border-slate-200 py-6 text-center text-xs text-slate-400 dark:border-slate-800">
|
|
66
|
+
<span>Built with brust · data from PokeAPI</span>
|
|
67
|
+
<a
|
|
68
|
+
href="https://github.com/AssetsArt/brust"
|
|
69
|
+
className="inline-flex items-center text-slate-400 transition-colors hover:text-slate-600 dark:hover:text-slate-200"
|
|
70
|
+
aria-label="brust on GitHub"
|
|
71
|
+
>
|
|
72
|
+
<GitFork size={16} isr={{ key: 'LcIconGitFork' }} />
|
|
73
|
+
</a>
|
|
74
|
+
</footer>
|
|
75
|
+
|
|
76
|
+
<Island
|
|
77
|
+
component={TeamBuilder}
|
|
78
|
+
props={teamProps}
|
|
79
|
+
ssr
|
|
80
|
+
hydrate="load"
|
|
81
|
+
isr={{ key: 'TeamBuilderDock', tags: ['team'] }}
|
|
82
|
+
/>
|
|
85
83
|
<Island component={NavPreloader} hydrate="load" />
|
|
86
84
|
</div>
|
|
87
85
|
</BrustPage>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// NATIVE INTERACTIVE COMPONENT — the breadcrumb leaf. It lives in AppLayout's
|
|
2
|
+
// persistent shell (OUTSIDE <main>), so the SSR-baked `crumb` would go stale on
|
|
3
|
+
// SPA navigation (only <main>/<Outlet> swaps; the shell does not re-render). Like
|
|
4
|
+
// NavLink, the fix is to WATCH the navigation store: a `computed` over `nav.path()`
|
|
5
|
+
// re-derives the label on every SPA nav, so the breadcrumb updates without a reload.
|
|
6
|
+
//
|
|
7
|
+
// Single-file component: `export const behavior` → _directives.js (react-free);
|
|
8
|
+
// the JSX default → the native template the compiler lowers to minijinja. The SSR
|
|
9
|
+
// text is the loader's `crumb` (correct for the first paint); the behavior takes
|
|
10
|
+
// over on hydration and on each navigation.
|
|
11
|
+
import { nav } from 'brustjs/navigation'
|
|
12
|
+
import { computed } from 'brustjs/store'
|
|
13
|
+
|
|
14
|
+
const STATIC_LABELS: Record<string, string> = {
|
|
15
|
+
'/': 'Home',
|
|
16
|
+
'/pokedex': 'Pokédex',
|
|
17
|
+
'/type-chart': 'Type chart',
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Capitalize + de-dash a path segment (mirrors pokeapi `cap`, inlined to keep the
|
|
21
|
+
// directive chunk free of the loader/fetch module).
|
|
22
|
+
const cap = (s: string): string =>
|
|
23
|
+
s ? s.charAt(0).toUpperCase() + s.slice(1).replace(/-/g, ' ') : s
|
|
24
|
+
|
|
25
|
+
/** Derive the crumb label from the current path — the detail route carries the
|
|
26
|
+
* Pokémon name in the path itself (`/pokemon/ivysaur` → "Ivysaur"), so no loader
|
|
27
|
+
* data is needed client-side. */
|
|
28
|
+
function deriveCrumb(path: string): string {
|
|
29
|
+
if (STATIC_LABELS[path]) return STATIC_LABELS[path] as string
|
|
30
|
+
const seg = path.split('/').filter(Boolean)
|
|
31
|
+
if (seg[0] === 'pokemon' && seg[1]) return cap(seg[1])
|
|
32
|
+
return cap(seg[seg.length - 1] ?? '') || 'Home'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// behavior → registered as "breadcrumb". Watches nav.path; x-text binds `label`.
|
|
36
|
+
export const behavior = () => {
|
|
37
|
+
const label = computed(() => deriveCrumb(nav.path()))
|
|
38
|
+
return { label }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// default → jinja. SSR renders the loader `crumb`; `x-text="label"` overrides it on
|
|
42
|
+
// bind and on every SPA navigation.
|
|
43
|
+
export default function Breadcrumb({ crumb }: { crumb: string }) {
|
|
44
|
+
return (
|
|
45
|
+
<b x-data="breadcrumb" x-text="label" className="text-slate-600 dark:text-slate-300">
|
|
46
|
+
{crumb}
|
|
47
|
+
</b>
|
|
48
|
+
)
|
|
49
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// NATIVE INTERACTIVE COMPONENT — the browse-page dex grid with live search +
|
|
2
|
+
// sort. The LOAD-BEARING dogfood of keyed `x-for` (0.1.28-alpha): the grid
|
|
3
|
+
// reconciles by `c.id` as the filtered/sorted list changes, no full re-render.
|
|
4
|
+
//
|
|
5
|
+
// Single-file native directive: `export const behavior` (react-free client
|
|
6
|
+
// logic) bundled into _directives.js as "dexFilter"; the JSX default lowered to
|
|
7
|
+
// minijinja. The full item list arrives as the loader-precomputed `x-props`
|
|
8
|
+
// JSON string (native templates can't call JSON.stringify), parsed into the
|
|
9
|
+
// behavior `props`. NO react imports — `signal`/`computed` from brustjs/store.
|
|
10
|
+
import { ArrowDownAZ, Hash, Search } from 'lucide-react'
|
|
11
|
+
import { computed, signal } from 'brustjs/store'
|
|
12
|
+
|
|
13
|
+
interface Card {
|
|
14
|
+
id: number
|
|
15
|
+
name: string
|
|
16
|
+
displayName: string
|
|
17
|
+
num: string
|
|
18
|
+
artwork: string
|
|
19
|
+
detailHref: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const behavior = ({ props }: { el: HTMLElement; props: unknown }) => {
|
|
23
|
+
const all = ((props as { items?: Card[] })?.items ?? []) as Card[]
|
|
24
|
+
const q = signal('')
|
|
25
|
+
const sortAz = signal(false)
|
|
26
|
+
const filtered = computed(() => {
|
|
27
|
+
const needle = q().trim().toLowerCase()
|
|
28
|
+
let out = needle ? all.filter((c) => c.name.includes(needle)) : all.slice()
|
|
29
|
+
if (sortAz()) out = out.slice().sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
|
|
30
|
+
return out
|
|
31
|
+
})
|
|
32
|
+
const onInput = (e: Event) => q.set((e.target as HTMLInputElement).value)
|
|
33
|
+
const setDex = () => sortAz.set(false)
|
|
34
|
+
const setAz = () => sortAz.set(true)
|
|
35
|
+
const countLabel = computed(() => `${filtered().length} / ${all.length}`)
|
|
36
|
+
return { q, sortAz, filtered, onInput, setDex, setAz, countLabel }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export default function DexFilter({ data }: { data?: string }) {
|
|
40
|
+
return (
|
|
41
|
+
<section x-data="dexFilter" x-props={data}>
|
|
42
|
+
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
43
|
+
<div className="relative w-full sm:max-w-xs">
|
|
44
|
+
<Search
|
|
45
|
+
size={16}
|
|
46
|
+
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-slate-400"
|
|
47
|
+
isr={{ key: 'LcIconSearchField' }}
|
|
48
|
+
/>
|
|
49
|
+
<input
|
|
50
|
+
type="search"
|
|
51
|
+
x-on-input="onInput"
|
|
52
|
+
placeholder="Search Pokémon…"
|
|
53
|
+
className="w-full rounded-xl border border-slate-200 bg-white py-2.5 pl-9 pr-4 text-sm text-slate-900 shadow-sm transition-colors placeholder:text-slate-400 focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/30 dark:border-slate-700 dark:bg-slate-900 dark:text-white"
|
|
54
|
+
/>
|
|
55
|
+
</div>
|
|
56
|
+
<div className="flex items-center gap-3">
|
|
57
|
+
<div className="inline-flex overflow-hidden rounded-xl border border-slate-200 dark:border-slate-700">
|
|
58
|
+
<button
|
|
59
|
+
type="button"
|
|
60
|
+
x-on-click="setDex"
|
|
61
|
+
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800"
|
|
62
|
+
>
|
|
63
|
+
<Hash size={14} isr={{ key: 'LcIconHash14' }} />
|
|
64
|
+
Dex#
|
|
65
|
+
</button>
|
|
66
|
+
<button
|
|
67
|
+
type="button"
|
|
68
|
+
x-on-click="setAz"
|
|
69
|
+
className="inline-flex items-center gap-1.5 border-l border-slate-200 px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-100 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
|
|
70
|
+
>
|
|
71
|
+
<ArrowDownAZ size={14} isr={{ key: 'LcIconArrowDownAZ14' }} />
|
|
72
|
+
A–Z
|
|
73
|
+
</button>
|
|
74
|
+
</div>
|
|
75
|
+
<span
|
|
76
|
+
x-text="countLabel"
|
|
77
|
+
className="rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold tabular-nums text-slate-500 dark:bg-slate-800 dark:text-slate-400"
|
|
78
|
+
/>
|
|
79
|
+
</div>
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
|
|
83
|
+
{/* biome-ignore lint/a11y/useValidAnchor: href is bound at hydration via x-bind-href (the template clone gets c.detailHref) */}
|
|
84
|
+
<a
|
|
85
|
+
x-for="c in filtered by c.id"
|
|
86
|
+
x-bind-href="c.detailHref"
|
|
87
|
+
className="group flex flex-col items-center rounded-2xl border border-slate-200 bg-white p-3 no-underline shadow-sm transition-all hover:-translate-y-0.5 hover:border-brand-500/50 hover:shadow-md dark:border-slate-800 dark:bg-slate-900"
|
|
88
|
+
>
|
|
89
|
+
<span
|
|
90
|
+
x-text="c.num"
|
|
91
|
+
className="self-start text-[11px] font-semibold tabular-nums text-slate-400"
|
|
92
|
+
/>
|
|
93
|
+
{/* biome-ignore lint/a11y/useAltText: alt is bound at hydration via x-bind-alt (c.displayName) */}
|
|
94
|
+
<img
|
|
95
|
+
x-bind-src="c.artwork"
|
|
96
|
+
x-bind-alt="c.displayName"
|
|
97
|
+
loading="lazy"
|
|
98
|
+
className="h-24 w-24 object-contain transition-transform group-hover:scale-110"
|
|
99
|
+
/>
|
|
100
|
+
<div
|
|
101
|
+
x-text="c.displayName"
|
|
102
|
+
className="mt-1 text-sm font-semibold text-slate-800 dark:text-slate-100"
|
|
103
|
+
/>
|
|
104
|
+
</a>
|
|
105
|
+
</div>
|
|
106
|
+
</section>
|
|
107
|
+
)
|
|
108
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// NATIVE INTERACTIVE COMPONENT — the hero search box. Single-file native
|
|
2
|
+
// directive: a co-located `export const behavior` (client logic, react-free) +
|
|
3
|
+
// a JSX `default` export (the native template the compiler lowers to
|
|
4
|
+
// minijinja). The build bundles ONLY `behavior` into _directives.js, registered
|
|
5
|
+
// as "heroSearch" (camelCase filename); the JSX default is tree-shaken out.
|
|
6
|
+
//
|
|
7
|
+
// react-free: `signal` from brustjs/store (the window singleton on the client),
|
|
8
|
+
// `navigate` from brustjs/navigation (imperative SPA navigation). On submit the
|
|
9
|
+
// behavior pushes /pokedex with the typed query — dogfooding imperative
|
|
10
|
+
// navigate() + query-object serialization.
|
|
11
|
+
import { Search } from 'lucide-react'
|
|
12
|
+
import { navigate } from 'brustjs/navigation'
|
|
13
|
+
import { signal } from 'brustjs/store'
|
|
14
|
+
|
|
15
|
+
// behavior → registered as "heroSearch". Tracks the input value via x-on-input
|
|
16
|
+
// and navigates to the browse page with `?q=` on submit/click.
|
|
17
|
+
export const behavior = () => {
|
|
18
|
+
const value = signal('')
|
|
19
|
+
const onInput = (e: Event) => value.set((e.target as HTMLInputElement).value)
|
|
20
|
+
const go = (e?: Event) => {
|
|
21
|
+
e?.preventDefault()
|
|
22
|
+
navigate('/pokedex', { query: { q: value() } })
|
|
23
|
+
}
|
|
24
|
+
return { value, onInput, go }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// default → jinja. The <form> submit and the button click both call `go`; the
|
|
28
|
+
// input feeds the `value` signal through `onInput`.
|
|
29
|
+
export default function HeroSearch() {
|
|
30
|
+
return (
|
|
31
|
+
<form
|
|
32
|
+
x-data="heroSearch"
|
|
33
|
+
x-on-submit="go"
|
|
34
|
+
className="mx-auto mt-8 flex w-full max-w-md items-center gap-2 rounded-2xl bg-white/95 p-2 shadow-lg ring-1 ring-black/5 dark:bg-slate-900/90 dark:ring-white/10"
|
|
35
|
+
>
|
|
36
|
+
<input
|
|
37
|
+
type="search"
|
|
38
|
+
placeholder="Search the Pokédex…"
|
|
39
|
+
x-on-input="onInput"
|
|
40
|
+
className="min-w-0 flex-1 rounded-xl bg-transparent px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none dark:text-white"
|
|
41
|
+
/>
|
|
42
|
+
<button
|
|
43
|
+
type="submit"
|
|
44
|
+
className="inline-flex items-center gap-1.5 rounded-xl bg-brand-500 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-brand-600"
|
|
45
|
+
>
|
|
46
|
+
<Search size={16} isr={{ key: 'LcIconSearch' }} />
|
|
47
|
+
Search
|
|
48
|
+
</button>
|
|
49
|
+
</form>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
@@ -1,50 +1,43 @@
|
|
|
1
|
-
// NATIVE INTERACTIVE COMPONENT — a
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// the React island reads via useNav(). The bootstrap-owned navigator commits
|
|
8
|
-
// nav.path; this behavior reacts.
|
|
1
|
+
// NATIVE INTERACTIVE COMPONENT — a navbar link whose active state is driven by
|
|
2
|
+
// the navigation store, WATCHED in the behavior and applied by the author. `nav`
|
|
3
|
+
// from brustjs/navigation is a plain reactive source (signals shared cross-chunk
|
|
4
|
+
// via brustjs/store's Symbol.for tracker), so a `computed` over `nav.path()`
|
|
5
|
+
// re-runs on every SPA navigation — the same store the React island reads via
|
|
6
|
+
// useNav(). The bootstrap-owned navigator commits nav.path; this behavior reacts.
|
|
9
7
|
//
|
|
10
8
|
// Single-file component: `export const behavior` → _directives.js (react-free);
|
|
11
9
|
// the JSX default → the native template the compiler lowers to minijinja.
|
|
12
10
|
import { nav } from 'brustjs/navigation'
|
|
13
11
|
import { computed } from 'brustjs/store'
|
|
14
12
|
|
|
13
|
+
const BASE =
|
|
14
|
+
'inline-flex items-center rounded-lg px-3 py-1.5 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-white'
|
|
15
|
+
const ACTIVE =
|
|
16
|
+
'inline-flex items-center rounded-lg px-3 py-1.5 text-sm font-semibold text-brand-600 bg-brand-50 transition-colors dark:text-brand-50 dark:bg-brand-600/20'
|
|
17
|
+
|
|
15
18
|
// behavior → registered as "navLink". Reads the link's own href off the element
|
|
16
19
|
// (no x-props needed), watches nav.path, and returns the active className +
|
|
17
20
|
// aria-current. x-bind-class sets the full className; x-bind-aria-current with a
|
|
18
21
|
// null value removes the attribute (see runtime setBound).
|
|
19
22
|
export const behavior = ({ el }: { el: HTMLElement }) => {
|
|
20
23
|
const linkPath = new URL((el as HTMLAnchorElement).href, location.href).pathname
|
|
21
|
-
const cls = computed(() => (nav.path() === linkPath ?
|
|
24
|
+
const cls = computed(() => (nav.path() === linkPath ? ACTIVE : BASE))
|
|
22
25
|
const current = computed(() => (nav.path() === linkPath ? 'page' : null))
|
|
23
26
|
return { cls, current }
|
|
24
27
|
}
|
|
25
28
|
|
|
26
|
-
// default → jinja. The SSR className is the inactive base; the behavior sets
|
|
27
|
-
//
|
|
28
|
-
|
|
29
|
-
export default function NavLink({
|
|
30
|
-
href,
|
|
31
|
-
label,
|
|
32
|
-
count,
|
|
33
|
-
}: {
|
|
34
|
-
href: string
|
|
35
|
-
label: string
|
|
36
|
-
count?: string
|
|
37
|
-
}) {
|
|
29
|
+
// default → jinja. The SSR className is the inactive base; the behavior sets the
|
|
30
|
+
// active class on bind and on every SPA nav.
|
|
31
|
+
export default function NavLink({ href, label }: { href: string; label: string }) {
|
|
38
32
|
return (
|
|
39
33
|
<a
|
|
40
34
|
x-data="navLink"
|
|
41
35
|
x-bind-class="cls"
|
|
42
36
|
x-bind-aria-current="current"
|
|
43
|
-
className="
|
|
37
|
+
className="inline-flex items-center rounded-lg px-3 py-1.5 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-white"
|
|
44
38
|
href={href}
|
|
45
39
|
>
|
|
46
40
|
<span>{label}</span>
|
|
47
|
-
{count ? <span className="aa-nav-item__count">{count}</span> : null}
|
|
48
41
|
</a>
|
|
49
42
|
)
|
|
50
43
|
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// `brustjs/navigation` through brustjs/store's cross-chunk signal tracker.
|
|
6
6
|
//
|
|
7
7
|
// Placed OUTSIDE <main> in AppLayout so the bootstrap navigator (which only
|
|
8
|
-
// unmounts
|
|
8
|
+
// unmounts/swaps islands inside <main>) never tears it down during the very
|
|
9
9
|
// navigation it is indicating. It renders nothing when idle, and an indeterminate
|
|
10
10
|
// bar while `phase === 'loading'`.
|
|
11
11
|
import { useNav } from 'brustjs/client'
|
|
@@ -14,8 +14,12 @@ export default function NavPreloader() {
|
|
|
14
14
|
const { phase } = useNav()
|
|
15
15
|
if (phase !== 'loading') return null
|
|
16
16
|
return (
|
|
17
|
-
<div
|
|
18
|
-
|
|
17
|
+
<div
|
|
18
|
+
className="fixed inset-x-0 top-0 z-[300] h-0.5 overflow-hidden bg-brand-500/20"
|
|
19
|
+
role="progressbar"
|
|
20
|
+
aria-label="Loading page"
|
|
21
|
+
>
|
|
22
|
+
<div className="h-full w-1/3 animate-pulse bg-brand-500" />
|
|
19
23
|
</div>
|
|
20
24
|
)
|
|
21
25
|
}
|