@adea-ai/ui 0.83.1 → 0.85.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/README.md +79 -0
- package/dist/NOTICE +26 -0
- package/dist/components/composites/catalog-browser/catalog-browser-group.d.ts +20 -0
- package/dist/components/composites/catalog-browser/catalog-browser-group.d.ts.map +1 -0
- package/dist/components/composites/catalog-browser/catalog-browser-group.js +195 -0
- package/dist/components/composites/catalog-browser/catalog-browser-group.js.map +1 -0
- package/dist/components/composites/catalog-browser/catalog-browser.d.ts +3 -0
- package/dist/components/composites/catalog-browser/catalog-browser.d.ts.map +1 -0
- package/dist/components/composites/catalog-browser/catalog-browser.js +378 -0
- package/dist/components/composites/catalog-browser/catalog-browser.js.map +1 -0
- package/dist/components/composites/catalog-browser/catalog-detail.d.ts +21 -0
- package/dist/components/composites/catalog-browser/catalog-detail.d.ts.map +1 -0
- package/dist/components/composites/catalog-browser/catalog-detail.js +92 -0
- package/dist/components/composites/catalog-browser/catalog-detail.js.map +1 -0
- package/dist/components/composites/catalog-browser/catalog-types.d.ts +78 -0
- package/dist/components/composites/catalog-browser/catalog-types.d.ts.map +1 -0
- package/dist/components/composites/catalog-browser/index.d.ts +4 -0
- package/dist/components/composites/catalog-browser/index.d.ts.map +1 -0
- package/dist/components/composites/catalog-browser/index.js +3 -0
- package/dist/components/layout/sidebar-nav/index.d.ts +1 -0
- package/dist/components/layout/sidebar-nav/index.d.ts.map +1 -1
- package/dist/components/layout/sidebar-nav/index.js +2 -1
- package/dist/components/layout/sidebar-nav/sidebar-nav-resize-handle.d.ts +21 -0
- package/dist/components/layout/sidebar-nav/sidebar-nav-resize-handle.d.ts.map +1 -0
- package/dist/components/layout/sidebar-nav/sidebar-nav-resize-handle.js +84 -0
- package/dist/components/layout/sidebar-nav/sidebar-nav-resize-handle.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -2
- package/dist/lint/index.d.ts +1 -0
- package/dist/lint/index.d.ts.map +1 -1
- package/dist/lint.js +171 -59
- package/dist/lint.js.map +1 -1
- package/dist/r/catalog-browser.json +47 -0
- package/dist/r/registry.json +53 -0
- package/dist/r/sidebar-nav.json +6 -0
- package/dist/r/src/components/composites/catalog-browser/catalog-browser-group.tsx +148 -0
- package/dist/r/src/components/composites/catalog-browser/catalog-browser.tsx +258 -0
- package/dist/r/src/components/composites/catalog-browser/catalog-detail.tsx +64 -0
- package/dist/r/src/components/composites/catalog-browser/catalog-types.ts +83 -0
- package/dist/r/src/components/composites/catalog-browser/index.ts +10 -0
- package/dist/r/src/components/layout/sidebar-nav/index.ts +4 -0
- package/dist/r/src/components/layout/sidebar-nav/sidebar-nav-resize-handle.tsx +59 -0
- package/package.json +1 -1
- package/registry.json +53 -0
- package/src/components/composites/catalog-browser/catalog-browser-group.tsx +148 -0
- package/src/components/composites/catalog-browser/catalog-browser.tsx +258 -0
- package/src/components/composites/catalog-browser/catalog-detail.tsx +64 -0
- package/src/components/composites/catalog-browser/catalog-types.ts +83 -0
- package/src/components/composites/catalog-browser/index.ts +10 -0
- package/src/components/layout/sidebar-nav/index.ts +4 -0
- package/src/components/layout/sidebar-nav/sidebar-nav-resize-handle.tsx +59 -0
- package/src/index.ts +1 -0
- package/src/lint/index.ts +168 -19
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { createUniqueId, For, onCleanup, Show, type JSX } from 'solid-js'
|
|
2
|
+
import { ChevronDown, ChevronRight, ChevronUp } from 'lucide-solid'
|
|
3
|
+
import { keyedRows } from '#lib/keyed-rows'
|
|
4
|
+
import { Badge } from '../../ui/badge'
|
|
5
|
+
import { Button } from '../../ui/button'
|
|
6
|
+
import { Skeleton } from '../../ui/skeleton'
|
|
7
|
+
import type { CatalogBrowserEntry, CatalogBrowserGroup as CatalogGroup } from './catalog-types'
|
|
8
|
+
|
|
9
|
+
export function CatalogLoadingState(props: { label: string }) {
|
|
10
|
+
return (
|
|
11
|
+
<div
|
|
12
|
+
class="grid min-w-0 grid-cols-1 gap-3 md:grid-cols-2"
|
|
13
|
+
aria-busy="true"
|
|
14
|
+
aria-label={props.label}
|
|
15
|
+
>
|
|
16
|
+
<For each={[0, 1, 2, 3, 4, 5, 6]}>
|
|
17
|
+
{() => (
|
|
18
|
+
<div class="flex min-w-0 items-center gap-3 rounded-xl border border-border p-3">
|
|
19
|
+
<Skeleton class="size-9 shrink-0" />
|
|
20
|
+
<div class="grid min-w-0 flex-1 gap-2">
|
|
21
|
+
<Skeleton class="h-4 w-2/5" />
|
|
22
|
+
<Skeleton class="h-3 w-4/5" />
|
|
23
|
+
<Skeleton class="h-3 w-3/5" />
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
)}
|
|
27
|
+
</For>
|
|
28
|
+
</div>
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function CatalogBrowserRow<Value>(props: {
|
|
33
|
+
entry: CatalogBrowserEntry<Value>
|
|
34
|
+
groupId: string
|
|
35
|
+
installedLabel: string
|
|
36
|
+
publishedByLabel: (publisher: string) => string
|
|
37
|
+
disabled: boolean
|
|
38
|
+
renderIcon: (value: Value) => JSX.Element
|
|
39
|
+
onSelect: (entry: CatalogBrowserEntry<Value>, key: string) => void
|
|
40
|
+
onRef: (key: string, element: HTMLButtonElement | undefined) => void
|
|
41
|
+
}) {
|
|
42
|
+
const key = JSON.stringify([props.groupId, props.entry.id])
|
|
43
|
+
let element: HTMLButtonElement | undefined
|
|
44
|
+
onCleanup(() => {
|
|
45
|
+
if (element) props.onRef(key, undefined)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<Button
|
|
50
|
+
ref={(node) => {
|
|
51
|
+
element = node
|
|
52
|
+
props.onRef(key, node)
|
|
53
|
+
}}
|
|
54
|
+
data-catalog-entry-id={props.entry.id}
|
|
55
|
+
variant="outline"
|
|
56
|
+
size="lg"
|
|
57
|
+
class="h-auto min-h-row-lg w-full min-w-0 justify-start gap-3 whitespace-normal"
|
|
58
|
+
disabled={props.disabled}
|
|
59
|
+
onClick={() => props.onSelect(props.entry, key)}
|
|
60
|
+
>
|
|
61
|
+
<span class="shrink-0">{props.renderIcon(props.entry.value)}</span>
|
|
62
|
+
<span class="flex min-w-0 flex-1 flex-col gap-1 text-left">
|
|
63
|
+
<span class="flex min-w-0 flex-wrap items-center gap-2">
|
|
64
|
+
<strong class="min-w-0 truncate">{props.entry.name}</strong>
|
|
65
|
+
<Show when={props.entry.installed}>
|
|
66
|
+
<Badge variant="secondary">{props.installedLabel}</Badge>
|
|
67
|
+
</Show>
|
|
68
|
+
</span>
|
|
69
|
+
<span class="text-muted-foreground line-clamp-2 text-xs">{props.entry.description}</span>
|
|
70
|
+
<span class="text-muted-foreground truncate text-2xs">
|
|
71
|
+
{props.publishedByLabel(props.entry.publisher)} · {props.entry.category}
|
|
72
|
+
</span>
|
|
73
|
+
</span>
|
|
74
|
+
<ChevronRight aria-hidden="true" />
|
|
75
|
+
</Button>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function CatalogBrowserGroup<Value>(props: {
|
|
80
|
+
group: CatalogGroup<Value>
|
|
81
|
+
expanded: boolean
|
|
82
|
+
previewCount: number
|
|
83
|
+
disabled: boolean
|
|
84
|
+
installedLabel: string
|
|
85
|
+
publishedByLabel: (publisher: string) => string
|
|
86
|
+
showMoreLabel: (entries: readonly CatalogBrowserEntry<Value>[], remainingCount: number) => string
|
|
87
|
+
showLessLabel: string
|
|
88
|
+
renderIcon: (value: Value) => JSX.Element
|
|
89
|
+
onSelect: (entry: CatalogBrowserEntry<Value>, key: string) => void
|
|
90
|
+
onRef: (key: string, element: HTMLButtonElement | undefined) => void
|
|
91
|
+
onToggle: (id: string) => void
|
|
92
|
+
}) {
|
|
93
|
+
const headingId = `catalog-browser-group-${createUniqueId()}`
|
|
94
|
+
const listId = `catalog-browser-items-${createUniqueId()}`
|
|
95
|
+
const hidden = () => props.group.entries.slice(props.previewCount)
|
|
96
|
+
const preview = () =>
|
|
97
|
+
props.expanded ? props.group.entries : props.group.entries.slice(0, props.previewCount)
|
|
98
|
+
const rows = keyedRows(preview, (entry) => entry.id)
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<section class="grid min-w-0 gap-2" aria-labelledby={headingId}>
|
|
102
|
+
<header class="flex min-w-0 items-center gap-2">
|
|
103
|
+
<h3 id={headingId} class="min-w-0 flex-1 truncate text-sm font-semibold">
|
|
104
|
+
{props.group.label}
|
|
105
|
+
</h3>
|
|
106
|
+
<Badge variant="outline">{props.group.entries.length}</Badge>
|
|
107
|
+
</header>
|
|
108
|
+
<div id={listId} class="grid min-w-0 grid-cols-1 gap-2 md:grid-cols-2">
|
|
109
|
+
<For each={rows()}>
|
|
110
|
+
{(row) => (
|
|
111
|
+
<CatalogBrowserRow
|
|
112
|
+
entry={row.item()}
|
|
113
|
+
groupId={props.group.id}
|
|
114
|
+
installedLabel={props.installedLabel}
|
|
115
|
+
publishedByLabel={props.publishedByLabel}
|
|
116
|
+
disabled={props.disabled}
|
|
117
|
+
renderIcon={props.renderIcon}
|
|
118
|
+
onSelect={props.onSelect}
|
|
119
|
+
onRef={props.onRef}
|
|
120
|
+
/>
|
|
121
|
+
)}
|
|
122
|
+
</For>
|
|
123
|
+
</div>
|
|
124
|
+
<Show when={hidden().length > 0}>
|
|
125
|
+
<Button
|
|
126
|
+
variant="ghost"
|
|
127
|
+
size="lg"
|
|
128
|
+
disabled={props.disabled}
|
|
129
|
+
aria-expanded={props.expanded}
|
|
130
|
+
aria-controls={listId}
|
|
131
|
+
onClick={() => props.onToggle(props.group.id)}
|
|
132
|
+
>
|
|
133
|
+
<Show
|
|
134
|
+
when={props.expanded}
|
|
135
|
+
fallback={
|
|
136
|
+
<>
|
|
137
|
+
{props.showMoreLabel(hidden(), hidden().length)}{' '}
|
|
138
|
+
<ChevronDown data-icon="inline-end" aria-hidden="true" />
|
|
139
|
+
</>
|
|
140
|
+
}
|
|
141
|
+
>
|
|
142
|
+
{props.showLessLabel} <ChevronUp data-icon="inline-end" aria-hidden="true" />
|
|
143
|
+
</Show>
|
|
144
|
+
</Button>
|
|
145
|
+
</Show>
|
|
146
|
+
</section>
|
|
147
|
+
)
|
|
148
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { createEffect, createMemo, createSignal, For, onCleanup, Show } from 'solid-js'
|
|
2
|
+
import { ArrowLeft, Blocks, Search } from 'lucide-solid'
|
|
3
|
+
import { keyedRows } from '#lib/keyed-rows'
|
|
4
|
+
import { Button } from '../../ui/button'
|
|
5
|
+
import {
|
|
6
|
+
Empty,
|
|
7
|
+
EmptyContent,
|
|
8
|
+
EmptyDescription,
|
|
9
|
+
EmptyHeader,
|
|
10
|
+
EmptyMedia,
|
|
11
|
+
EmptyTitle,
|
|
12
|
+
} from '../../ui/empty'
|
|
13
|
+
import { InputGroup, InputGroupAddon, InputGroupInput } from '../../ui/input-group'
|
|
14
|
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../../ui/tabs'
|
|
15
|
+
import { ScrollArea } from '../../ui/scroll-area'
|
|
16
|
+
import { cn } from '#lib/utils'
|
|
17
|
+
import { CatalogBrowserGroup, CatalogLoadingState } from './catalog-browser-group'
|
|
18
|
+
import type { CatalogBrowserProps } from './catalog-types'
|
|
19
|
+
|
|
20
|
+
export function CatalogBrowser<Value>(props: CatalogBrowserProps<Value>) {
|
|
21
|
+
const [expandedGroups, setExpandedGroups] = createSignal<ReadonlySet<string>>(new Set())
|
|
22
|
+
const [returnKey, setReturnKey] = createSignal<string>()
|
|
23
|
+
const [lastSelectedId, setLastSelectedId] = createSignal<string>()
|
|
24
|
+
const rowRefs = new Map<string, HTMLButtonElement>()
|
|
25
|
+
let searchInput: HTMLInputElement | undefined
|
|
26
|
+
let backButton: HTMLButtonElement | undefined
|
|
27
|
+
|
|
28
|
+
const selectedTab = createMemo(() => props.tabs.find((tab) => tab.id === props.tab))
|
|
29
|
+
const supplemental = () => selectedTab()?.kind === 'supplemental'
|
|
30
|
+
const selected = createMemo(() => {
|
|
31
|
+
const id = props.selectedId
|
|
32
|
+
return id && !supplemental() ? props.entries.find((entry) => entry.id === id) : undefined
|
|
33
|
+
})
|
|
34
|
+
const groupRows = keyedRows(
|
|
35
|
+
() => props.groups,
|
|
36
|
+
(group) => group.id
|
|
37
|
+
)
|
|
38
|
+
const [wide, setWide] = createSignal(
|
|
39
|
+
typeof window === 'undefined' || window.matchMedia('(min-width: 48rem)').matches
|
|
40
|
+
)
|
|
41
|
+
const updateWide = (event: MediaQueryListEvent) => setWide(event.matches)
|
|
42
|
+
|
|
43
|
+
createEffect(() => {
|
|
44
|
+
if (typeof window === 'undefined') return
|
|
45
|
+
const query = window.matchMedia('(min-width: 48rem)')
|
|
46
|
+
query.addEventListener('change', updateWide)
|
|
47
|
+
onCleanup(() => query.removeEventListener('change', updateWide))
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
createEffect(() => {
|
|
51
|
+
const open = props.open
|
|
52
|
+
const selectedId = props.selectedId
|
|
53
|
+
const selectedEntry = selected()
|
|
54
|
+
if (!open) {
|
|
55
|
+
setExpandedGroups(new Set<string>())
|
|
56
|
+
setLastSelectedId(undefined)
|
|
57
|
+
setReturnKey(undefined)
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
if (selectedId && selectedEntry) {
|
|
61
|
+
const previous = lastSelectedId()
|
|
62
|
+
setLastSelectedId(selectedId)
|
|
63
|
+
if (selectedId !== previous) {
|
|
64
|
+
window.requestAnimationFrame(() => {
|
|
65
|
+
if (props.open && props.selectedId === selectedId) backButton?.focus()
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
const previous = lastSelectedId()
|
|
71
|
+
if (!previous) return
|
|
72
|
+
const key = returnKey()
|
|
73
|
+
setLastSelectedId(undefined)
|
|
74
|
+
setReturnKey(undefined)
|
|
75
|
+
window.requestAnimationFrame(() => {
|
|
76
|
+
if (!props.open || props.selectedId) return
|
|
77
|
+
const row = key ? rowRefs.get(key) : undefined
|
|
78
|
+
if (row?.isConnected) row.focus()
|
|
79
|
+
else searchInput?.focus()
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
const registerRow = (key: string, element: HTMLButtonElement | undefined) => {
|
|
84
|
+
if (element) rowRefs.set(key, element)
|
|
85
|
+
else rowRefs.delete(key)
|
|
86
|
+
}
|
|
87
|
+
const selectEntry = (key: string, value: Value) => {
|
|
88
|
+
setReturnKey(key)
|
|
89
|
+
props.onSelect(value)
|
|
90
|
+
}
|
|
91
|
+
const toggleGroup = (id: string) => {
|
|
92
|
+
setExpandedGroups((current) => {
|
|
93
|
+
const next = new Set(current)
|
|
94
|
+
if (next.has(id)) next.delete(id)
|
|
95
|
+
else next.add(id)
|
|
96
|
+
return next
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
const previewCount = () => (wide() ? 6 : 3)
|
|
100
|
+
const countLabel = () =>
|
|
101
|
+
props.status === 'loading' ? props.loadingLabel : props.resultLabel(props.resultCount)
|
|
102
|
+
const supplementalContent = () => props.renderSupplementalView?.(props.tab)
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<div class={cn('flex min-h-0 min-w-0 flex-1 flex-col', props.class)}>
|
|
106
|
+
<Show
|
|
107
|
+
when={selected()}
|
|
108
|
+
fallback={
|
|
109
|
+
<Tabs
|
|
110
|
+
class="flex min-h-0 min-w-0 flex-1 flex-col gap-0"
|
|
111
|
+
value={props.tab}
|
|
112
|
+
onChange={(value) => value && props.onTabChange(value)}
|
|
113
|
+
>
|
|
114
|
+
<TabsList aria-label={props.tabsLabel} class="ms-auto max-w-full flex-wrap px-4 pt-2">
|
|
115
|
+
<For each={props.tabs}>
|
|
116
|
+
{(tab) => <TabsTrigger value={tab.id}>{tab.label}</TabsTrigger>}
|
|
117
|
+
</For>
|
|
118
|
+
</TabsList>
|
|
119
|
+
<TabsContent value={props.tab} class="flex min-h-0 min-w-0 flex-1 flex-col gap-3">
|
|
120
|
+
<Show
|
|
121
|
+
when={supplemental()}
|
|
122
|
+
fallback={
|
|
123
|
+
<>
|
|
124
|
+
<Show when={props.installError}>
|
|
125
|
+
{(message) => (
|
|
126
|
+
<p class="text-destructive px-4 pt-2 text-sm" role="alert">
|
|
127
|
+
{message()}
|
|
128
|
+
</p>
|
|
129
|
+
)}
|
|
130
|
+
</Show>
|
|
131
|
+
<div class="flex min-w-0 shrink-0 flex-wrap items-center gap-2 px-4 pt-2">
|
|
132
|
+
{props.filterControl}
|
|
133
|
+
<InputGroup class="min-w-0 flex-1 sm:max-w-md">
|
|
134
|
+
<InputGroupAddon>
|
|
135
|
+
<Search aria-hidden="true" />
|
|
136
|
+
</InputGroupAddon>
|
|
137
|
+
<InputGroupInput
|
|
138
|
+
ref={(element) => (searchInput = element)}
|
|
139
|
+
type="search"
|
|
140
|
+
aria-label={props.searchLabel}
|
|
141
|
+
placeholder={props.searchPlaceholder}
|
|
142
|
+
value={props.query}
|
|
143
|
+
onInput={(event) => props.onQueryChange(event.currentTarget.value)}
|
|
144
|
+
/>
|
|
145
|
+
</InputGroup>
|
|
146
|
+
<span class="text-muted-foreground ms-auto text-sm" role="status">
|
|
147
|
+
{countLabel()}
|
|
148
|
+
</span>
|
|
149
|
+
</div>
|
|
150
|
+
<ScrollArea
|
|
151
|
+
role="region"
|
|
152
|
+
aria-label={props.resultsRegionLabel}
|
|
153
|
+
orientation="vertical"
|
|
154
|
+
class="flex min-h-0 min-w-0 flex-1 flex-col gap-5 px-4 pb-4"
|
|
155
|
+
>
|
|
156
|
+
<Show when={props.status === 'error'}>
|
|
157
|
+
<Empty
|
|
158
|
+
class="min-h-0 min-w-0 flex-1"
|
|
159
|
+
role={props.catalogError.role ?? 'alert'}
|
|
160
|
+
>
|
|
161
|
+
<EmptyHeader>
|
|
162
|
+
<EmptyMedia variant="icon">
|
|
163
|
+
<Blocks aria-hidden="true" />
|
|
164
|
+
</EmptyMedia>
|
|
165
|
+
<EmptyTitle>{props.catalogError.title}</EmptyTitle>
|
|
166
|
+
<EmptyDescription>{props.catalogError.description}</EmptyDescription>
|
|
167
|
+
</EmptyHeader>
|
|
168
|
+
<Show when={props.catalogErrorAction}>
|
|
169
|
+
<EmptyContent>{props.catalogErrorAction}</EmptyContent>
|
|
170
|
+
</Show>
|
|
171
|
+
</Empty>
|
|
172
|
+
</Show>
|
|
173
|
+
<Show when={props.status === 'loading'}>
|
|
174
|
+
<CatalogLoadingState label={props.loadingLabel} />
|
|
175
|
+
</Show>
|
|
176
|
+
<Show when={props.status === 'ready'}>
|
|
177
|
+
<Show
|
|
178
|
+
when={props.resultCount > 0}
|
|
179
|
+
fallback={
|
|
180
|
+
<Empty class="min-h-0 min-w-0 flex-1" role="status">
|
|
181
|
+
<EmptyHeader>
|
|
182
|
+
<EmptyMedia variant="icon">
|
|
183
|
+
<Blocks aria-hidden="true" />
|
|
184
|
+
</EmptyMedia>
|
|
185
|
+
<EmptyTitle>{props.emptyState.title}</EmptyTitle>
|
|
186
|
+
<EmptyDescription>{props.emptyState.description}</EmptyDescription>
|
|
187
|
+
</EmptyHeader>
|
|
188
|
+
<Show when={props.emptyStateAction}>
|
|
189
|
+
<EmptyContent>{props.emptyStateAction}</EmptyContent>
|
|
190
|
+
</Show>
|
|
191
|
+
</Empty>
|
|
192
|
+
}
|
|
193
|
+
>
|
|
194
|
+
<div class="grid min-w-0 gap-5">
|
|
195
|
+
<For each={groupRows()}>
|
|
196
|
+
{(row) => (
|
|
197
|
+
<CatalogBrowserGroup
|
|
198
|
+
group={row.item()}
|
|
199
|
+
expanded={expandedGroups().has(row.item().id)}
|
|
200
|
+
previewCount={previewCount()}
|
|
201
|
+
disabled={props.interactionDisabled ?? false}
|
|
202
|
+
installedLabel={props.installedLabel}
|
|
203
|
+
publishedByLabel={props.publishedByLabel}
|
|
204
|
+
showMoreLabel={props.showMoreLabel}
|
|
205
|
+
showLessLabel={props.showLessLabel}
|
|
206
|
+
renderIcon={props.renderIcon}
|
|
207
|
+
onSelect={(entry, key) => selectEntry(key, entry.value)}
|
|
208
|
+
onRef={registerRow}
|
|
209
|
+
onToggle={toggleGroup}
|
|
210
|
+
/>
|
|
211
|
+
)}
|
|
212
|
+
</For>
|
|
213
|
+
</div>
|
|
214
|
+
</Show>
|
|
215
|
+
</Show>
|
|
216
|
+
<For each={props.notices ?? []}>
|
|
217
|
+
{(notice) => (
|
|
218
|
+
<p class="text-muted-foreground text-sm" role={notice.role}>
|
|
219
|
+
{notice.message}
|
|
220
|
+
</p>
|
|
221
|
+
)}
|
|
222
|
+
</For>
|
|
223
|
+
</ScrollArea>
|
|
224
|
+
</>
|
|
225
|
+
}
|
|
226
|
+
>
|
|
227
|
+
{supplementalContent()}
|
|
228
|
+
</Show>
|
|
229
|
+
</TabsContent>
|
|
230
|
+
</Tabs>
|
|
231
|
+
}
|
|
232
|
+
>
|
|
233
|
+
{(entry) => (
|
|
234
|
+
<div class="flex min-h-0 min-w-0 flex-1 flex-col gap-3" data-catalog-browser-detail>
|
|
235
|
+
<Button
|
|
236
|
+
ref={(element) => (backButton = element)}
|
|
237
|
+
variant="ghost"
|
|
238
|
+
size="sm"
|
|
239
|
+
onClick={() => props.onBack()}
|
|
240
|
+
class="mx-4 mt-2 self-start"
|
|
241
|
+
>
|
|
242
|
+
<ArrowLeft data-icon="inline-start" aria-hidden="true" />
|
|
243
|
+
{props.backLabel}
|
|
244
|
+
</Button>
|
|
245
|
+
<ScrollArea
|
|
246
|
+
role="region"
|
|
247
|
+
aria-label={props.detailRegionLabel}
|
|
248
|
+
orientation="vertical"
|
|
249
|
+
class="min-h-0 min-w-0 flex-1 px-4 pb-4"
|
|
250
|
+
>
|
|
251
|
+
{props.renderDetail(entry().value)}
|
|
252
|
+
</ScrollArea>
|
|
253
|
+
</div>
|
|
254
|
+
)}
|
|
255
|
+
</Show>
|
|
256
|
+
</div>
|
|
257
|
+
)
|
|
258
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { createUniqueId, Show, type JSX } from 'solid-js'
|
|
2
|
+
import { Badge } from '../../ui/badge'
|
|
3
|
+
import { Card, CardContent, CardFooter, CardHeader } from '../../ui/card'
|
|
4
|
+
|
|
5
|
+
/** Shared item summary and action frame for catalog-owned detail content. */
|
|
6
|
+
export function CatalogDetail(props: {
|
|
7
|
+
title: string
|
|
8
|
+
description: string
|
|
9
|
+
category: string
|
|
10
|
+
publisher: string
|
|
11
|
+
publishedByLabel: (publisher: string) => string
|
|
12
|
+
leading?: JSX.Element
|
|
13
|
+
eyebrow?: string
|
|
14
|
+
badges?: JSX.Element
|
|
15
|
+
action?: JSX.Element
|
|
16
|
+
status?: JSX.Element
|
|
17
|
+
children?: JSX.Element
|
|
18
|
+
}) {
|
|
19
|
+
return (
|
|
20
|
+
<article class="grid min-w-0 gap-4" data-catalog-detail>
|
|
21
|
+
<Card>
|
|
22
|
+
<CardHeader class="flex-row items-start gap-3">
|
|
23
|
+
<Show when={props.leading}>
|
|
24
|
+
<span class="shrink-0">{props.leading}</span>
|
|
25
|
+
</Show>
|
|
26
|
+
<div class="grid min-w-0 gap-1.5">
|
|
27
|
+
<div class="flex min-w-0 flex-wrap items-center gap-1.5">
|
|
28
|
+
<Show when={props.eyebrow}>
|
|
29
|
+
<Badge variant="outline">{props.eyebrow}</Badge>
|
|
30
|
+
</Show>
|
|
31
|
+
<Badge variant="secondary">{props.category}</Badge>
|
|
32
|
+
{props.badges}
|
|
33
|
+
</div>
|
|
34
|
+
<h2 class="text-base leading-tight font-semibold tracking-tight">{props.title}</h2>
|
|
35
|
+
<p class="text-muted-foreground text-sm">{props.description}</p>
|
|
36
|
+
<p class="text-muted-foreground text-xs">{props.publishedByLabel(props.publisher)}</p>
|
|
37
|
+
</div>
|
|
38
|
+
</CardHeader>
|
|
39
|
+
<CardFooter class="flex-wrap justify-between gap-3">
|
|
40
|
+
{props.action}
|
|
41
|
+
{props.status}
|
|
42
|
+
</CardFooter>
|
|
43
|
+
</Card>
|
|
44
|
+
<div class="grid min-w-0 gap-3">{props.children}</div>
|
|
45
|
+
</article>
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A shared surface and heading for host-specific fields, permissions, and copy. */
|
|
50
|
+
export function CatalogDetailSection(props: { title: string; children?: JSX.Element }) {
|
|
51
|
+
const headingId = `catalog-detail-section-${createUniqueId()}`
|
|
52
|
+
return (
|
|
53
|
+
<section class="min-w-0" aria-labelledby={headingId}>
|
|
54
|
+
<Card>
|
|
55
|
+
<CardHeader>
|
|
56
|
+
<h3 id={headingId} class="text-sm font-semibold tracking-tight">
|
|
57
|
+
{props.title}
|
|
58
|
+
</h3>
|
|
59
|
+
</CardHeader>
|
|
60
|
+
<CardContent class="grid min-w-0 gap-3">{props.children}</CardContent>
|
|
61
|
+
</Card>
|
|
62
|
+
</section>
|
|
63
|
+
)
|
|
64
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { JSX } from 'solid-js'
|
|
2
|
+
|
|
3
|
+
/** A host-owned catalog record reduced to the fields needed by the browser. */
|
|
4
|
+
export type CatalogBrowserEntry<Value> = Readonly<{
|
|
5
|
+
id: string
|
|
6
|
+
value: Value
|
|
7
|
+
name: string
|
|
8
|
+
description: string
|
|
9
|
+
category: string
|
|
10
|
+
publisher: string
|
|
11
|
+
installed: boolean
|
|
12
|
+
}>
|
|
13
|
+
|
|
14
|
+
/** One stable, host-ordered category in the browser. */
|
|
15
|
+
export type CatalogBrowserGroup<Value> = Readonly<{
|
|
16
|
+
id: string
|
|
17
|
+
label: string
|
|
18
|
+
entries: readonly CatalogBrowserEntry<Value>[]
|
|
19
|
+
}>
|
|
20
|
+
|
|
21
|
+
export type CatalogBrowserTab = Readonly<{
|
|
22
|
+
id: string
|
|
23
|
+
label: string
|
|
24
|
+
/** A host-rendered view, such as navigation settings, outside the catalog. */
|
|
25
|
+
kind?: 'catalog' | 'supplemental'
|
|
26
|
+
}>
|
|
27
|
+
|
|
28
|
+
export type CatalogBrowserNotice = Readonly<{
|
|
29
|
+
id: string
|
|
30
|
+
role: 'alert' | 'status'
|
|
31
|
+
message: string
|
|
32
|
+
}>
|
|
33
|
+
|
|
34
|
+
export type CatalogBrowserMessage = Readonly<{
|
|
35
|
+
title: string
|
|
36
|
+
description: string
|
|
37
|
+
role?: 'alert' | 'status'
|
|
38
|
+
}>
|
|
39
|
+
|
|
40
|
+
export type CatalogBrowserProps<Value> = Readonly<{
|
|
41
|
+
/** Optional layout hook for the host's containing surface. */
|
|
42
|
+
class?: string
|
|
43
|
+
/** Whether the containing host surface is open; closing it clears view-only state. */
|
|
44
|
+
open: boolean
|
|
45
|
+
tabs: readonly CatalogBrowserTab[]
|
|
46
|
+
tab: string
|
|
47
|
+
tabsLabel: string
|
|
48
|
+
onTabChange: (tab: string) => void
|
|
49
|
+
/** A host-owned view is rendered in the same tab frame without catalog assumptions. */
|
|
50
|
+
renderSupplementalView?: (tab: string) => JSX.Element | undefined
|
|
51
|
+
query: string
|
|
52
|
+
onQueryChange: (query: string) => void
|
|
53
|
+
searchLabel: string
|
|
54
|
+
searchPlaceholder: string
|
|
55
|
+
resultsRegionLabel: string
|
|
56
|
+
filterControl?: JSX.Element
|
|
57
|
+
resultCount: number
|
|
58
|
+
resultLabel: (count: number) => string
|
|
59
|
+
loadingLabel: string
|
|
60
|
+
status: 'loading' | 'ready' | 'error'
|
|
61
|
+
catalogError: CatalogBrowserMessage
|
|
62
|
+
catalogErrorAction?: JSX.Element
|
|
63
|
+
installError?: string
|
|
64
|
+
notices?: readonly CatalogBrowserNotice[]
|
|
65
|
+
emptyState: CatalogBrowserMessage
|
|
66
|
+
emptyStateAction?: JSX.Element
|
|
67
|
+
groups: readonly CatalogBrowserGroup<Value>[]
|
|
68
|
+
/** All selectable records, including records omitted by the current filter. */
|
|
69
|
+
entries: readonly CatalogBrowserEntry<Value>[]
|
|
70
|
+
selectedId: string | null
|
|
71
|
+
onSelect: (value: Value) => void
|
|
72
|
+
onBack: () => void
|
|
73
|
+
backLabel: string
|
|
74
|
+
detailRegionLabel: string
|
|
75
|
+
installedLabel: string
|
|
76
|
+
publishedByLabel: (publisher: string) => string
|
|
77
|
+
showMoreLabel: (entries: readonly CatalogBrowserEntry<Value>[], remainingCount: number) => string
|
|
78
|
+
showLessLabel: string
|
|
79
|
+
/** Temporarily disables catalog controls while a host menu owns interaction. */
|
|
80
|
+
interactionDisabled?: boolean
|
|
81
|
+
renderIcon: (value: Value) => JSX.Element
|
|
82
|
+
renderDetail: (value: Value) => JSX.Element
|
|
83
|
+
}>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { CatalogBrowser } from './catalog-browser'
|
|
2
|
+
export { CatalogDetail, CatalogDetailSection } from './catalog-detail'
|
|
3
|
+
export type {
|
|
4
|
+
CatalogBrowserEntry,
|
|
5
|
+
CatalogBrowserGroup,
|
|
6
|
+
CatalogBrowserMessage,
|
|
7
|
+
CatalogBrowserNotice,
|
|
8
|
+
CatalogBrowserProps,
|
|
9
|
+
CatalogBrowserTab,
|
|
10
|
+
} from './catalog-types'
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import Resizable from '@corvu/resizable'
|
|
2
|
+
import { cn } from '#lib/utils'
|
|
3
|
+
|
|
4
|
+
export type SidebarNavResizeHandleProps = {
|
|
5
|
+
value: number
|
|
6
|
+
minimum: number
|
|
7
|
+
maximum: number
|
|
8
|
+
step?: number
|
|
9
|
+
label?: string
|
|
10
|
+
controls?: string
|
|
11
|
+
class?: string
|
|
12
|
+
onChange(value: number): void
|
|
13
|
+
onCommit?(value: number): void
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Resize a contextual sidebar without making its host recreate a separator
|
|
18
|
+
* widget. Corvu owns pointer capture, cancellation, constraints, keyboard
|
|
19
|
+
* movement and cleanup. Its two invisible panels provide a bounded pixel
|
|
20
|
+
* ruler; the host retains the real pane layout and preference persistence.
|
|
21
|
+
* Place this inside the positioned sidebar. The ruler can extend over the
|
|
22
|
+
* adjacent content, but only its handle participates in pointer hit testing.
|
|
23
|
+
*/
|
|
24
|
+
export function SidebarNavResizeHandle(props: SidebarNavResizeHandleProps) {
|
|
25
|
+
const maximum = () => Math.max(1, props.maximum)
|
|
26
|
+
const minimum = () => Math.min(maximum(), Math.max(0, props.minimum))
|
|
27
|
+
const value = () => Math.min(maximum(), Math.max(minimum(), props.value))
|
|
28
|
+
const commit = () => props.onCommit?.(value())
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<Resizable
|
|
32
|
+
orientation="horizontal"
|
|
33
|
+
sizes={[value() / maximum(), 1 - value() / maximum()]}
|
|
34
|
+
keyboardDelta={`${props.step ?? 16}px`}
|
|
35
|
+
style={{ width: `${maximum()}px` }}
|
|
36
|
+
class={cn('pointer-events-none absolute inset-y-0 start-0 flex', props.class)}
|
|
37
|
+
onSizesChange={(sizes) => {
|
|
38
|
+
const next = Math.round((sizes[0] ?? 0) * maximum())
|
|
39
|
+
if (next !== value()) props.onChange(Math.min(maximum(), Math.max(minimum(), next)))
|
|
40
|
+
}}
|
|
41
|
+
>
|
|
42
|
+
<Resizable.Panel minSize={minimum() / maximum()} maxSize={1} aria-hidden="true" />
|
|
43
|
+
<Resizable.Handle
|
|
44
|
+
aria-label={props.label ?? 'Resize navigation'}
|
|
45
|
+
aria-controls={props.controls}
|
|
46
|
+
aria-orientation="vertical"
|
|
47
|
+
aria-valuemin={minimum()}
|
|
48
|
+
aria-valuemax={maximum()}
|
|
49
|
+
aria-valuenow={value()}
|
|
50
|
+
onHandleDragEnd={commit}
|
|
51
|
+
onKeyUp={(event) => {
|
|
52
|
+
if (['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) commit()
|
|
53
|
+
}}
|
|
54
|
+
class="pointer-events-auto relative z-(--z-docked) w-px -translate-x-full shrink-0 bg-transparent after:absolute after:inset-y-0 after:-inset-x-1 after:w-3 hover:bg-primary focus-visible:bg-primary focus-visible:ring-3 focus-visible:ring-primary-subtle focus-visible:outline-none"
|
|
55
|
+
/>
|
|
56
|
+
<Resizable.Panel minSize={0} aria-hidden="true" />
|
|
57
|
+
</Resizable>
|
|
58
|
+
)
|
|
59
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -98,6 +98,7 @@ export * from './components/theme'
|
|
|
98
98
|
/* --- Composites ---------------------------------------------------------- */
|
|
99
99
|
export * from './components/composites/account-menu'
|
|
100
100
|
export * from './components/composites/action-button'
|
|
101
|
+
export * from './components/composites/catalog-browser'
|
|
101
102
|
export * from './components/composites/list-row'
|
|
102
103
|
export * from './components/composites/settings'
|
|
103
104
|
export * from './components/composites/stat'
|