@startsimpli/ui 0.4.56 → 0.4.57

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/ui",
3
- "version": "0.4.56",
3
+ "version": "0.4.57",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -0,0 +1,156 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { cn } from '../../lib/utils'
5
+
6
+ const ENTITIES: Record<string, string> = {
7
+ '&amp;': '&',
8
+ '&lt;': '<',
9
+ '&gt;': '>',
10
+ '&quot;': '"',
11
+ '&#39;': "'",
12
+ '&nbsp;': ' ',
13
+ }
14
+
15
+ /**
16
+ * Flatten markdown / HTML / whitespace-noisy text into a single-line preview,
17
+ * clamped to `maxChars` on a word boundary. Board cards get a readable content
18
+ * snippet without pulling in a full markdown renderer (startsim-768w.17.3): the
19
+ * pipeline writes long article bodies to `data.content`, and a raw slice of that
20
+ * shows heading hashes, link syntax and code fences.
21
+ */
22
+ export function toPlainSnippet(
23
+ input: string | null | undefined,
24
+ maxChars = 240,
25
+ ): string {
26
+ if (!input) return ''
27
+ let s = String(input)
28
+ s = s.replace(/```[\s\S]*?```/g, ' ') // fenced code blocks
29
+ s = s.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1') // images -> alt text
30
+ s = s.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1') // links -> link text
31
+ s = s.replace(/<[^>]+>/g, ' ') // html tags
32
+ s = s.replace(/^[ \t]*[#>\-*+]+[ \t]+/gm, ' ') // heading / list / quote markers
33
+ s = s.replace(/[*_`~]+/g, '') // inline emphasis / code marks
34
+ s = s.replace(/&[a-z#0-9]+;/gi, (m) => ENTITIES[m.toLowerCase()] ?? ' ')
35
+ s = s.replace(/\s+/g, ' ').trim()
36
+ if (s.length <= maxChars) return s
37
+ const cut = s.slice(0, maxChars)
38
+ const lastSpace = cut.lastIndexOf(' ')
39
+ // cut back to the last whole word when that boundary is in the back half,
40
+ // otherwise hard-cut (e.g. a single very long token has no usable boundary)
41
+ const body = lastSpace > maxChars * 0.5 ? cut.slice(0, lastSpace) : cut
42
+ return body.trimEnd() + '…'
43
+ }
44
+
45
+ const LINE_CLAMP: Record<number, string> = {
46
+ 2: 'line-clamp-2',
47
+ 3: 'line-clamp-3',
48
+ 4: 'line-clamp-4',
49
+ 5: 'line-clamp-5',
50
+ 6: 'line-clamp-6',
51
+ }
52
+
53
+ export interface BoardCardMeta {
54
+ label: string
55
+ value: React.ReactNode
56
+ }
57
+
58
+ export interface BoardCardProps {
59
+ /** Card heading (record name / title). */
60
+ title: React.ReactNode
61
+ /** Raw content (markdown / HTML / plain); shown as a clamped plain-text preview. */
62
+ snippet?: string | null
63
+ /** Max plain-text characters for the snippet (default 240). */
64
+ snippetMaxChars?: number
65
+ /** Tailwind line-clamp count for the snippet (default 3). */
66
+ snippetLines?: 2 | 3 | 4 | 5 | 6
67
+ /** Source / host label or link, shown under the snippet. */
68
+ source?: React.ReactNode
69
+ /** Compact key/value chips for short declared attributes. */
70
+ meta?: BoardCardMeta[]
71
+ /** Opens the record detail; wraps the title as a button when provided. */
72
+ onOpen?: () => void
73
+ /**
74
+ * Footer slot — e.g. a status `<Select>`. Pointer/click events inside are
75
+ * stopped so interacting with a control never starts a drag on the card.
76
+ */
77
+ footer?: React.ReactNode
78
+ className?: string
79
+ }
80
+
81
+ /**
82
+ * A content-aware board card: title + readable content snippet + source + a few
83
+ * meta chips + a footer slot. Presentation-only and drag-agnostic — the
84
+ * KanbanBoard wraps it in a Draggable; this just renders the content the epic's
85
+ * News Item board was missing (title-only cards). Reusable for any entity board.
86
+ */
87
+ export function BoardCard({
88
+ title,
89
+ snippet,
90
+ snippetMaxChars = 240,
91
+ snippetLines = 3,
92
+ source,
93
+ meta,
94
+ onOpen,
95
+ footer,
96
+ className,
97
+ }: BoardCardProps) {
98
+ const preview = toPlainSnippet(snippet, snippetMaxChars)
99
+ const hasSource = source != null && source !== ''
100
+ return (
101
+ <div
102
+ className={cn(
103
+ 'm-2 rounded-md border bg-white p-3 shadow-sm dark:bg-neutral-900',
104
+ className,
105
+ )}
106
+ >
107
+ {onOpen ? (
108
+ <button
109
+ type="button"
110
+ onClick={onOpen}
111
+ className="block w-full text-left text-sm font-medium leading-snug hover:underline"
112
+ >
113
+ {title}
114
+ </button>
115
+ ) : (
116
+ <div className="text-sm font-medium leading-snug">{title}</div>
117
+ )}
118
+
119
+ {preview && (
120
+ <p
121
+ className={cn(
122
+ 'mt-1 text-xs leading-snug text-neutral-600 dark:text-neutral-300',
123
+ LINE_CLAMP[snippetLines],
124
+ )}
125
+ >
126
+ {preview}
127
+ </p>
128
+ )}
129
+
130
+ {hasSource && (
131
+ <div className="mt-1.5 truncate text-xs text-neutral-400">{source}</div>
132
+ )}
133
+
134
+ {meta && meta.length > 0 && (
135
+ <dl className="mt-1 space-y-0.5">
136
+ {meta.map((m, i) => (
137
+ <div key={i} className="flex gap-1 text-xs text-neutral-600 dark:text-neutral-300">
138
+ <dt className="capitalize text-neutral-400">{m.label}:</dt>
139
+ <dd className="truncate">{m.value}</dd>
140
+ </div>
141
+ ))}
142
+ </dl>
143
+ )}
144
+
145
+ {footer != null && (
146
+ <div
147
+ className="mt-2"
148
+ onClick={(e) => e.stopPropagation()}
149
+ onPointerDown={(e) => e.stopPropagation()}
150
+ >
151
+ {footer}
152
+ </div>
153
+ )}
154
+ </div>
155
+ )
156
+ }
@@ -0,0 +1,77 @@
1
+ import { render, screen, fireEvent } from '@testing-library/react'
2
+ import { BoardCard, toPlainSnippet } from '../BoardCard'
3
+
4
+ describe('toPlainSnippet', () => {
5
+ it('returns empty string for nullish/empty input', () => {
6
+ expect(toPlainSnippet(null)).toBe('')
7
+ expect(toPlainSnippet(undefined)).toBe('')
8
+ expect(toPlainSnippet('')).toBe('')
9
+ })
10
+
11
+ it('strips markdown headings, emphasis, links and code fences', () => {
12
+ const md = '# Title\n\nSome **bold** and _italic_ with a [link](https://x.io).\n\n```js\ncode()\n```'
13
+ const out = toPlainSnippet(md)
14
+ expect(out).toBe('Title Some bold and italic with a link.')
15
+ expect(out).not.toMatch(/[#*_`]|\]\(|```/)
16
+ })
17
+
18
+ it('strips html tags and decodes common entities', () => {
19
+ expect(toPlainSnippet('<p>Gold &amp; silver &lt;up&gt;</p>')).toBe('Gold & silver <up>')
20
+ })
21
+
22
+ it('collapses whitespace to single spaces', () => {
23
+ expect(toPlainSnippet('a\n\n b\t c')).toBe('a b c')
24
+ })
25
+
26
+ it('truncates on a word boundary with an ellipsis when over the limit', () => {
27
+ // cut(12) = "one two thre"; last boundary at 7 -> back to the whole word
28
+ expect(toPlainSnippet('one two three four five six', 12)).toBe('one two…')
29
+ })
30
+
31
+ it('hard-cuts a single long token with no usable word boundary', () => {
32
+ expect(toPlainSnippet('supercalifragilisticexpialidocious', 10)).toBe('supercalif…')
33
+ })
34
+
35
+ it('does not truncate when within the limit', () => {
36
+ expect(toPlainSnippet('short', 240)).toBe('short')
37
+ })
38
+ })
39
+
40
+ describe('BoardCard', () => {
41
+ it('renders the title and a plain-text content snippet (markdown stripped)', () => {
42
+ render(<BoardCard title="Gold hits record" snippet={'## Lead\n\nPrices **surged** today.'} />)
43
+ expect(screen.getByText('Gold hits record')).toBeInTheDocument()
44
+ expect(screen.getByText('Lead Prices surged today.')).toBeInTheDocument()
45
+ })
46
+
47
+ it('renders source and meta chips', () => {
48
+ render(
49
+ <BoardCard
50
+ title="T"
51
+ source="reuters.com"
52
+ meta={[{ label: 'market', value: 'UAE' }]}
53
+ />,
54
+ )
55
+ expect(screen.getByText('reuters.com')).toBeInTheDocument()
56
+ expect(screen.getByText('market:')).toBeInTheDocument()
57
+ expect(screen.getByText('UAE')).toBeInTheDocument()
58
+ })
59
+
60
+ it('calls onOpen when the title is clicked', () => {
61
+ const onOpen = jest.fn()
62
+ render(<BoardCard title="Open me" onOpen={onOpen} />)
63
+ fireEvent.click(screen.getByRole('button', { name: 'Open me' }))
64
+ expect(onOpen).toHaveBeenCalledTimes(1)
65
+ })
66
+
67
+ it('stops footer clicks from bubbling (so a control never starts a card drag)', () => {
68
+ const outer = jest.fn()
69
+ render(
70
+ <div onClick={outer}>
71
+ <BoardCard title="T" footer={<button type="button">status</button>} />
72
+ </div>,
73
+ )
74
+ fireEvent.click(screen.getByRole('button', { name: 'status' }))
75
+ expect(outer).not.toHaveBeenCalled()
76
+ })
77
+ })
@@ -1,2 +1,4 @@
1
1
  export { KanbanBoard, resolveKanbanMove } from './KanbanBoard'
2
2
  export type { KanbanBoardProps, KanbanColumnConfig, KanbanMove } from './KanbanBoard'
3
+ export { BoardCard, toPlainSnippet } from './BoardCard'
4
+ export type { BoardCardProps, BoardCardMeta } from './BoardCard'