@olwiba/ui 0.2.23 → 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.
@@ -0,0 +1,80 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { Button, Spinner, cn } from '@olwiba/cn';
5
+
6
+ export interface LoadMoreProps extends React.HTMLAttributes<HTMLDivElement> {
7
+ hasNextPage: boolean;
8
+ isFetchingNextPage: boolean;
9
+ fetchNextPage: () => void | Promise<unknown>;
10
+ /** Watches the sentinel instead of showing a manual button. */
11
+ auto?: boolean;
12
+ /** Plural noun used by the manual button, for example `properties`. */
13
+ label?: string;
14
+ /** Starts loading before the sentinel reaches the viewport. */
15
+ rootMargin?: string;
16
+ }
17
+
18
+ /** Cursor-pagination footer with either guarded infinite scroll or a manual fallback. */
19
+ export function LoadMore({
20
+ hasNextPage,
21
+ isFetchingNextPage,
22
+ fetchNextPage,
23
+ auto = true,
24
+ label = 'more',
25
+ rootMargin = '400px',
26
+ className,
27
+ ...props
28
+ }: LoadMoreProps) {
29
+ const sentinelRef = React.useRef<HTMLDivElement>(null);
30
+ const stateRef = React.useRef({ hasNextPage, isFetchingNextPage, fetchNextPage });
31
+ stateRef.current = { hasNextPage, isFetchingNextPage, fetchNextPage };
32
+
33
+ React.useEffect(() => {
34
+ if (!auto) return;
35
+ const node = sentinelRef.current;
36
+ if (!node) return;
37
+
38
+ const observer = new IntersectionObserver(
39
+ ([entry]) => {
40
+ if (!entry?.isIntersecting) return;
41
+ const state = stateRef.current;
42
+ if (state.hasNextPage && !state.isFetchingNextPage) void state.fetchNextPage();
43
+ },
44
+ { rootMargin },
45
+ );
46
+
47
+ observer.observe(node);
48
+ return () => observer.disconnect();
49
+ }, [auto, rootMargin]);
50
+
51
+ if (!hasNextPage) return null;
52
+
53
+ return (
54
+ <div ref={sentinelRef} className={cn('flex justify-center py-4', className)} {...props}>
55
+ {auto ? (
56
+ <span className="flex h-9 items-center text-sm text-muted-foreground" aria-live="polite">
57
+ {isFetchingNextPage && (
58
+ <>
59
+ <Spinner className="mr-2 size-4" /> Loading more…
60
+ </>
61
+ )}
62
+ </span>
63
+ ) : (
64
+ <Button
65
+ variant="outline"
66
+ onClick={() => void fetchNextPage()}
67
+ disabled={isFetchingNextPage}
68
+ >
69
+ {isFetchingNextPage ? (
70
+ <>
71
+ <Spinner className="mr-2 size-4" /> Loading…
72
+ </>
73
+ ) : (
74
+ `Load more ${label}`
75
+ )}
76
+ </Button>
77
+ )}
78
+ </div>
79
+ );
80
+ }
@@ -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
  }
package/src/index.ts CHANGED
@@ -27,6 +27,8 @@ export {
27
27
  type AppShellAction,
28
28
  type AppShellRenderLink,
29
29
  type AppShellBreadcrumb,
30
+ type AppShellHeaderControls,
31
+ type AppShellHeaderStart,
30
32
  type AppNavItem,
31
33
  } from './app/AppShell';
32
34
 
@@ -145,6 +147,7 @@ export { CommandMenu, type CommandMenuProps, type CommandMenuGroup, type Command
145
147
 
146
148
  // ─── Components — data ───────────────────────────────────────────────────────
147
149
  export { DataTable, type DataTableProps, type DataTableColumn } from './components/DataTable';
150
+ export { LoadMore, type LoadMoreProps } from './components/LoadMore';
148
151
  export { DataView, type DataViewProps } from './app/DataView';
149
152
  export { Chart, type ChartProps, type ChartSeries } from './components/Chart';
150
153
  export { FileUpload, type FileUploadProps, type FileUploadEntry } from './components/FileUpload';
@@ -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