@a4ui/core 0.33.0 → 0.35.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/dist/elements.css +299 -0
- package/dist/full.css +299 -0
- package/dist/index.d.ts +27 -1
- package/dist/index.js +9140 -5065
- package/dist/ui/ActivityFeed.d.ts +49 -0
- package/dist/ui/AudioWaveform.d.ts +22 -0
- package/dist/ui/AvailabilityPicker.d.ts +35 -0
- package/dist/ui/CodeEditor.d.ts +25 -0
- package/dist/ui/CouponField.d.ts +33 -0
- package/dist/ui/EmojiPicker.d.ts +18 -0
- package/dist/ui/EventScheduler.d.ts +41 -0
- package/dist/ui/FollowingPointer.d.ts +24 -0
- package/dist/ui/GanttChart.d.ts +37 -0
- package/dist/ui/InteractiveMap.d.ts +50 -0
- package/dist/ui/JsonViewer.d.ts +24 -0
- package/dist/ui/Kanban.d.ts +49 -0
- package/dist/ui/Lamp.d.ts +22 -0
- package/dist/ui/Lightbox.d.ts +41 -0
- package/dist/ui/LocationPicker.d.ts +25 -0
- package/dist/ui/MaskedInput.d.ts +26 -0
- package/dist/ui/OnboardingChecklist.d.ts +51 -0
- package/dist/ui/OtpInput.d.ts +29 -0
- package/dist/ui/PivotTable.d.ts +37 -0
- package/dist/ui/PresenceAvatars.d.ts +54 -0
- package/dist/ui/QueryBuilder.d.ts +56 -0
- package/dist/ui/SheetSnap.d.ts +28 -0
- package/dist/ui/SignaturePad.d.ts +19 -0
- package/dist/ui/SpreadsheetGrid.d.ts +27 -0
- package/dist/ui/TreeTable.d.ts +52 -0
- package/dist/ui/VideoPlayerShell.d.ts +19 -0
- package/package.json +1 -1
- package/src/index.ts +60 -1
- package/src/ui/ActivityFeed.tsx +178 -0
- package/src/ui/AudioWaveform.tsx +190 -0
- package/src/ui/AvailabilityPicker.tsx +163 -0
- package/src/ui/CodeEditor.tsx +277 -0
- package/src/ui/CouponField.tsx +120 -0
- package/src/ui/EmojiPicker.tsx +424 -0
- package/src/ui/EventScheduler.tsx +280 -0
- package/src/ui/FollowingPointer.tsx +112 -0
- package/src/ui/GanttChart.tsx +283 -0
- package/src/ui/InteractiveMap.tsx +349 -0
- package/src/ui/JsonViewer.tsx +189 -0
- package/src/ui/Kanban.tsx +250 -0
- package/src/ui/Lamp.tsx +88 -0
- package/src/ui/Lightbox.tsx +261 -0
- package/src/ui/LocationPicker.tsx +96 -0
- package/src/ui/MaskedInput.tsx +108 -0
- package/src/ui/OnboardingChecklist.tsx +163 -0
- package/src/ui/OtpInput.tsx +153 -0
- package/src/ui/PivotTable.tsx +0 -0
- package/src/ui/PresenceAvatars.tsx +142 -0
- package/src/ui/QueryBuilder.tsx +280 -0
- package/src/ui/SheetSnap.tsx +264 -0
- package/src/ui/SignaturePad.tsx +221 -0
- package/src/ui/SpreadsheetGrid.tsx +304 -0
- package/src/ui/TreeTable.tsx +172 -0
- package/src/ui/VideoPlayerShell.tsx +282 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { JSX } from 'solid-js';
|
|
2
|
+
/** A user currently present/active in a collaborative session. */
|
|
3
|
+
export interface PresenceUser {
|
|
4
|
+
id: string;
|
|
5
|
+
name: string;
|
|
6
|
+
avatar?: string;
|
|
7
|
+
/** Accent for this user's remote cursor label. Falls back to the primary token. */
|
|
8
|
+
color?: string;
|
|
9
|
+
}
|
|
10
|
+
/** A remote user's pointer position, as fractions of the shared canvas. */
|
|
11
|
+
export interface RemoteCursor {
|
|
12
|
+
userId: string;
|
|
13
|
+
/** 0..1 fraction of the container's width. */
|
|
14
|
+
x: number;
|
|
15
|
+
/** 0..1 fraction of the container's height. */
|
|
16
|
+
y: number;
|
|
17
|
+
}
|
|
18
|
+
export interface PresenceAvatarsProps {
|
|
19
|
+
users: PresenceUser[];
|
|
20
|
+
/** Max avatars shown before collapsing the rest into `+N`. @default 4 */
|
|
21
|
+
max?: number;
|
|
22
|
+
/**
|
|
23
|
+
* Remote cursors to render. Positioned absolutely against the nearest
|
|
24
|
+
* `relative` ancestor, so the caller wraps its canvas/surface with
|
|
25
|
+
* `class="relative"` for the cursors to anchor to — this component itself
|
|
26
|
+
* does not create that positioning context.
|
|
27
|
+
*/
|
|
28
|
+
cursors?: RemoteCursor[];
|
|
29
|
+
class?: string;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Stacked avatar group of users active in a collaborative session, each with
|
|
33
|
+
* a small online dot and a name tooltip; overflow past `max` (default 4)
|
|
34
|
+
* collapses into a trailing `+N` chip, mirroring {@link AvatarGroup}. When
|
|
35
|
+
* `cursors` is given, also renders labeled remote cursors — a small arrow
|
|
36
|
+
* plus a name chip tinted with the user's `color` — positioned at `x`/`y`
|
|
37
|
+
* fractions of the nearest `relative` ancestor and animated smoothly as
|
|
38
|
+
* positions update.
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* ```tsx
|
|
42
|
+
* <div class="relative h-64 w-full overflow-hidden rounded-lg border">
|
|
43
|
+
* <PresenceAvatars
|
|
44
|
+
* class="absolute right-3 top-3"
|
|
45
|
+
* users={[
|
|
46
|
+
* { id: 'u1', name: 'Ada Lovelace' },
|
|
47
|
+
* { id: 'u2', name: 'Grace Hopper', color: 'hsl(280 80% 60%)' },
|
|
48
|
+
* ]}
|
|
49
|
+
* cursors={[{ userId: 'u2', x: 0.42, y: 0.6 }]}
|
|
50
|
+
* />
|
|
51
|
+
* </div>
|
|
52
|
+
* ```
|
|
53
|
+
*/
|
|
54
|
+
export declare function PresenceAvatars(props: PresenceAvatarsProps): JSX.Element;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { JSX } from 'solid-js';
|
|
2
|
+
/** A field the query builder can filter on. */
|
|
3
|
+
export interface QueryField {
|
|
4
|
+
/** Value stored in a rule's `field`; must be unique across `fields`. */
|
|
5
|
+
name: string;
|
|
6
|
+
/** Label shown in the field `<Select>`. */
|
|
7
|
+
label: string;
|
|
8
|
+
/** Determines which operators are offered and how the value is entered. Defaults to `'text'`. */
|
|
9
|
+
type?: 'text' | 'number' | 'select';
|
|
10
|
+
/** Options for the value `<Select>` when `type` is `'select'`. */
|
|
11
|
+
options?: string[];
|
|
12
|
+
}
|
|
13
|
+
/** A single leaf condition: `field operator value`. */
|
|
14
|
+
export type QueryRule = {
|
|
15
|
+
field: string;
|
|
16
|
+
operator: string;
|
|
17
|
+
value: string;
|
|
18
|
+
};
|
|
19
|
+
/** A group of rules and/or nested groups, combined with `and`/`or`. */
|
|
20
|
+
export type QueryGroup = {
|
|
21
|
+
combinator: 'and' | 'or';
|
|
22
|
+
rules: (QueryRule | QueryGroup)[];
|
|
23
|
+
};
|
|
24
|
+
export interface QueryBuilderProps {
|
|
25
|
+
/** Fields available to filter on; drives the per-field operator and value-input set. */
|
|
26
|
+
fields: QueryField[];
|
|
27
|
+
/** Controlled tree. Omit to manage state internally, seeded with one empty rule. */
|
|
28
|
+
value?: QueryGroup;
|
|
29
|
+
/** Called with the full, updated tree on every edit. */
|
|
30
|
+
onChange?: (group: QueryGroup) => void;
|
|
31
|
+
class?: string;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Nested AND/OR rule builder: each group has a combinator toggle plus "+ Rule"
|
|
35
|
+
* / "+ Group" buttons, and rules/groups can be removed individually. Groups
|
|
36
|
+
* nest recursively, indented with a left border. Controlled via `value` +
|
|
37
|
+
* `onChange`, or uncontrolled (seeded with one empty rule) when `value` is
|
|
38
|
+
* omitted.
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* ```tsx
|
|
42
|
+
* const [query, setQuery] = createSignal<QueryGroup>({
|
|
43
|
+
* combinator: 'and',
|
|
44
|
+
* rules: [{ field: 'status', operator: 'is', value: 'open' }],
|
|
45
|
+
* })
|
|
46
|
+
* <QueryBuilder
|
|
47
|
+
* fields={[
|
|
48
|
+
* { name: 'status', label: 'Status', type: 'select', options: ['open', 'closed'] },
|
|
49
|
+
* { name: 'age', label: 'Age', type: 'number' },
|
|
50
|
+
* ]}
|
|
51
|
+
* value={query()}
|
|
52
|
+
* onChange={setQuery}
|
|
53
|
+
* />
|
|
54
|
+
* ```
|
|
55
|
+
*/
|
|
56
|
+
export declare function QueryBuilder(props: QueryBuilderProps): JSX.Element;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { JSX } from 'solid-js';
|
|
2
|
+
export interface SheetSnapProps {
|
|
3
|
+
open: boolean;
|
|
4
|
+
onOpenChange: (open: boolean) => void;
|
|
5
|
+
/** Fractions of viewport height, ascending, e.g. `[0.4, 0.9]`. @default [0.5, 0.9] */
|
|
6
|
+
snapPoints?: number[];
|
|
7
|
+
/** Index into `snapPoints` to open at. @default 0 */
|
|
8
|
+
defaultSnap?: number;
|
|
9
|
+
children: JSX.Element;
|
|
10
|
+
class?: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* A draggable bottom sheet that rests at one of several height "snap points"
|
|
14
|
+
* (fractions of viewport height) instead of just open/closed. Drag the handle
|
|
15
|
+
* to resize with the finger; releasing snaps to the nearest point, biased by
|
|
16
|
+
* flick velocity (a fast swipe jumps a level), and a hard downward flick/drag
|
|
17
|
+
* past the lowest point dismisses. Falls back to an instant show/hide at
|
|
18
|
+
* `defaultSnap` — no drag physics — under reduced motion.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```tsx
|
|
22
|
+
* const [open, setOpen] = createSignal(false)
|
|
23
|
+
* <SheetSnap open={open()} onOpenChange={setOpen} snapPoints={[0.4, 0.9]}>
|
|
24
|
+
* <p>Sheet content…</p>
|
|
25
|
+
* </SheetSnap>
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
export declare function SheetSnap(props: SheetSnapProps): JSX.Element;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { JSX } from 'solid-js';
|
|
2
|
+
export interface SignaturePadProps {
|
|
3
|
+
/** Called with a PNG data URL after each completed stroke, or `null` once the pad is empty (cleared/undone to nothing). */
|
|
4
|
+
onChange?: (dataUrl: string | null) => void;
|
|
5
|
+
/** Canvas height in CSS pixels. @default 200 */
|
|
6
|
+
height?: number;
|
|
7
|
+
class?: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* A signature capture pad: draw with mouse, pen, or touch on a DPR-aware
|
|
11
|
+
* `<canvas>`, with Clear and Undo controls. Strokes are kept as point lists so
|
|
12
|
+
* Undo can fully redraw the remaining ones rather than trying to erase pixels.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```tsx
|
|
16
|
+
* <SignaturePad height={220} onChange={(dataUrl) => setSignature(dataUrl)} />
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
export declare function SignaturePad(props: SignaturePadProps): JSX.Element;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { JSX } from 'solid-js';
|
|
2
|
+
export interface SpreadsheetGridProps {
|
|
3
|
+
/** Number of rows in the grid (fixed for the component's lifetime). */
|
|
4
|
+
rows: number;
|
|
5
|
+
/** Number of columns in the grid (fixed for the component's lifetime). */
|
|
6
|
+
cols: number;
|
|
7
|
+
/** Initial cell values, seeded once on mount (missing cells default to `''`). */
|
|
8
|
+
data?: string[][];
|
|
9
|
+
/** Called with the full `rows×cols` matrix after any edit or paste. */
|
|
10
|
+
onChange?: (data: string[][]) => void;
|
|
11
|
+
/** Column header labels; defaults to `A, B, C, …, Z, AA, AB, …`. */
|
|
12
|
+
columnHeaders?: string[];
|
|
13
|
+
class?: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Excel-like editable grid. Arrow keys move the active cell; typing or Enter
|
|
17
|
+
* opens an inline editor that commits on Enter/blur and cancels on Escape.
|
|
18
|
+
* Shift+click / Shift+arrow extend a rectangular selection (Ctrl/Cmd+C copies
|
|
19
|
+
* it as TSV, Ctrl/Cmd+V pastes TSV starting at the active cell).
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* ```tsx
|
|
23
|
+
* const [sheet, setSheet] = createSignal<string[][]>([])
|
|
24
|
+
* <SpreadsheetGrid rows={10} cols={6} onChange={setSheet} />
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
export declare function SpreadsheetGrid(props: SpreadsheetGridProps): JSX.Element;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { JSX } from 'solid-js';
|
|
2
|
+
/** A column definition for {@link TreeTable}. */
|
|
3
|
+
export interface TreeTableColumn<T> {
|
|
4
|
+
/** Row data property this column reads when `cell` is omitted. */
|
|
5
|
+
key: string;
|
|
6
|
+
/** Column heading content. */
|
|
7
|
+
header: JSX.Element;
|
|
8
|
+
/** Custom cell renderer; falls back to `String(row[key])` when omitted. */
|
|
9
|
+
cell?: (row: T) => JSX.Element;
|
|
10
|
+
/** Right-align the column (numbers, totals). */
|
|
11
|
+
align?: 'left' | 'right';
|
|
12
|
+
}
|
|
13
|
+
/** A single node in a {@link TreeTable}; nodes may nest via `children`. */
|
|
14
|
+
export interface TreeTableRow<T> {
|
|
15
|
+
/** Unique identifier; used to track expanded state. */
|
|
16
|
+
id: string;
|
|
17
|
+
/** Row payload passed to each column's `cell` renderer. */
|
|
18
|
+
data: T;
|
|
19
|
+
/** Child rows revealed when this row is expanded. */
|
|
20
|
+
children?: TreeTableRow<T>[];
|
|
21
|
+
}
|
|
22
|
+
export interface TreeTableProps<T> {
|
|
23
|
+
columns: TreeTableColumn<T>[];
|
|
24
|
+
rows: TreeTableRow<T>[];
|
|
25
|
+
/** Expand every row with children on first render. Defaults to false. */
|
|
26
|
+
defaultExpanded?: boolean;
|
|
27
|
+
class?: string;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Hierarchical table: rows may carry `children`, revealed by clicking the
|
|
31
|
+
* chevron in the first column. Depth is shown via indentation; expanded state
|
|
32
|
+
* is tracked internally, seeded fully expanded when `defaultExpanded` is set.
|
|
33
|
+
*
|
|
34
|
+
* @example
|
|
35
|
+
* ```tsx
|
|
36
|
+
* <TreeTable
|
|
37
|
+
* defaultExpanded
|
|
38
|
+
* columns={[
|
|
39
|
+
* { key: 'name', header: 'Name' },
|
|
40
|
+
* { key: 'size', header: 'Size', align: 'right', cell: (row) => `${row.size} KB` },
|
|
41
|
+
* ]}
|
|
42
|
+
* rows={[
|
|
43
|
+
* {
|
|
44
|
+
* id: 'src',
|
|
45
|
+
* data: { name: 'src', size: 0 },
|
|
46
|
+
* children: [{ id: 'index', data: { name: 'index.ts', size: 2 } }],
|
|
47
|
+
* },
|
|
48
|
+
* ]}
|
|
49
|
+
* />
|
|
50
|
+
* ```
|
|
51
|
+
*/
|
|
52
|
+
export declare function TreeTable<T>(props: TreeTableProps<T>): JSX.Element;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { JSX } from 'solid-js';
|
|
2
|
+
export interface VideoPlayerShellProps {
|
|
3
|
+
src: string;
|
|
4
|
+
poster?: string;
|
|
5
|
+
class?: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Native `<video>` wrapped in a glass shell with fully custom controls: a big
|
|
9
|
+
* center play/pause, a bottom bar (play/pause, elapsed/duration, a scrub
|
|
10
|
+
* range, mute toggle, fullscreen, Picture-in-Picture), and a keyboard map
|
|
11
|
+
* (Space = play/pause, ArrowLeft/Right = seek ±5s). Controls auto-hide while
|
|
12
|
+
* playing and reappear on pointer movement.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```tsx
|
|
16
|
+
* <VideoPlayerShell src="/media/demo.mp4" poster="/media/demo-poster.jpg" />
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
export declare function VideoPlayerShell(props: VideoPlayerShellProps): JSX.Element;
|
package/package.json
CHANGED
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.35.0'
|
|
12
12
|
|
|
13
13
|
// Helpers (src/lib) — generic, framework-level utilities.
|
|
14
14
|
export { cn } from './lib/cn'
|
|
@@ -209,6 +209,65 @@ export { Globe, type GlobeMarker, type GlobeArc, type GlobeProps } from './ui/Gl
|
|
|
209
209
|
export { WorldMap, type WorldMapPoint, type WorldMapConnection, type WorldMapProps } from './ui/WorldMap'
|
|
210
210
|
export { Confetti, fireConfetti, type ConfettiProps } from './ui/Confetti'
|
|
211
211
|
export { CursorTrail, type CursorTrailProps } from './ui/CursorTrail'
|
|
212
|
+
|
|
213
|
+
// Productivity data views.
|
|
214
|
+
export { Kanban, type KanbanCard, type KanbanColumn, type KanbanProps } from './ui/Kanban'
|
|
215
|
+
export { GanttChart, type GanttTask, type GanttChartProps } from './ui/GanttChart'
|
|
216
|
+
export { TreeTable, type TreeTableColumn, type TreeTableRow, type TreeTableProps } from './ui/TreeTable'
|
|
217
|
+
export { PivotTable, type PivotDatum, type PivotTableProps } from './ui/PivotTable'
|
|
218
|
+
|
|
219
|
+
// Onboarding + commerce.
|
|
220
|
+
export {
|
|
221
|
+
OnboardingChecklist,
|
|
222
|
+
type OnboardingStep,
|
|
223
|
+
type OnboardingChecklistProps,
|
|
224
|
+
} from './ui/OnboardingChecklist'
|
|
225
|
+
export { CouponField, type CouponFieldProps } from './ui/CouponField'
|
|
226
|
+
|
|
227
|
+
// Media.
|
|
228
|
+
export { Lightbox, type LightboxImage, type LightboxProps } from './ui/Lightbox'
|
|
229
|
+
export { VideoPlayerShell, type VideoPlayerShellProps } from './ui/VideoPlayerShell'
|
|
230
|
+
export { AudioWaveform, type AudioWaveformProps } from './ui/AudioWaveform'
|
|
231
|
+
|
|
232
|
+
// Spatial + interaction.
|
|
233
|
+
export { Lamp, type LampProps } from './ui/Lamp'
|
|
234
|
+
export { FollowingPointer, type FollowingPointerProps } from './ui/FollowingPointer'
|
|
235
|
+
export { SheetSnap, type SheetSnapProps } from './ui/SheetSnap'
|
|
236
|
+
|
|
237
|
+
// Advanced form inputs.
|
|
238
|
+
export { OtpInput, type OtpInputProps } from './ui/OtpInput'
|
|
239
|
+
export { MaskedInput, type MaskedInputProps } from './ui/MaskedInput'
|
|
240
|
+
export { CodeEditor, type CodeEditorProps } from './ui/CodeEditor'
|
|
241
|
+
export { SignaturePad, type SignaturePadProps } from './ui/SignaturePad'
|
|
242
|
+
export { EmojiPicker, type EmojiPickerProps } from './ui/EmojiPicker'
|
|
243
|
+
|
|
244
|
+
// Scheduling.
|
|
245
|
+
export { EventScheduler, type SchedulerEvent, type EventSchedulerProps } from './ui/EventScheduler'
|
|
246
|
+
export { AvailabilityPicker, type AvailabilityPickerProps } from './ui/AvailabilityPicker'
|
|
247
|
+
|
|
248
|
+
// Geo.
|
|
249
|
+
export { InteractiveMap, type MapMarker, type InteractiveMapProps } from './ui/InteractiveMap'
|
|
250
|
+
export { LocationPicker, type LocationValue, type LocationPickerProps } from './ui/LocationPicker'
|
|
251
|
+
|
|
252
|
+
// Dev / data tooling.
|
|
253
|
+
export {
|
|
254
|
+
QueryBuilder,
|
|
255
|
+
type QueryField,
|
|
256
|
+
type QueryRule,
|
|
257
|
+
type QueryGroup,
|
|
258
|
+
type QueryBuilderProps,
|
|
259
|
+
} from './ui/QueryBuilder'
|
|
260
|
+
export { JsonViewer, type JsonViewerProps } from './ui/JsonViewer'
|
|
261
|
+
export { SpreadsheetGrid, type SpreadsheetGridProps } from './ui/SpreadsheetGrid'
|
|
262
|
+
|
|
263
|
+
// Collaboration.
|
|
264
|
+
export {
|
|
265
|
+
PresenceAvatars,
|
|
266
|
+
type PresenceUser,
|
|
267
|
+
type RemoteCursor,
|
|
268
|
+
type PresenceAvatarsProps,
|
|
269
|
+
} from './ui/PresenceAvatars'
|
|
270
|
+
export { ActivityFeed, type ActivityItem, type ActivityFeedProps } from './ui/ActivityFeed'
|
|
212
271
|
export {
|
|
213
272
|
CopyButton,
|
|
214
273
|
type CopyButtonProps,
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// Chronological audit-trail list, newest first, grouped by day with a
|
|
2
|
+
// Timeline-style connector line down the left. Distinct from
|
|
3
|
+
// TransactionFeed (dense finance rows, no day grouping/connector) — this is
|
|
4
|
+
// generic activity/audit history ("who did what, when").
|
|
5
|
+
import { Activity } from 'lucide-solid'
|
|
6
|
+
import type { JSX } from 'solid-js'
|
|
7
|
+
import { For, Show } from 'solid-js'
|
|
8
|
+
|
|
9
|
+
import { cn } from '../lib/cn'
|
|
10
|
+
import { Avatar } from './Avatar'
|
|
11
|
+
|
|
12
|
+
/** A single audit-trail entry rendered by {@link ActivityFeed}. */
|
|
13
|
+
export interface ActivityItem {
|
|
14
|
+
id: string
|
|
15
|
+
/** Name of the user/system that performed the action. */
|
|
16
|
+
actor: string
|
|
17
|
+
/** What happened, rendered right after `actor` (e.g. `"commented on Invoice #42"`). */
|
|
18
|
+
action: JSX.Element
|
|
19
|
+
/** Leading icon shown when `avatar` is omitted. Defaults to a generic activity icon. */
|
|
20
|
+
icon?: JSX.Element
|
|
21
|
+
/** ISO 8601 timestamp of the event. */
|
|
22
|
+
timestamp: string
|
|
23
|
+
avatar?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ActivityFeedProps {
|
|
27
|
+
items: ActivityItem[]
|
|
28
|
+
class?: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface ActivityGroup {
|
|
32
|
+
label: string
|
|
33
|
+
items: ActivityItem[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const DAY_MS = 86_400_000
|
|
37
|
+
|
|
38
|
+
function startOfDay(date: Date): number {
|
|
39
|
+
return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** `"Today"` / `"Yesterday"` / a locale date, based on calendar-day distance from now. */
|
|
43
|
+
function dayLabel(timestamp: string): string {
|
|
44
|
+
const date = new Date(timestamp)
|
|
45
|
+
const diffDays = Math.round((startOfDay(new Date()) - startOfDay(date)) / DAY_MS)
|
|
46
|
+
if (diffDays === 0) return 'Today'
|
|
47
|
+
if (diffDays === 1) return 'Yesterday'
|
|
48
|
+
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Compact relative time (`"2h ago"`, `"just now"`) computed from an ISO timestamp. */
|
|
52
|
+
function relativeTime(timestamp: string): string {
|
|
53
|
+
const diffSeconds = Math.round((Date.now() - new Date(timestamp).getTime()) / 1000)
|
|
54
|
+
if (diffSeconds < 60) return 'just now'
|
|
55
|
+
const diffMinutes = Math.round(diffSeconds / 60)
|
|
56
|
+
if (diffMinutes < 60) return `${diffMinutes}m ago`
|
|
57
|
+
const diffHours = Math.round(diffMinutes / 60)
|
|
58
|
+
if (diffHours < 24) return `${diffHours}h ago`
|
|
59
|
+
const diffDays = Math.round(diffHours / 24)
|
|
60
|
+
if (diffDays < 7) return `${diffDays}d ago`
|
|
61
|
+
const diffWeeks = Math.round(diffDays / 7)
|
|
62
|
+
if (diffWeeks < 5) return `${diffWeeks}w ago`
|
|
63
|
+
const diffMonths = Math.round(diffDays / 30)
|
|
64
|
+
if (diffMonths < 12) return `${diffMonths}mo ago`
|
|
65
|
+
return `${Math.round(diffDays / 365)}y ago`
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function initials(name: string): string {
|
|
69
|
+
return name
|
|
70
|
+
.split(' ')
|
|
71
|
+
.filter(Boolean)
|
|
72
|
+
.slice(0, 2)
|
|
73
|
+
.map((part) => part[0]?.toUpperCase())
|
|
74
|
+
.join('')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Groups items newest-first by calendar day, preserving first-seen group order. */
|
|
78
|
+
function groupByDay(items: ActivityItem[]): ActivityGroup[] {
|
|
79
|
+
const sorted = [...items].sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
|
|
80
|
+
const groups: ActivityGroup[] = []
|
|
81
|
+
const byLabel = new Map<string, ActivityGroup>()
|
|
82
|
+
for (const item of sorted) {
|
|
83
|
+
const label = dayLabel(item.timestamp)
|
|
84
|
+
let group = byLabel.get(label)
|
|
85
|
+
if (!group) {
|
|
86
|
+
group = { label, items: [] }
|
|
87
|
+
byLabel.set(label, group)
|
|
88
|
+
groups.push(group)
|
|
89
|
+
}
|
|
90
|
+
group.items.push(item)
|
|
91
|
+
}
|
|
92
|
+
return groups
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Chronological audit-trail list (newest first), grouped under subtle day
|
|
97
|
+
* headers (`"Today"` / `"Yesterday"` / a locale date) with a continuous
|
|
98
|
+
* connector line down the left, in the same spirit as {@link Timeline}. Each
|
|
99
|
+
* row shows an avatar (or a fallback icon), `"{actor} {action}"`, and a
|
|
100
|
+
* relative timestamp whose `title` carries the absolute time. Distinct from
|
|
101
|
+
* {@link TransactionFeed}: this is generic activity/audit history, not a
|
|
102
|
+
* financial ledger.
|
|
103
|
+
*
|
|
104
|
+
* @example
|
|
105
|
+
* ```tsx
|
|
106
|
+
* <ActivityFeed
|
|
107
|
+
* items={[
|
|
108
|
+
* {
|
|
109
|
+
* id: '1',
|
|
110
|
+
* actor: 'Ada Lovelace',
|
|
111
|
+
* action: <>commented on <strong>Invoice #42</strong></>,
|
|
112
|
+
* timestamp: '2026-07-21T09:15:00Z',
|
|
113
|
+
* avatar: ada.avatarUrl,
|
|
114
|
+
* },
|
|
115
|
+
* {
|
|
116
|
+
* id: '2',
|
|
117
|
+
* actor: 'System',
|
|
118
|
+
* action: 'archived the project',
|
|
119
|
+
* timestamp: '2026-07-20T18:02:00Z',
|
|
120
|
+
* },
|
|
121
|
+
* ]}
|
|
122
|
+
* />
|
|
123
|
+
* ```
|
|
124
|
+
*/
|
|
125
|
+
export function ActivityFeed(props: ActivityFeedProps): JSX.Element {
|
|
126
|
+
const groups = () => groupByDay(props.items)
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<div class={cn('flex flex-col gap-6', props.class)}>
|
|
130
|
+
<For each={groups()}>
|
|
131
|
+
{(group) => (
|
|
132
|
+
<div>
|
|
133
|
+
<p class="mb-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
134
|
+
{group.label}
|
|
135
|
+
</p>
|
|
136
|
+
<ol class="relative flex flex-col gap-5">
|
|
137
|
+
<For each={group.items}>
|
|
138
|
+
{(item, index) => (
|
|
139
|
+
<li class="relative flex gap-3 pl-1">
|
|
140
|
+
<Show when={index() < group.items.length - 1}>
|
|
141
|
+
<span
|
|
142
|
+
aria-hidden="true"
|
|
143
|
+
class="absolute left-[1.375rem] top-9 -bottom-5 w-px -translate-x-1/2 bg-border"
|
|
144
|
+
/>
|
|
145
|
+
</Show>
|
|
146
|
+
<Show
|
|
147
|
+
when={item.avatar}
|
|
148
|
+
fallback={
|
|
149
|
+
<span class="relative z-[1] grid h-9 w-9 shrink-0 place-items-center rounded-full bg-muted text-muted-foreground">
|
|
150
|
+
{item.icon ?? <Activity class="h-4 w-4" />}
|
|
151
|
+
</span>
|
|
152
|
+
}
|
|
153
|
+
>
|
|
154
|
+
<span class="relative z-[1] shrink-0">
|
|
155
|
+
<Avatar src={item.avatar} alt={item.actor} fallback={initials(item.actor)} />
|
|
156
|
+
</span>
|
|
157
|
+
</Show>
|
|
158
|
+
<div class="min-w-0 flex-1 pt-1.5">
|
|
159
|
+
<p class="text-sm text-foreground">
|
|
160
|
+
<span class="font-medium">{item.actor}</span> {item.action}
|
|
161
|
+
</p>
|
|
162
|
+
<p
|
|
163
|
+
class="mt-0.5 text-xs text-muted-foreground"
|
|
164
|
+
title={new Date(item.timestamp).toLocaleString()}
|
|
165
|
+
>
|
|
166
|
+
{relativeTime(item.timestamp)}
|
|
167
|
+
</p>
|
|
168
|
+
</div>
|
|
169
|
+
</li>
|
|
170
|
+
)}
|
|
171
|
+
</For>
|
|
172
|
+
</ol>
|
|
173
|
+
</div>
|
|
174
|
+
)}
|
|
175
|
+
</For>
|
|
176
|
+
</div>
|
|
177
|
+
)
|
|
178
|
+
}
|