@startsimpli/ui 0.4.92 → 0.4.94

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.
Files changed (25) hide show
  1. package/package.json +3 -3
  2. package/src/components/dashboard/DashboardSection.tsx +36 -5
  3. package/src/components/dashboard/MetricCard.tsx +4 -1
  4. package/src/components/dashboard/StatTile.tsx +20 -1
  5. package/src/components/states/Absence.tsx +156 -0
  6. package/src/components/states/Caveat.tsx +167 -0
  7. package/src/components/states/EmptyState.tsx +39 -10
  8. package/src/components/states/Footnote.tsx +171 -0
  9. package/src/components/states/__tests__/caveat-and-absence.test.tsx +662 -0
  10. package/src/components/states/index.ts +7 -1
  11. package/src/components/ui/card.tsx +37 -6
  12. package/src/components/unified-table/__tests__/components/TablePagination.test.tsx +62 -5
  13. package/src/components/unified-table/__tests__/hooks/usePagination.test.ts +22 -0
  14. package/src/components/unified-table/__tests__/utils/border-tokens.test.ts +61 -0
  15. package/src/components/unified-table/components/BulkActionBar/index.tsx +1 -1
  16. package/src/components/unified-table/components/DataTableCore/index.tsx +1 -1
  17. package/src/components/unified-table/components/MobileView/Card.tsx +1 -1
  18. package/src/components/unified-table/components/TableFilters/index.tsx +2 -2
  19. package/src/components/unified-table/components/TablePagination/index.tsx +19 -5
  20. package/src/components/unified-table/hooks/usePagination.ts +12 -0
  21. package/src/components/unified-table/utils/themes.ts +8 -5
  22. package/src/theme/__tests__/type-scale.test.tsx +145 -0
  23. package/src/theme/contract.ts +5 -0
  24. package/src/theme/index.ts +10 -0
  25. package/src/theme/type-scale.ts +119 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/ui",
3
- "version": "0.4.92",
3
+ "version": "0.4.94",
4
4
  "description": "Shared UI components package for StartSimpli applications",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -120,8 +120,8 @@
120
120
  "tailwindcss": "^3.4.19",
121
121
  "tsup": "^8.5.1",
122
122
  "typescript": "^6.0.3",
123
- "@startsimpli/i18n": "0.1.1",
124
- "@startsimpli/realtime": "0.0.2"
123
+ "@startsimpli/realtime": "0.0.2",
124
+ "@startsimpli/i18n": "0.1.1"
125
125
  },
