@a4ui/core 0.26.0 → 0.27.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 +2 -2
- package/dist/elements.css +13 -0
- package/dist/full.css +13 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +3393 -3088
- package/dist/layout/Aurora.d.ts +6 -0
- package/dist/lib/createLinkedSelection.d.ts +30 -0
- package/dist/ui/DataView.d.ts +51 -0
- package/dist/ui/SlashMenu.d.ts +43 -0
- package/package.json +1 -1
- package/preset.js +24 -0
- package/src/index.ts +4 -1
- package/src/layout/Aurora.tsx +41 -11
- package/src/lib/createLinkedSelection.ts +60 -0
- package/src/ui/DataView.tsx +198 -0
- package/src/ui/SlashMenu.tsx +141 -0
package/dist/layout/Aurora.d.ts
CHANGED
|
@@ -9,6 +9,12 @@ export interface AuroraProps {
|
|
|
9
9
|
* on any `.glow-edge` cards). Reduced-motion aware (off when reduced). @default true
|
|
10
10
|
*/
|
|
11
11
|
pointerGlow?: boolean;
|
|
12
|
+
/**
|
|
13
|
+
* Backdrop style: `'blobs'` (soft blurred color blobs) or `'mesh'` (a
|
|
14
|
+
* multi-point gradient mesh). Both are theme-tinted and drift when `animated`.
|
|
15
|
+
* @default 'blobs'
|
|
16
|
+
*/
|
|
17
|
+
variant?: 'blobs' | 'mesh';
|
|
12
18
|
class?: string;
|
|
13
19
|
}
|
|
14
20
|
/**
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export interface LinkedSelection<Id> {
|
|
2
|
+
hoveredId: () => Id | undefined;
|
|
3
|
+
selectedId: () => Id | undefined;
|
|
4
|
+
setHovered: (id: Id | undefined) => void;
|
|
5
|
+
setSelected: (id: Id | undefined) => void;
|
|
6
|
+
isHovered: (id: Id) => boolean;
|
|
7
|
+
isSelected: (id: Id) => boolean;
|
|
8
|
+
/** Convenience props to spread onto an element for a given id (onPointerEnter/Leave + onClick). */
|
|
9
|
+
itemProps: (id: Id) => {
|
|
10
|
+
onPointerEnter: () => void;
|
|
11
|
+
onPointerLeave: () => void;
|
|
12
|
+
onClick: () => void;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Shared hovered/selected state for two synchronized views of the same
|
|
17
|
+
* collection (a map and a list, a chart and a table, …): hover a list row to
|
|
18
|
+
* highlight the map pin, and vice versa.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```tsx
|
|
22
|
+
* const selection = createLinkedSelection<string>()
|
|
23
|
+
* return (
|
|
24
|
+
* <For each={items}>
|
|
25
|
+
* {(item) => <ListRow class={selection.isSelected(item.id) ? 'active' : ''} {...selection.itemProps(item.id)} />}
|
|
26
|
+
* </For>
|
|
27
|
+
* )
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export declare function createLinkedSelection<Id = string>(initialSelected?: Id): LinkedSelection<Id>;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { JSX } from 'solid-js';
|
|
2
|
+
/** A single table column for {@link DataView}'s table presentation. */
|
|
3
|
+
export interface DataViewColumn<T> {
|
|
4
|
+
/** Row property this column reads. */
|
|
5
|
+
key: string;
|
|
6
|
+
/** Column heading text. */
|
|
7
|
+
header: string;
|
|
8
|
+
/** Custom cell renderer; falls back to `String(row[key])` when omitted. */
|
|
9
|
+
cell?: (row: T) => JSX.Element;
|
|
10
|
+
}
|
|
11
|
+
/** Presentation mode offered by {@link DataView}. */
|
|
12
|
+
export type DataViewMode = 'table' | 'board' | 'gallery';
|
|
13
|
+
/** Props for {@link DataView}. */
|
|
14
|
+
export interface DataViewProps<T> {
|
|
15
|
+
data: T[];
|
|
16
|
+
/** Table columns; also used as the source of truth in board/gallery fallbacks. */
|
|
17
|
+
columns: DataViewColumn<T>[];
|
|
18
|
+
/** Stable row identifier, used as a DOM hook for tests/a11y. */
|
|
19
|
+
getId: (row: T) => string;
|
|
20
|
+
/** For board view: group rows into columns by this key. If omitted, board mode is not offered. */
|
|
21
|
+
groupBy?: (row: T) => string;
|
|
22
|
+
/** Card renderer for board + gallery. Falls back to a simple key/value list. */
|
|
23
|
+
card?: (row: T) => JSX.Element;
|
|
24
|
+
/** Which views to offer (in order). Default: `['table', 'gallery']` plus `'board'` when `groupBy` is set. */
|
|
25
|
+
views?: DataViewMode[];
|
|
26
|
+
/** Controlled current view. Uncontrolled (internal signal) when omitted. */
|
|
27
|
+
view?: DataViewMode;
|
|
28
|
+
onViewChange?: (view: DataViewMode) => void;
|
|
29
|
+
class?: string;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* One dataset, switchable between table, board (grouped columns), and
|
|
33
|
+
* gallery (card grid) presentations. Generic over the row type — pass
|
|
34
|
+
* `columns` for the table, an optional `groupBy` to enable the board view,
|
|
35
|
+
* and an optional `card` renderer reused by board + gallery.
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```tsx
|
|
39
|
+
* <DataView
|
|
40
|
+
* data={tasks}
|
|
41
|
+
* getId={(t) => t.id}
|
|
42
|
+
* columns={[
|
|
43
|
+
* { key: 'title', header: 'Title' },
|
|
44
|
+
* { key: 'status', header: 'Status' },
|
|
45
|
+
* ]}
|
|
46
|
+
* groupBy={(t) => t.status}
|
|
47
|
+
* card={(t) => <span class="font-medium">{t.title}</span>}
|
|
48
|
+
* />
|
|
49
|
+
* ```
|
|
50
|
+
*/
|
|
51
|
+
export declare function DataView<T>(props: DataViewProps<T>): JSX.Element;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { JSX } from 'solid-js';
|
|
2
|
+
/** A single insertable item in a {@link SlashMenu}. */
|
|
3
|
+
export interface SlashItem {
|
|
4
|
+
/** Value passed to `onSelect` when this item is picked. */
|
|
5
|
+
value: string;
|
|
6
|
+
/** Primary text shown on the row. */
|
|
7
|
+
label: string;
|
|
8
|
+
/** Optional secondary text shown muted below the label. */
|
|
9
|
+
description?: string;
|
|
10
|
+
/** Optional leading icon. */
|
|
11
|
+
icon?: JSX.Element;
|
|
12
|
+
/** Extra terms matched against the query, in addition to label + description. */
|
|
13
|
+
keywords?: string[];
|
|
14
|
+
}
|
|
15
|
+
export interface SlashMenuProps {
|
|
16
|
+
items: SlashItem[];
|
|
17
|
+
/** Filter text — typically whatever the user typed after '/'. */
|
|
18
|
+
query: string;
|
|
19
|
+
/** Invoked with the picked item's `value` (click or Enter). */
|
|
20
|
+
onSelect: (value: string) => void;
|
|
21
|
+
/** Renders nothing when `false`. @default true */
|
|
22
|
+
open?: boolean;
|
|
23
|
+
class?: string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Floating, keyboard-navigable menu for a "/"-triggered insert/command flow.
|
|
27
|
+
* Filters `items` against `query` (case-insensitive match over label +
|
|
28
|
+
* description + keywords), highlights an active row (↑/↓, wraps), and picks
|
|
29
|
+
* it on Enter or click. Purely presentational: the caller owns the '/'
|
|
30
|
+
* trigger, `open`, and `query` — this component only renders the filtered
|
|
31
|
+
* list and reports the pick.
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* ```tsx
|
|
35
|
+
* const [query, setQuery] = createSignal('')
|
|
36
|
+
* <SlashMenu
|
|
37
|
+
* items={[{ value: 'heading', label: 'Heading', icon: <Heading size={16} /> }]}
|
|
38
|
+
* query={query()}
|
|
39
|
+
* onSelect={(value) => insertBlock(value)}
|
|
40
|
+
* />
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
export declare function SlashMenu(props: SlashMenuProps): JSX.Element;
|
package/package.json
CHANGED
package/preset.js
CHANGED
|
@@ -48,6 +48,30 @@ const glass = plugin(({ addComponents, addVariant }) => {
|
|
|
48
48
|
boxShadow: 'inset 0 1px 2px hsl(var(--shadow) / 0.18)',
|
|
49
49
|
},
|
|
50
50
|
|
|
51
|
+
// ---- Refractive glass ----
|
|
52
|
+
// A heavier glass surface with a specular sheen + bright top edge, so it
|
|
53
|
+
// reads as light refracting through the material rather than a flat blur.
|
|
54
|
+
// Sits over the Aurora backdrop like `.card`; use for hero/feature panels.
|
|
55
|
+
'.glass-refractive': {
|
|
56
|
+
position: 'relative',
|
|
57
|
+
overflow: 'hidden',
|
|
58
|
+
background: 'hsl(var(--card) / 0.5)',
|
|
59
|
+
backdropFilter: 'blur(14px) saturate(180%)',
|
|
60
|
+
WebkitBackdropFilter: 'blur(14px) saturate(180%)',
|
|
61
|
+
border: '1px solid hsl(var(--foreground) / 0.14)',
|
|
62
|
+
borderRadius: 'var(--radius-xl, 1rem)',
|
|
63
|
+
boxShadow:
|
|
64
|
+
'0 1px 2px hsl(var(--shadow) / 0.06), 0 8px 24px hsl(var(--shadow) / 0.14), inset 0 1px 0 hsl(0 0% 100% / 0.18)',
|
|
65
|
+
'&::before': {
|
|
66
|
+
content: '""',
|
|
67
|
+
position: 'absolute',
|
|
68
|
+
inset: '0',
|
|
69
|
+
borderRadius: 'inherit',
|
|
70
|
+
background: 'linear-gradient(150deg, hsl(0 0% 100% / 0.16), transparent 42%)',
|
|
71
|
+
pointerEvents: 'none',
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
|
|
51
75
|
// ---- Primary glass surface ----
|
|
52
76
|
'.card': {
|
|
53
77
|
position: 'relative',
|
package/src/index.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// import '@a4ui/core/styles.css'
|
|
9
9
|
// import { Button, Card, Modal } from '@a4ui/core'
|
|
10
10
|
|
|
11
|
-
export const A4UI_VERSION = '0.
|
|
11
|
+
export const A4UI_VERSION = '0.27.0'
|
|
12
12
|
|
|
13
13
|
// Helpers (src/lib) — generic, framework-level utilities.
|
|
14
14
|
export { cn } from './lib/cn'
|
|
@@ -36,6 +36,7 @@ export { remeasureAfterLayout } from './lib/virtual'
|
|
|
36
36
|
export { createOptimistic } from './lib/createOptimistic'
|
|
37
37
|
export { startViewTransition } from './lib/viewTransition'
|
|
38
38
|
export { EASE, SPRING, type EaseName, type SpringName } from './lib/easing'
|
|
39
|
+
export { createLinkedSelection, type LinkedSelection } from './lib/createLinkedSelection'
|
|
39
40
|
|
|
40
41
|
// UI components (src/ui) — all 18 extracted. See CLAUDE.md.
|
|
41
42
|
export { Accordion, type AccordionItem } from './ui/Accordion'
|
|
@@ -164,6 +165,8 @@ export { CodeTabs, type CodeTabsProps, type CodeTab } from './ui/CodeTabs'
|
|
|
164
165
|
export { PillSearch, type PillSearchProps, type PillField } from './ui/PillSearch'
|
|
165
166
|
export { CategoryStrip, type CategoryStripProps, type CategoryItem } from './ui/CategoryStrip'
|
|
166
167
|
export { MasterDetail, type MasterDetailProps, type MasterDetailItem } from './ui/MasterDetail'
|
|
168
|
+
export { SlashMenu, type SlashMenuProps, type SlashItem } from './ui/SlashMenu'
|
|
169
|
+
export { DataView, type DataViewProps, type DataViewColumn, type DataViewMode } from './ui/DataView'
|
|
167
170
|
|
|
168
171
|
// Motion components (src/ui) — animation primitives built on the `motion` engine
|
|
169
172
|
// (adapted from motion.dev examples). All tree-shakeable and reduced-motion aware;
|
package/src/layout/Aurora.tsx
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// Usage: render it once at the top of your layout and keep the page root's
|
|
9
9
|
// background transparent (don't put `bg-background` on the root) so the Aurora
|
|
10
10
|
// shows through — the component paints the base background itself.
|
|
11
|
-
import { For, onCleanup, onMount, type JSX } from 'solid-js'
|
|
11
|
+
import { For, onCleanup, onMount, Show, type JSX } from 'solid-js'
|
|
12
12
|
|
|
13
13
|
import { cn } from '../lib/cn'
|
|
14
14
|
import { motionReduced } from '../lib/motion'
|
|
@@ -24,9 +24,24 @@ export interface AuroraProps {
|
|
|
24
24
|
* on any `.glow-edge` cards). Reduced-motion aware (off when reduced). @default true
|
|
25
25
|
*/
|
|
26
26
|
pointerGlow?: boolean
|
|
27
|
+
/**
|
|
28
|
+
* Backdrop style: `'blobs'` (soft blurred color blobs) or `'mesh'` (a
|
|
29
|
+
* multi-point gradient mesh). Both are theme-tinted and drift when `animated`.
|
|
30
|
+
* @default 'blobs'
|
|
31
|
+
*/
|
|
32
|
+
variant?: 'blobs' | 'mesh'
|
|
27
33
|
class?: string
|
|
28
34
|
}
|
|
29
35
|
|
|
36
|
+
// Mesh variant — several token-tinted radial gradients composited into one
|
|
37
|
+
// backdrop (positions chosen to fill the corners without a hard seam).
|
|
38
|
+
const MESH_STOPS = [
|
|
39
|
+
{ at: '18% 22%', token: '--primary', a: 1 },
|
|
40
|
+
{ at: '82% 26%', token: '--accent', a: 0.9 },
|
|
41
|
+
{ at: '72% 82%', token: '--primary', a: 0.8 },
|
|
42
|
+
{ at: '14% 78%', token: '--accent', a: 0.85 },
|
|
43
|
+
] as const
|
|
44
|
+
|
|
30
45
|
// Static class strings (scanned by Tailwind). token = which theme color; a =
|
|
31
46
|
// per-blob opacity weight, multiplied by `intensity`.
|
|
32
47
|
const BLOBS = [
|
|
@@ -53,6 +68,11 @@ const BLOBS = [
|
|
|
53
68
|
*/
|
|
54
69
|
export function Aurora(props: AuroraProps): JSX.Element {
|
|
55
70
|
const intensity = (): number => props.intensity ?? 0.45
|
|
71
|
+
const meshBg = (): string =>
|
|
72
|
+
MESH_STOPS.map(
|
|
73
|
+
(s) =>
|
|
74
|
+
`radial-gradient(45% 45% at ${s.at}, hsl(var(${s.token}) / ${(intensity() * s.a).toFixed(2)}), transparent 70%)`,
|
|
75
|
+
).join(', ')
|
|
56
76
|
let root: HTMLDivElement | undefined
|
|
57
77
|
|
|
58
78
|
onMount(() => {
|
|
@@ -69,16 +89,26 @@ export function Aurora(props: AuroraProps): JSX.Element {
|
|
|
69
89
|
aria-hidden="true"
|
|
70
90
|
class={cn('pointer-events-none fixed inset-0 -z-10 overflow-hidden bg-background', props.class)}
|
|
71
91
|
>
|
|
72
|
-
<
|
|
73
|
-
{
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
92
|
+
<Show
|
|
93
|
+
when={props.variant === 'mesh'}
|
|
94
|
+
fallback={
|
|
95
|
+
<For each={BLOBS}>
|
|
96
|
+
{(b) => (
|
|
97
|
+
<div
|
|
98
|
+
class={cn('absolute rounded-full blur-3xl', b.pos, b.size, props.animated && 'aurora-drift')}
|
|
99
|
+
style={{
|
|
100
|
+
background: `radial-gradient(circle, hsl(var(${b.token}) / ${(intensity() * b.a).toFixed(2)}), transparent 70%)`,
|
|
101
|
+
}}
|
|
102
|
+
/>
|
|
103
|
+
)}
|
|
104
|
+
</For>
|
|
105
|
+
}
|
|
106
|
+
>
|
|
107
|
+
<div
|
|
108
|
+
class={cn('absolute -inset-[15%] blur-2xl', props.animated && 'aurora-drift')}
|
|
109
|
+
style={{ background: meshBg() }}
|
|
110
|
+
/>
|
|
111
|
+
</Show>
|
|
82
112
|
{/* Soft glow that follows the cursor across the backdrop (positioned by
|
|
83
113
|
bindPointerFx via left/top on pointermove). */}
|
|
84
114
|
<div
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Shared hover/selection state for two views of the same collection (e.g. a
|
|
2
|
+
// map and a list) so interacting with one highlights the other.
|
|
3
|
+
import { createSignal } from 'solid-js'
|
|
4
|
+
|
|
5
|
+
export interface LinkedSelection<Id> {
|
|
6
|
+
hoveredId: () => Id | undefined
|
|
7
|
+
selectedId: () => Id | undefined
|
|
8
|
+
setHovered: (id: Id | undefined) => void
|
|
9
|
+
setSelected: (id: Id | undefined) => void
|
|
10
|
+
isHovered: (id: Id) => boolean
|
|
11
|
+
isSelected: (id: Id) => boolean
|
|
12
|
+
/** Convenience props to spread onto an element for a given id (onPointerEnter/Leave + onClick). */
|
|
13
|
+
itemProps: (id: Id) => { onPointerEnter: () => void; onPointerLeave: () => void; onClick: () => void }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Shared hovered/selected state for two synchronized views of the same
|
|
18
|
+
* collection (a map and a list, a chart and a table, …): hover a list row to
|
|
19
|
+
* highlight the map pin, and vice versa.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* ```tsx
|
|
23
|
+
* const selection = createLinkedSelection<string>()
|
|
24
|
+
* return (
|
|
25
|
+
* <For each={items}>
|
|
26
|
+
* {(item) => <ListRow class={selection.isSelected(item.id) ? 'active' : ''} {...selection.itemProps(item.id)} />}
|
|
27
|
+
* </For>
|
|
28
|
+
* )
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
export function createLinkedSelection<Id = string>(initialSelected?: Id): LinkedSelection<Id> {
|
|
32
|
+
const [hoveredId, setHoveredId] = createSignal<Id | undefined>(undefined)
|
|
33
|
+
const [selectedId, setSelectedId] = createSignal<Id | undefined>(initialSelected)
|
|
34
|
+
|
|
35
|
+
function setHovered(id: Id | undefined) {
|
|
36
|
+
setHoveredId(() => id)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function setSelected(id: Id | undefined) {
|
|
40
|
+
setSelectedId(() => id)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isHovered(id: Id): boolean {
|
|
44
|
+
return hoveredId() === id
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isSelected(id: Id): boolean {
|
|
48
|
+
return selectedId() === id
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function itemProps(id: Id) {
|
|
52
|
+
return {
|
|
53
|
+
onPointerEnter: () => setHovered(id),
|
|
54
|
+
onPointerLeave: () => setHovered(undefined),
|
|
55
|
+
onClick: () => setSelected(id),
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return { hoveredId, selectedId, setHovered, setSelected, isHovered, isSelected, itemProps }
|
|
60
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// One dataset, three switchable presentations: table (reuses Table), board
|
|
2
|
+
// (rows grouped into columns, Kanban-style), and gallery (a card grid). The
|
|
3
|
+
// view switcher reuses SegmentedControl; board/gallery cards fall back to a
|
|
4
|
+
// simple key/value list when no `card` renderer is given.
|
|
5
|
+
import type { JSX } from 'solid-js'
|
|
6
|
+
import { createMemo, createSignal, For, Show } from 'solid-js'
|
|
7
|
+
|
|
8
|
+
import { cn } from '../lib/cn'
|
|
9
|
+
import { Badge } from './Badge'
|
|
10
|
+
import type { SegmentedOption } from './SegmentedControl'
|
|
11
|
+
import { SegmentedControl } from './SegmentedControl'
|
|
12
|
+
import { Table, TableBody, TableCell, TableHead, TableHeadCell, TableRow } from './Table'
|
|
13
|
+
|
|
14
|
+
/** A single table column for {@link DataView}'s table presentation. */
|
|
15
|
+
export interface DataViewColumn<T> {
|
|
16
|
+
/** Row property this column reads. */
|
|
17
|
+
key: string
|
|
18
|
+
/** Column heading text. */
|
|
19
|
+
header: string
|
|
20
|
+
/** Custom cell renderer; falls back to `String(row[key])` when omitted. */
|
|
21
|
+
cell?: (row: T) => JSX.Element
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Presentation mode offered by {@link DataView}. */
|
|
25
|
+
export type DataViewMode = 'table' | 'board' | 'gallery'
|
|
26
|
+
|
|
27
|
+
/** Props for {@link DataView}. */
|
|
28
|
+
export interface DataViewProps<T> {
|
|
29
|
+
data: T[]
|
|
30
|
+
/** Table columns; also used as the source of truth in board/gallery fallbacks. */
|
|
31
|
+
columns: DataViewColumn<T>[]
|
|
32
|
+
/** Stable row identifier, used as a DOM hook for tests/a11y. */
|
|
33
|
+
getId: (row: T) => string
|
|
34
|
+
/** For board view: group rows into columns by this key. If omitted, board mode is not offered. */
|
|
35
|
+
groupBy?: (row: T) => string
|
|
36
|
+
/** Card renderer for board + gallery. Falls back to a simple key/value list. */
|
|
37
|
+
card?: (row: T) => JSX.Element
|
|
38
|
+
/** Which views to offer (in order). Default: `['table', 'gallery']` plus `'board'` when `groupBy` is set. */
|
|
39
|
+
views?: DataViewMode[]
|
|
40
|
+
/** Controlled current view. Uncontrolled (internal signal) when omitted. */
|
|
41
|
+
view?: DataViewMode
|
|
42
|
+
onViewChange?: (view: DataViewMode) => void
|
|
43
|
+
class?: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const MODE_LABELS: Record<DataViewMode, string> = {
|
|
47
|
+
table: 'Table',
|
|
48
|
+
board: 'Board',
|
|
49
|
+
gallery: 'Gallery',
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function defaultModes<T>(props: DataViewProps<T>): DataViewMode[] {
|
|
53
|
+
if (props.views) return props.views
|
|
54
|
+
return props.groupBy ? ['table', 'board', 'gallery'] : ['table', 'gallery']
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Key/value fallback used for board + gallery cards when no `card` renderer is given. */
|
|
58
|
+
function FallbackCard<T>(props: { row: T }): JSX.Element {
|
|
59
|
+
const entries = () => Object.entries(props.row as Record<string, unknown>)
|
|
60
|
+
return (
|
|
61
|
+
<dl class="flex flex-col gap-1 text-sm">
|
|
62
|
+
<For each={entries()}>
|
|
63
|
+
{([key, value]) => (
|
|
64
|
+
<div class="flex items-center justify-between gap-3">
|
|
65
|
+
<dt class="text-muted-foreground">{key}</dt>
|
|
66
|
+
<dd class="truncate font-medium text-foreground">{String(value)}</dd>
|
|
67
|
+
</div>
|
|
68
|
+
)}
|
|
69
|
+
</For>
|
|
70
|
+
</dl>
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* One dataset, switchable between table, board (grouped columns), and
|
|
76
|
+
* gallery (card grid) presentations. Generic over the row type — pass
|
|
77
|
+
* `columns` for the table, an optional `groupBy` to enable the board view,
|
|
78
|
+
* and an optional `card` renderer reused by board + gallery.
|
|
79
|
+
*
|
|
80
|
+
* @example
|
|
81
|
+
* ```tsx
|
|
82
|
+
* <DataView
|
|
83
|
+
* data={tasks}
|
|
84
|
+
* getId={(t) => t.id}
|
|
85
|
+
* columns={[
|
|
86
|
+
* { key: 'title', header: 'Title' },
|
|
87
|
+
* { key: 'status', header: 'Status' },
|
|
88
|
+
* ]}
|
|
89
|
+
* groupBy={(t) => t.status}
|
|
90
|
+
* card={(t) => <span class="font-medium">{t.title}</span>}
|
|
91
|
+
* />
|
|
92
|
+
* ```
|
|
93
|
+
*/
|
|
94
|
+
export function DataView<T>(props: DataViewProps<T>): JSX.Element {
|
|
95
|
+
const modes = createMemo<DataViewMode[]>(() => defaultModes(props))
|
|
96
|
+
|
|
97
|
+
const [internalView, setInternalView] = createSignal<DataViewMode>(props.view ?? modes()[0] ?? 'table')
|
|
98
|
+
|
|
99
|
+
const view = createMemo(() => props.view ?? internalView())
|
|
100
|
+
|
|
101
|
+
const setView = (next: DataViewMode) => {
|
|
102
|
+
if (props.view === undefined) setInternalView(next)
|
|
103
|
+
props.onViewChange?.(next)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const options = createMemo<SegmentedOption[]>(() =>
|
|
107
|
+
modes().map((mode) => ({ value: mode, label: MODE_LABELS[mode] })),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
const groups = createMemo(() => {
|
|
111
|
+
const groupBy = props.groupBy
|
|
112
|
+
if (!groupBy) return []
|
|
113
|
+
const byKey = new Map<string, T[]>()
|
|
114
|
+
for (const row of props.data) {
|
|
115
|
+
const key = groupBy(row)
|
|
116
|
+
const bucket = byKey.get(key)
|
|
117
|
+
if (bucket) bucket.push(row)
|
|
118
|
+
else byKey.set(key, [row])
|
|
119
|
+
}
|
|
120
|
+
return Array.from(byKey.entries()).map(([key, rows]) => ({ key, rows }))
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<div class={cn('flex w-full flex-col gap-4', props.class)}>
|
|
125
|
+
<div class="flex items-center justify-end">
|
|
126
|
+
<SegmentedControl
|
|
127
|
+
value={view()}
|
|
128
|
+
onChange={(next) => setView(next as DataViewMode)}
|
|
129
|
+
options={options()}
|
|
130
|
+
/>
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
<Show when={view() === 'table'}>
|
|
134
|
+
<Table>
|
|
135
|
+
<TableHead>
|
|
136
|
+
<TableRow>
|
|
137
|
+
<For each={props.columns}>{(column) => <TableHeadCell>{column.header}</TableHeadCell>}</For>
|
|
138
|
+
</TableRow>
|
|
139
|
+
</TableHead>
|
|
140
|
+
<TableBody>
|
|
141
|
+
<For each={props.data}>
|
|
142
|
+
{(row) => (
|
|
143
|
+
<TableRow data-row-id={props.getId(row)}>
|
|
144
|
+
<For each={props.columns}>
|
|
145
|
+
{(column) => (
|
|
146
|
+
<TableCell>
|
|
147
|
+
{column.cell?.(row) ?? String((row as Record<string, unknown>)[column.key])}
|
|
148
|
+
</TableCell>
|
|
149
|
+
)}
|
|
150
|
+
</For>
|
|
151
|
+
</TableRow>
|
|
152
|
+
)}
|
|
153
|
+
</For>
|
|
154
|
+
</TableBody>
|
|
155
|
+
</Table>
|
|
156
|
+
</Show>
|
|
157
|
+
|
|
158
|
+
<Show when={view() === 'board' && props.groupBy}>
|
|
159
|
+
<div class="flex gap-4 overflow-x-auto">
|
|
160
|
+
<For each={groups()}>
|
|
161
|
+
{(group) => (
|
|
162
|
+
<div class="flex min-w-64 flex-col gap-3 rounded-xl border border-border bg-card p-3 text-card-foreground">
|
|
163
|
+
<div class="flex items-center justify-between gap-2 text-sm font-semibold">
|
|
164
|
+
<span class="truncate">{group.key}</span>
|
|
165
|
+
<Badge tone="neutral">{group.rows.length}</Badge>
|
|
166
|
+
</div>
|
|
167
|
+
<div class="flex flex-col gap-2">
|
|
168
|
+
<For each={group.rows}>
|
|
169
|
+
{(row) => (
|
|
170
|
+
<div
|
|
171
|
+
data-row-id={props.getId(row)}
|
|
172
|
+
class="rounded-lg border border-border bg-background p-3"
|
|
173
|
+
>
|
|
174
|
+
{props.card?.(row) ?? <FallbackCard row={row} />}
|
|
175
|
+
</div>
|
|
176
|
+
)}
|
|
177
|
+
</For>
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
)}
|
|
181
|
+
</For>
|
|
182
|
+
</div>
|
|
183
|
+
</Show>
|
|
184
|
+
|
|
185
|
+
<Show when={view() === 'gallery'}>
|
|
186
|
+
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
187
|
+
<For each={props.data}>
|
|
188
|
+
{(row) => (
|
|
189
|
+
<div data-row-id={props.getId(row)} class="card rounded-xl bg-card p-4 text-card-foreground">
|
|
190
|
+
{props.card?.(row) ?? <FallbackCard row={row} />}
|
|
191
|
+
</div>
|
|
192
|
+
)}
|
|
193
|
+
</For>
|
|
194
|
+
</div>
|
|
195
|
+
</Show>
|
|
196
|
+
</div>
|
|
197
|
+
)
|
|
198
|
+
}
|