@olwiba/ui 0.2.24 → 0.2.25

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": "@olwiba/ui",
3
- "version": "0.2.24",
3
+ "version": "0.2.25",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -41,7 +41,6 @@
41
41
  "@dnd-kit/modifiers": "^9.0.0",
42
42
  "@dnd-kit/sortable": "^10.0.0",
43
43
  "@dnd-kit/utilities": "^3.2.2",
44
- "@olwiba/dx": "0.0.23",
45
44
  "@tanstack/react-table": "^8.21.3",
46
45
  "clsx": "^2.1.1",
47
46
  "lucide-react": "^0.562.0",
@@ -65,6 +64,7 @@
65
64
  "@content-collections/mdx": "^0.2.2",
66
65
  "@olwiba/cn": "0.1.35",
67
66
  "@olwiba/docs": "0.1.40",
67
+ "@olwiba/dx": "0.0.23",
68
68
  "@tailwindcss/vite": "^4.1.18",
69
69
  "@tanstack/react-router": "1.154.8",
70
70
  "@tanstack/react-router-devtools": "1.154.8",
@@ -42,6 +42,27 @@ export interface ChartProps {
42
42
  grid?: boolean;
43
43
  /** Legend. @default true for multiple series or donut, false for one series */
44
44
  legend?: boolean;
45
+ /**
46
+ * Stack series instead of overlaying them. Area and bar only — a stacked
47
+ * line is a shape people misread as absolute values.
48
+ *
49
+ * Off by default: stacking answers "what does the total split into", and
50
+ * silently switching a two-series comparison to it would change what an
51
+ * existing chart claims.
52
+ */
53
+ stacked?: boolean;
54
+ /**
55
+ * Formats x-axis ticks. Separate from `valueFormatter`, which formats the
56
+ * measured value — an axis of ISO dates wants shortening without changing
57
+ * what the tooltip reports.
58
+ */
59
+ xTickFormatter?: (value: string) => string;
60
+ /**
61
+ * Fixes the y-axis range. Without it recharts scales to the data, which for
62
+ * a percentage series makes a 98-100%% band fill the plot and read as
63
+ * volatility.
64
+ */
65
+ yDomain?: [number, number];
45
66
  /** Formats values in tooltips and the y-axis, e.g. `(v) => \`$${v}\``. */
46
67
  valueFormatter?: (value: number) => string;
47
68
  className?: string;
@@ -121,6 +142,9 @@ export function Chart({
121
142
  height = 300,
122
143
  grid = true,
123
144
  legend,
145
+ stacked = false,
146
+ xTickFormatter,
147
+ yDomain,
124
148
  valueFormatter = (v) => String(v),
125
149
  className,
126
150
  }: ChartProps) {
@@ -170,11 +194,12 @@ export function Chart({
170
194
  chart = (
171
195
  <BarChart data={data} barCategoryGap="25%">
172
196
  {gridEl}
173
- <XAxis dataKey={xKey} {...axisProps} />
174
- <YAxis {...axisProps} width={48} tickFormatter={valueFormatter} />
197
+ <XAxis dataKey={xKey} {...axisProps} tickFormatter={xTickFormatter} />
198
+ <YAxis {...axisProps} width={48} domain={yDomain} tickFormatter={valueFormatter} />
175
199
  {tooltip}
176
200
  {series.map((s, i) => (
177
201
  <Bar
202
+ stackId={stacked ? 'stack' : undefined}
178
203
  key={s.key}
179
204
  dataKey={s.key}
180
205
  name={s.label ?? s.key}
@@ -189,8 +214,8 @@ export function Chart({
189
214
  chart = (
190
215
  <AreaChart data={data}>
191
216
  {gridEl}
192
- <XAxis dataKey={xKey} {...axisProps} />
193
- <YAxis {...axisProps} width={48} tickFormatter={valueFormatter} />
217
+ <XAxis dataKey={xKey} {...axisProps} tickFormatter={xTickFormatter} />
218
+ <YAxis {...axisProps} width={48} domain={yDomain} tickFormatter={valueFormatter} />
194
219
  {tooltip}
195
220
  {series.map((s, i) => (
196
221
  <Area
@@ -198,10 +223,16 @@ export function Chart({
198
223
  type="monotone"
199
224
  dataKey={s.key}
200
225
  name={s.label ?? s.key}
226
+ // One shared id is what makes recharts stack rather than
227
+ // overlay; undefined leaves the existing behaviour untouched.
228
+ stackId={stacked ? 'stack' : undefined}
201
229
  stroke={seriesColor(s, i)}
202
230
  strokeWidth={2}
203
231
  fill={seriesColor(s, i)}
204
- fillOpacity={0.12}
232
+ // Overlaid areas must stay translucent so the ones behind show
233
+ // through. Stacked bands never overlap, and at 0.12 they are too
234
+ // faint to tell apart.
235
+ fillOpacity={stacked ? 0.35 : 0.12}
205
236
  dot={false}
206
237
  activeDot={{ r: 4 }}
207
238
  />
@@ -212,8 +243,8 @@ export function Chart({
212
243
  chart = (
213
244
  <LineChart data={data}>
214
245
  {gridEl}
215
- <XAxis dataKey={xKey} {...axisProps} />
216
- <YAxis {...axisProps} width={48} tickFormatter={valueFormatter} />
246
+ <XAxis dataKey={xKey} {...axisProps} tickFormatter={xTickFormatter} />
247
+ <YAxis {...axisProps} width={48} domain={yDomain} tickFormatter={valueFormatter} />
217
248
  {tooltip}
218
249
  {series.map((s, i) => (
219
250
  <Line
@@ -26,6 +26,23 @@ export interface DataTableProps<TData> {
26
26
  onSelectionChange?: (rows: TData[]) => void;
27
27
  /** Rows per page. Set to `0` to disable pagination entirely. @default 10 */
28
28
  pageSize?: number;
29
+ /**
30
+ * Hands pagination to the caller, for data the client does not hold all of.
31
+ *
32
+ * With this set, `data` is treated as the current page rather than the whole
33
+ * set: the table stops slicing, and the footer reports and drives the
34
+ * caller's page instead of its own. Without it nothing changes — a table
35
+ * given every row keeps paginating locally, which is right for the common
36
+ * case and avoids making every consumer implement paging to get a table.
37
+ */
38
+ pagination?: {
39
+ pageIndex: number;
40
+ /** Total pages. Omit when unknown — the footer then relies on `hasNext`. */
41
+ pageCount?: number;
42
+ hasNext?: boolean;
43
+ hasPrevious?: boolean;
44
+ onPageChange: (pageIndex: number) => void;
45
+ };
29
46
  /** Slot rendered top-right of the toolbar — e.g. an "Add" button. */
30
47
  toolbar?: React.ReactNode;
31
48
  onRowClick?: (row: TData) => void;
@@ -66,6 +83,7 @@ export function DataTable<TData>({
66
83
  selectable = false,
67
84
  onSelectionChange,
68
85
  pageSize = 10,
86
+ pagination,
69
87
  toolbar,
70
88
  onRowClick,
71
89
  emptyMessage = 'No results.',
@@ -94,8 +112,13 @@ export function DataTable<TData>({
94
112
  },
95
113
  getCoreRowModel: getCoreRowModel(),
96
114
  getSortedRowModel: getSortedRowModel(),
97
- ...(pageSize > 0 ? { getPaginationRowModel: getPaginationRowModel() } : {}),
98
- initialState: pageSize > 0 ? { pagination: { pageSize } } : undefined,
115
+ // Server-paginated tables must not slice: `data` is already the page, and
116
+ // running the client model over it would paginate a single page again.
117
+ ...(pageSize > 0 && !pagination
118
+ ? { getPaginationRowModel: getPaginationRowModel() }
119
+ : {}),
120
+ ...(pagination ? { manualPagination: true } : {}),
121
+ initialState: pageSize > 0 && !pagination ? { pagination: { pageSize } } : undefined,
99
122
  enableRowSelection: selectable,
100
123
  });
101
124
 
@@ -107,6 +130,31 @@ export function DataTable<TData>({
107
130
 
108
131
  const rows = table.getRowModel().rows;
109
132
 
133
+ // One set of derivations so the footer does not branch on `pagination`
134
+ // three separate times and drift between them.
135
+ const canPrevious = pagination
136
+ ? (pagination.hasPrevious ?? pagination.pageIndex > 0)
137
+ : table.getCanPreviousPage();
138
+ const canNext = pagination
139
+ ? (pagination.hasNext ??
140
+ (pagination.pageCount !== undefined && pagination.pageIndex < pagination.pageCount - 1))
141
+ : table.getCanNextPage();
142
+ const goPrevious = () =>
143
+ pagination ? pagination.onPageChange(pagination.pageIndex - 1) : table.previousPage();
144
+ const goNext = () =>
145
+ pagination ? pagination.onPageChange(pagination.pageIndex + 1) : table.nextPage();
146
+ // A server-paginated table shows its pager whenever paging is possible.
147
+ // Total pages are often unknown, so "more than one page" is not a question
148
+ // it can answer up front the way the local model can.
149
+ const showPager = pagination
150
+ ? canPrevious || canNext
151
+ : pageSize > 0 && table.getPageCount() > 1;
152
+ const pageLabel = pagination
153
+ ? pagination.pageCount !== undefined
154
+ ? `Page ${pagination.pageIndex + 1} of ${pagination.pageCount}`
155
+ : `Page ${pagination.pageIndex + 1}`
156
+ : `Page ${table.getState().pagination.pageIndex + 1} of ${table.getPageCount()}`;
157
+
110
158
  return (
111
159
  <div className={cn('space-y-4', className)}>
112
160
  {(searchKey || toolbar) && (
@@ -186,19 +234,19 @@ export function DataTable<TData>({
186
234
  </Table>
187
235
  </div>
188
236
 
189
- {(selectable || (pageSize > 0 && table.getPageCount() > 1)) && (
237
+ {(selectable || showPager) && (
190
238
  <div className="flex items-center justify-between">
191
239
  <p className="text-sm text-muted-foreground">
192
240
  {selectable && `${table.getFilteredSelectedRowModel().rows.length} of ${table.getFilteredRowModel().rows.length} selected`}
193
- {selectable && pageSize > 0 && table.getPageCount() > 1 && ' · '}
194
- {pageSize > 0 && table.getPageCount() > 1 && `Page ${table.getState().pagination.pageIndex + 1} of ${table.getPageCount()}`}
241
+ {selectable && showPager && ' · '}
242
+ {showPager && pageLabel}
195
243
  </p>
196
- {pageSize > 0 && table.getPageCount() > 1 && (
244
+ {showPager && (
197
245
  <div className="flex gap-2">
198
- <Button variant="outline" size="sm" onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>
246
+ <Button variant="outline" size="sm" onClick={goPrevious} disabled={!canPrevious}>
199
247
  <ChevronLeft className="size-4" /> Previous
200
248
  </Button>
201
- <Button variant="outline" size="sm" onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>
249
+ <Button variant="outline" size="sm" onClick={goNext} disabled={!canNext}>
202
250
  Next <ChevronRight className="size-4" />
203
251
  </Button>
204
252
  </div>
@@ -86,11 +86,42 @@ export function NotificationToast({
86
86
  }
87
87
 
88
88
  export interface NotifyOptions extends Omit<NotificationToastProps, 'onDismiss'> {
89
+ /**
90
+ * Overrides the per-variant default below. Explicit always wins — a caller
91
+ * that has thought about the timing knows more than this heuristic does.
92
+ */
89
93
  duration?: number;
90
94
  }
91
95
 
96
+ /**
97
+ * How long a toast stays, when the caller has not said.
98
+ *
99
+ * The Toaster's own default suits a confirmation: long enough to read "Saved",
100
+ * short enough not to linger. Two cases need longer, and both were previously
101
+ * left to whoever remembered to pass a number.
102
+ *
103
+ * A failure has to be *read* — often it is the only account of what went
104
+ * wrong, and a message that vanishes before it is understood is barely better
105
+ * than none. A toast carrying an action has to be read *and* decided on, and a
106
+ * button that disappears mid-reach is worse than no button, because the reader
107
+ * knows they missed something.
108
+ *
109
+ * Returning undefined defers to the Toaster, so a product that has set its own
110
+ * default keeps it rather than being silently overridden here.
111
+ */
112
+ function defaultDuration(variant: NotificationToastProps['variant'], hasAction: boolean) {
113
+ if (variant === 'error') return 10_000;
114
+ if (variant === 'warning') return 8_000;
115
+ if (hasAction) return 8_000;
116
+ return undefined;
117
+ }
118
+
92
119
  /** Fires a `NotificationToast` through sonner. Requires `<Toaster />` from `@olwiba/cn` mounted once in your app. */
93
120
  export function notify(options: NotifyOptions) {
94
121
  const { duration, ...toastProps } = options;
95
- return toast.custom((id) => <NotificationToast {...toastProps} onDismiss={() => toast.dismiss(id)} />, { duration });
122
+ const resolved =
123
+ duration ?? defaultDuration(toastProps.variant, Boolean(toastProps.action || toastProps.secondaryAction));
124
+ return toast.custom((id) => <NotificationToast {...toastProps} onDismiss={() => toast.dismiss(id)} />, {
125
+ duration: resolved,
126
+ });
96
127
  }
@@ -9,7 +9,13 @@ export interface FooterProps {
9
9
  navLinks?: Array<{ label: string; href: string }>;
10
10
  socialLinks?: Array<{ label: string; href: string; icon: ReactNode }>;
11
11
  legal?: string;
12
- status?: { label: string; operational?: boolean };
12
+ /**
13
+ * Live status pill. `label` is a ReactNode, not a string: a real status
14
+ * pill often carries per-service detail in a tooltip, and typing it as
15
+ * string forced consumers to smuggle that through `as unknown as string`.
16
+ * A plain string still works and is the common case.
17
+ */
18
+ status?: { label: ReactNode; operational?: boolean };
13
19
  renderLink?: AppShellRenderLink;
14
20
  }
15
21