126
126
  "keywords": [
127
127
  "ui",
@@ -1,32 +1,63 @@
1
1
  import * as React from 'react'
2
+ import { cn } from '../../lib/utils'
3
+ import { TYPE_SCALE } from '../../theme/type-scale'
4
+ import { Caveat } from '../states/Caveat'
5
+ import { FootnoteScope } from '../states/Footnote'
2
6
 
3
7
  export interface DashboardSectionProps {
4
8
  title: string
5
9
  description?: string
6
10
  action?: React.ReactNode
11
+ /**
12
+ * A qualification of everything in this section. Collapses to a marker beside
13
+ * the heading and expands on demand.
14
+ */
15
+ caveat?: React.ReactNode
16
+ /**
17
+ * Collect the notes the section's items claim and print each distinct one
18
+ * once, underneath them. On by default — a caveat authored per item is a
19
+ * caveat said per section, not per item. Pass `false` to opt a section out.
20
+ */
21
+ footnotes?: boolean
7
22
  children: React.ReactNode
8
23
  className?: string
9
24
  }
10
25
 
26
+ /**
27
+ * A titled band of a dashboard, and the scope its items' footnotes collect into.
28
+ *
29
+ * The scope is the whole point of the default: an item can author the caveat it
30
+ * knows about without anyone counting how many other items say the same thing.
31
+ */
11
32
  export function DashboardSection({
12
33
  title,
13
34
  description,
14
35
  action,
36
+ caveat,
37
+ footnotes = true,
15
38
  children,
16
39
  className = '',
17
40
  }: DashboardSectionProps) {
18
41
  return (
19
- <div className={`space-y-4 ${className}`}>
20
- <div className="flex items-start justify-between">
42
+ <div className={cn('space-y-4', className)}>
43
+ <div className="flex items-start justify-between gap-4">
21
44
  <div>
22
- <h2 className="text-lg font-semibold text-gray-900">{title}</h2>
45
+ {/* `heading` is the section step of the type scale; the colour comes
46
+ from the theme, so this reads on a dark tenant as well as a light
47
+ one. `text-gray-900`/`text-gray-600` used to hard-code light. */}
48
+ <h2 className={cn(TYPE_SCALE.heading, 'text-foreground')}>
49
+ {title}
50
+ {caveat ? <Caveat subject={title}>{caveat}</Caveat> : null}
51
+ </h2>
23
52
  {description && (
24
- <p className="text-sm text-gray-600 mt-1">{description}</p>
53
+ <p className={cn(TYPE_SCALE.muted, 'mt-1 text-muted-foreground')}>
54
+ {description}
55
+ </p>
25
56
  )}
26
57
  </div>
27
58
  {action && <div className="flex-shrink-0">{action}</div>}
28
59
  </div>
29
- {children}
60
+ {footnotes ? <FootnoteScope>{children}</FootnoteScope> : children}
30
61
  </div>
31
62
  )
32
63
  }
@@ -36,7 +36,10 @@ export function MetricCard({
36
36
  const content = (
37
37
  <div
38
38
  className={cn(
39
- 'rounded-xl border bg-card text-card-foreground shadow p-6 transition-all duration-200 hover:shadow-lg cursor-pointer',
39
+ // `border` on its own resolves to currentColor in Tailwind v4 — on a
40
+ // card that sets text-card-foreground that draws a near-black outline
41
+ // in light mode and a glaring pale one on a dark tenant. Name the token.
42
+ 'rounded-xl border border-border bg-card text-card-foreground shadow p-6 transition-all duration-200 hover:shadow-lg cursor-pointer',
40
43
  error && 'border-red-200 bg-red-50',
41
44
  isLoading && 'animate-pulse',
42
45
  className,
@@ -1,5 +1,7 @@
1
1
  import * as React from 'react'
2
2
  import { cn } from '../../lib/utils'
3
+ import { Caveat } from '../states/Caveat'
4
+ import { Footnote } from '../states/Footnote'
3
5
  import { AreaTrend } from './AreaTrend'
4
6
  import { DeltaPill, type DeltaPillProps } from './DeltaPill'
5
7
 
@@ -20,6 +22,17 @@ export interface StatTileProps {
20
22
  sparklineColor?: string
21
23
  /** Numeral scale — `lg` (text-4xl) by default, `xl` for a hero figure. */
22
24
  size?: StatTileSize
25
+ /**
26
+ * A qualification of this figure. Collapses to a marker beside the kicker and
27
+ * expands on demand, so it never competes with the number for weight.
28
+ */
29
+ caveat?: React.ReactNode
30
+ /**
31
+ * A caveat that belongs to the whole section rather than this tile. Inside a
32
+ * `FootnoteScope` it is printed once under the section, however many tiles
33
+ * carry it.
34
+ */
35
+ note?: string
23
36
  /** Extra content below the delta. */
24
37
  children?: React.ReactNode
25
38
  className?: string
@@ -45,6 +58,8 @@ export function StatTile({
45
58
  sparkline,
46
59
  sparklineColor,
47
60
  size = 'lg',
61
+ caveat,
62
+ note,
48
63
  children,
49
64
  className,
50
65
  }: StatTileProps) {
@@ -57,8 +72,12 @@ export function StatTile({
57
72
  className,
58
73
  )}
59
74
  >
60
- <p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
75
+ {/* The kicker is the canonical home for a caveat: beside the thing it
76
+ qualifies, at the weight of a label rather than a number. */}
77
+ <p className="text-start text-xs font-medium uppercase tracking-wide text-muted-foreground">
61
78
  {label}
79
+ {caveat ? <Caveat subject={label}>{caveat}</Caveat> : null}
80
+ {note ? <Footnote note={note} subject={label} /> : null}
62
81
  </p>
63
82
 
64
83
  <div className="mt-2 flex items-center justify-between gap-4">
@@ -0,0 +1,156 @@
1
+ import * as React from 'react'
2
+ import { cn } from '../../lib/utils'
3
+ import { Card, CardContent } from '../ui/card'
4
+ import { Caveat } from './Caveat'
5
+ import { Footnote } from './Footnote'
6
+ import { EmptyState, StateAction, type EmptyStateAction } from './EmptyState'
7
+
8
+ /**
9
+ * How much is missing, and therefore how much room saying so is worth.
10
+ *
11
+ * - `value` — one figure inside a tile that is otherwise alive. An em dash.
12
+ * - `note` — one line, no chrome, under or beside data that is present.
13
+ * - `card` — a card slot with nothing behind it at all.
14
+ * - `page` — the whole surface has nothing to show.
15
+ *
16
+ * The ladder exists because a missing tile and a missing screen are not the
17
+ * same event, and rendering them identically makes a dashboard read as a
18
+ * disclaimer document.
19
+ */
20
+ export type AbsenceTier = 'value' | 'note' | 'card' | 'page'
21
+
22
+ /** What stands in for a figure nobody reported. Never a 0, never a blank. */
23
+ export const ABSENCE_DASH = '—'
24
+
25
+ export interface AbsenceProps {
26
+ tier: AbsenceTier
27
+ /**
28
+ * What is missing, in a phrase. On the `value` tier it is not drawn — it is
29
+ * the screen-reader text behind the dash, so the absence is never silent.
30
+ */
31
+ title: string
32
+ /** One line of what. Never a number and never a zero — those mean something else. */
33
+ description?: string
34
+ /**
35
+ * Why it is missing, where that is known ("Anghami has not reported this").
36
+ * Carried by a disclosure, so the reason is available without being shouted.
37
+ */
38
+ why?: React.ReactNode
39
+ /**
40
+ * A sentence that belongs to the whole screen rather than this card. Inside a
41
+ * `FootnoteScope` it is printed once, however many absences claim it.
42
+ */
43
+ note?: string
44
+ /** `page` tier: drawn above the heading. */
45
+ icon?: React.ReactNode
46
+ /** What the reader can do about it, where there is something. */
47
+ action?: EmptyStateAction
48
+ /** `note` tier: a dashed inset instead of a bare line, for a gap inside a card. */
49
+ inset?: boolean
50
+ className?: string
51
+ }
52
+
53
+ /**
54
+ * One vocabulary for "we do not have this", graded by how much is missing.
55
+ *
56
+ * Every tier says WHAT is absent and, where it is known, WHY — and none of them
57
+ * renders a 0, a blank cell or a spinner that never ends, because a number
58
+ * nobody reported and a number that is zero are different statements.
59
+ */
60
+ export function Absence({
61
+ tier,
62
+ title,
63
+ description,
64
+ why,
65
+ note,
66
+ icon,
67
+ action,
68
+ inset,
69
+ className,
70
+ }: AbsenceProps) {
71
+ const caveat = why ? (
72
+ <Caveat subject={title} placement={tier === 'value' ? 'overlay' : 'inline'}>
73
+ {why}
74
+ </Caveat>
75
+ ) : null
76
+ const footnote = note ? <Footnote note={note} subject={title} /> : null
77
+
78
+ if (tier === 'value') {
79
+ return (
80
+ <span
81
+ data-absence="value"
82
+ className={cn('inline-flex items-baseline text-muted-foreground', className)}
83
+ >
84
+ <span aria-hidden="true" className="tabular-nums">
85
+ {ABSENCE_DASH}
86
+ </span>
87
+ <span className="sr-only">
88
+ {description ? `${title}. ${description}` : title}
89
+ </span>
90
+ {caveat}
91
+ {footnote}
92
+ </span>
93
+ )
94
+ }
95
+
96
+ if (tier === 'note') {
97
+ return (
98
+ <div
99
+ role="note"
100
+ data-absence="note"
101
+ className={cn(
102
+ 'max-w-prose text-start text-sm leading-relaxed text-muted-foreground',
103
+ inset && 'rounded-lg border border-dashed border-border p-4',
104
+ className,
105
+ )}
106
+ >
107
+ <span className={cn('font-medium text-foreground/80', inset && 'block')}>
108
+ {title}
109
+ </span>
110
+ {description ? (
111
+ <span className={cn(inset ? 'mt-1 block' : 'ms-1')}>{description}</span>
112
+ ) : null}
113
+ {caveat}
114
+ {footnote}
115
+ </div>
116
+ )
117
+ }
118
+
119
+ if (tier === 'card') {
120
+ return (
121
+ <Card data-absence="card" className={cn('text-start', className)}>
122
+ <CardContent className="space-y-1.5 p-5">
123
+ <p className="text-sm font-semibold text-foreground">
124
+ {title}
125
+ {footnote}
126
+ </p>
127
+ {description && (
128
+ <p className="max-w-prose text-sm leading-relaxed text-muted-foreground">
129
+ {description}
130
+ </p>
131
+ )}
132
+ {caveat}
133
+ {action && <StateAction action={action} className="mt-2" />}
134
+ </CardContent>
135
+ </Card>
136
+ )
137
+ }
138
+
139
+ return (
140
+ <EmptyState
141
+ title={title}
142
+ description={description}
143
+ action={action}
144
+ icon={icon}
145
+ className={cn('text-center', className)}
146
+ data-absence="page"
147
+ >
148
+ {(caveat || footnote) && (
149
+ <span className="mt-4 inline-flex items-center text-xs text-muted-foreground">
150
+ {caveat}
151
+ {footnote}
152
+ </span>
153
+ )}
154
+ </EmptyState>
155
+ )
156
+ }
@@ -0,0 +1,167 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { cn } from '../../lib/utils'
5
+
6
+ export type CaveatPlacement = 'overlay' | 'inline'
7
+
8
+ export interface CaveatProps {
9
+ /** The qualification itself. It stays out of the layout until it is asked for. */
10
+ children: React.ReactNode
11
+ /** Trigger wording. Two words at most — it sits beside a number, not above it. */
12
+ label?: string
13
+ /** What the caveat qualifies. Folded into the trigger's accessible name. */
14
+ subject?: string
15
+ /** `marker` (default) draws a small round glyph; `text` draws the label itself. */
16
+ trigger?: 'marker' | 'text'
17
+ /**
18
+ * `overlay` (default) floats the note over the layout, so a caveat can sit in
19
+ * a tight row without pushing the row apart. `inline` opens it in flow, for
20
+ * surfaces that already have the room.
21
+ */
22
+ placement?: CaveatPlacement
23
+ /** Open on first paint. Ignored when `open` is supplied. */
24
+ defaultOpen?: boolean
25
+ /** Controlled open state. */
26
+ open?: boolean
27
+ onOpenChange?: (open: boolean) => void
28
+ className?: string
29
+ panelClassName?: string
30
+ }
31
+
32
+ /**
33
+ * A caveat that collapses to a marker and expands on demand.
34
+ *
35
+ * The affordance exists so a qualification ("these are estimates", "settlement
36
+ * lags about a month") can live next to the figure it qualifies without being
37
+ * set at the same weight as the figure. It is a disclosure widget, not a
38
+ * tooltip: the trigger is a real button carrying `aria-expanded` and
39
+ * `aria-controls`, it is reachable and operable from the keyboard, and the note
40
+ * is never hover-only.
41
+ *
42
+ * The panel is always in the DOM and hidden when closed, so `aria-controls`
43
+ * always resolves; `aria-describedby` is attached only while it is open, so a
44
+ * screen reader is never pointed at hidden text.
45
+ */
46
+ export function Caveat({
47
+ children,
48
+ label = 'Why?',
49
+ subject,
50
+ trigger = 'marker',
51
+ placement = 'overlay',
52
+ defaultOpen = false,
53
+ open,
54
+ onOpenChange,
55
+ className,
56
+ panelClassName,
57
+ }: CaveatProps) {
58
+ const generatedId = React.useId().replace(/:/g, '')
59
+ const panelId = `caveat-${generatedId}`
60
+ const rootRef = React.useRef<HTMLSpanElement>(null)
61
+ const panelRef = React.useRef<HTMLSpanElement>(null)
62
+ const [flipped, setFlipped] = React.useState(false)
63
+
64
+ const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen)
65
+ const isControlled = open !== undefined
66
+ const isOpen = isControlled ? open : uncontrolledOpen
67
+
68
+ const setOpen = React.useCallback(
69
+ (next: boolean) => {
70
+ if (!isControlled) setUncontrolledOpen(next)
71
+ onOpenChange?.(next)
72
+ },
73
+ [isControlled, onOpenChange],
74
+ )
75
+
76
+ // A caveat on the last tile of a row would otherwise open off the edge of the
77
+ // screen. Anchor it to the opposite logical edge instead — measured, so it
78
+ // works the same whichever way the document reads.
79
+ React.useLayoutEffect(() => {
80
+ if (!isOpen || placement !== 'overlay') {
81
+ setFlipped(false)
82
+ return
83
+ }
84
+ const panel = panelRef.current
85
+ if (!panel) return
86
+ const rect = panel.getBoundingClientRect()
87
+ if (rect.width === 0) return
88
+ const viewport = window.innerWidth
89
+ if (rect.right > viewport - 8 || rect.left < 8) setFlipped(true)
90
+ }, [isOpen, placement])
91
+
92
+ // An overlay sits on top of the layout, so a click elsewhere should put it
93
+ // away. An inline note is part of the page and stays until it is dismissed.
94
+ React.useEffect(() => {
95
+ if (!isOpen || placement !== 'overlay') return
96
+ function onPointerDown(event: MouseEvent) {
97
+ const root = rootRef.current
98
+ if (root && !root.contains(event.target as Node)) setOpen(false)
99
+ }
100
+ document.addEventListener('mousedown', onPointerDown)
101
+ return () => document.removeEventListener('mousedown', onPointerDown)
102
+ }, [isOpen, placement, setOpen])
103
+
104
+ const accessibleName = subject ? `${label} ${subject}` : label
105
+
106
+ return (
107
+ <span
108
+ ref={rootRef}
109
+ className={cn(
110
+ 'text-start',
111
+ placement === 'overlay' ? 'relative inline-block' : 'inline',
112
+ className,
113
+ )}
114
+ onKeyDown={(event) => {
115
+ if (event.key === 'Escape' && isOpen) {
116
+ event.stopPropagation()
117
+ setOpen(false)
118
+ }
119
+ }}
120
+ >
121
+ <button
122
+ type="button"
123
+ aria-expanded={isOpen}
124
+ aria-controls={panelId}
125
+ aria-describedby={isOpen ? panelId : undefined}
126
+ data-state={isOpen ? 'open' : 'closed'}
127
+ onClick={() => setOpen(!isOpen)}
128
+ className={cn(
129
+ 'ms-1 inline-flex shrink-0 items-center justify-center align-middle text-muted-foreground transition-colors',
130
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1',
131
+ trigger === 'marker'
132
+ ? 'h-4 w-4 rounded-full border border-border text-[10px] font-semibold leading-none hover:bg-accent hover:text-accent-foreground'
133
+ : 'gap-1 rounded text-xs font-medium underline underline-offset-2 hover:text-foreground',
134
+ )}
135
+ >
136
+ {trigger === 'marker' ? (
137
+ <>
138
+ <span aria-hidden="true">?</span>
139
+ <span className="sr-only">{accessibleName}</span>
140
+ </>
141
+ ) : (
142
+ <>
143
+ <span>{label}</span>
144
+ {subject ? <span className="sr-only">{subject}</span> : null}
145
+ </>
146
+ )}
147
+ </button>
148
+
149
+ <span
150
+ ref={panelRef}
151
+ id={panelId}
152
+ role="note"
153
+ hidden={!isOpen}
154
+ className={cn(
155
+ 'rounded-md border border-border bg-popover p-2 text-start text-xs font-normal normal-case leading-relaxed tracking-normal text-popover-foreground shadow-sm',
156
+ placement === 'overlay' && 'absolute top-full z-20 mt-1 w-64 max-w-[min(16rem,80vw)]',
157
+ placement === 'overlay' && (flipped ? 'end-0' : 'start-0'),
158
+ placement === 'inline' && 'mt-2 max-w-prose',
159
+ isOpen ? 'block' : 'hidden',
160
+ panelClassName,
161
+ )}
162
+ >
163
+ {children}
164
+ </span>
165
+ </span>
166
+ )
167
+ }
@@ -6,18 +6,48 @@ export interface EmptyStateAction {
6
6
  onClick: () => void
7
7
  }
8
8
 
9
- export interface EmptyStateProps {
9
+ export interface EmptyStateProps
10
+ extends Omit<React.HTMLAttributes<HTMLDivElement>, 'title' | 'action'> {
10
11
  title: string
11
12
  description?: string
12
13
  action?: EmptyStateAction
14
+ /** Drawn above the heading. Decorative — the heading carries the meaning. */
15
+ icon?: React.ReactNode
16
+ /** Anything that belongs under the action, e.g. a caveat or a footnote marker. */
17
+ children?: React.ReactNode
13
18
  className?: string
14
19
  }
15
20
 
21
+ /** The one button shape every state surface uses, so none of them redraws it. */
22
+ export function StateAction({
23
+ action,
24
+ className,
25
+ }: {
26
+ action: EmptyStateAction
27
+ className?: string
28
+ }) {
29
+ return (
30
+ <button
31
+ onClick={action.onClick}
32
+ className={cn(
33
+ 'inline-flex items-center justify-center rounded-md text-sm font-medium bg-primary text-primary-foreground shadow hover:bg-primary/90 h-9 px-4 py-2 transition-colors',
34
+ className
35
+ )}
36
+ aria-label={action.label}
37
+ >
38
+ {action.label}
39
+ </button>
40
+ )
41
+ }
42
+
16
43
  export function EmptyState({
17
44
  title,
18
45
  description,
19
46
  action,
47
+ icon,
48
+ children,
20
49
  className,
50
+ ...rest
21
51
  }: EmptyStateProps) {
22
52
  return (
23
53
  <div
@@ -27,22 +57,21 @@ export function EmptyState({
27
57
  )}
28
58
  role="status"
29
59
  aria-live="polite"
60
+ {...rest}
30
61
  >
62
+ {icon && (
63
+ <div className="mb-4 text-muted-foreground" aria-hidden="true">
64
+ {icon}
65
+ </div>
66
+ )}
31
67
  <h3 className="text-xl font-semibold text-foreground mb-3">{title}</h3>
32
68
  {description && (
33
69
  <p className="text-base text-muted-foreground mb-8 max-w-md leading-relaxed">
34
70
  {description}
35
71
  </p>
36
72
  )}
37
- {action && (
38
- <button
39
- onClick={action.onClick}
40
- className="inline-flex items-center justify-center rounded-md text-sm font-medium bg-primary text-primary-foreground shadow hover:bg-primary/90 h-9 px-4 py-2 transition-colors"
41
- aria-label={action.label}
42
- >
43
- {action.label}
44
- </button>
45
- )}
73
+ {action && <StateAction action={action} />}
74
+ {children}
46
75
  </div>
47
76
  )
48
77
  }