@olwiba/ui 0.2.5 → 0.2.7

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.5",
3
+ "version": "0.2.7",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -110,6 +110,15 @@ export interface AppShellProps {
110
110
  sidebarPosition?: 'viewport' | 'contained';
111
111
  /** Which side the sidebar sits on. @default "left" */
112
112
  side?: 'left' | 'right';
113
+ /**
114
+ * Class applied to the content wrapper around `children`, between the
115
+ * header and the page content. Every consuming page otherwise has to
116
+ * remember its own padding — some do, some don't, and the ones that don't
117
+ * end up flush against the sidebar/header chrome. Override to `''` for a
118
+ * genuinely edge-to-edge page (a full-width table, a map/canvas).
119
+ * @default "p-6"
120
+ */
121
+ contentClassName?: string;
113
122
  }
114
123
 
115
124
  // ─── Internal sub-components ──────────────────────────────────────────────────
@@ -377,6 +386,7 @@ export function AppShell({
377
386
  collapsible = 'icon',
378
387
  sidebarPosition = 'viewport',
379
388
  side = 'left',
389
+ contentClassName = 'p-6',
380
390
  children,
381
391
  }: AppShellProps = {}) {
382
392
  const isContained = sidebarPosition === 'contained';
@@ -398,7 +408,7 @@ export function AppShell({
398
408
  />
399
409
  <SidebarInset className={isContained ? undefined : 'overflow-y-auto'}>
400
410
  <ShellHeader pageTitle={pageTitle} headerStart={headerStart} headerEnd={headerEnd} />
401
- {children}
411
+ <div className={contentClassName}>{children}</div>
402
412
  </SidebarInset>
403
413
  </SidebarProvider>
404
414
  );
@@ -0,0 +1,99 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import type { ColumnDef } from '@tanstack/react-table';
5
+ import { cn } from '@olwiba/cn';
6
+ import { AppGrid, type AppGridColumns } from '../layout/AppGrid';
7
+ import { DataTable } from '../components/DataTable';
8
+ import type { ViewMode } from '../hooks/use-view-mode';
9
+
10
+ export interface DataViewProps<TData> {
11
+ items: TData[];
12
+ /** Stable key per item. */
13
+ getRowId: (item: TData) => string;
14
+ /** Card renderer for the grid view. */
15
+ renderCard: (item: TData) => React.ReactNode;
16
+ /** Columns for the list view. */
17
+ columns: ColumnDef<TData>[];
18
+ view: ViewMode;
19
+ /**
20
+ * False while a persisted preference is still being read. The whole view is
21
+ * withheld until true — see the note on the component.
22
+ */
23
+ ready?: boolean;
24
+ /** Grid density at the widest breakpoint. Default 3. */
25
+ gridColumns?: AppGridColumns;
26
+ gap?: 'none' | 'sm' | 'md' | 'lg';
27
+ /** Rendered instead of either view when there is nothing to show. */
28
+ empty?: React.ReactNode;
29
+ /** Shown while `ready` is false. Defaults to nothing. */
30
+ placeholder?: React.ReactNode;
31
+ searchKey?: string;
32
+ searchPlaceholder?: string;
33
+ pageSize?: number;
34
+ emptyMessage?: string;
35
+ onRowClick?: (row: TData) => void;
36
+ className?: string;
37
+ }
38
+
39
+ /**
40
+ * A collection rendered either as a grid of cards or as a table, from one set
41
+ * of data.
42
+ *
43
+ * Knows nothing about what it is listing: callers supply a card renderer and
44
+ * table columns. Cards are for "what arrived recently", the table for
45
+ * comparing many rows at once, and switching between them should not mean two
46
+ * page implementations that drift apart.
47
+ *
48
+ * The `ready` gate is the subtle part, and the reason this is a component
49
+ * rather than a snippet to copy. A persisted preference cannot be read during
50
+ * SSR or the first client render, so a page that renders its default
51
+ * immediately will either mismatch on hydration or visibly flash the wrong
52
+ * view at anyone who chose the other one. Holding the section until the
53
+ * preference resolves avoids both, and putting that here means no consumer
54
+ * has to work it out again.
55
+ */
56
+ export function DataView<TData>({
57
+ items,
58
+ getRowId,
59
+ renderCard,
60
+ columns,
61
+ view,
62
+ ready = true,
63
+ gridColumns = 3,
64
+ gap = 'sm',
65
+ empty,
66
+ placeholder = null,
67
+ searchKey,
68
+ searchPlaceholder,
69
+ pageSize = 25,
70
+ emptyMessage,
71
+ onRowClick,
72
+ className,
73
+ }: DataViewProps<TData>) {
74
+ if (!ready) return <>{placeholder}</>;
75
+ if (items.length === 0 && empty) return <>{empty}</>;
76
+
77
+ if (view === 'list') {
78
+ return (
79
+ <DataTable
80
+ columns={columns}
81
+ data={items}
82
+ searchKey={searchKey}
83
+ searchPlaceholder={searchPlaceholder}
84
+ pageSize={pageSize}
85
+ emptyMessage={emptyMessage}
86
+ onRowClick={onRowClick}
87
+ className={className}
88
+ />
89
+ );
90
+ }
91
+
92
+ return (
93
+ <AppGrid columns={gridColumns} gap={gap} className={cn(className)}>
94
+ {items.map((item) => (
95
+ <React.Fragment key={getRowId(item)}>{renderCard(item)}</React.Fragment>
96
+ ))}
97
+ </AppGrid>
98
+ );
99
+ }
@@ -0,0 +1,68 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { Card, cn } from '@olwiba/cn';
5
+
6
+ const bannerAspectClass = {
7
+ wide: 'aspect-[3/1]',
8
+ video: 'aspect-video',
9
+ square: 'aspect-square',
10
+ };
11
+
12
+ export interface MediaCardProps extends React.HTMLAttributes<HTMLDivElement> {
13
+ /**
14
+ * Banner rendered above the content, clipped to the card's rounded corners.
15
+ * A ReactNode rather than a src, so a brand fallback, an inline SVG, a map,
16
+ * or a plain <img> all work — this is the difference from ImageCard, which
17
+ * takes a URL and is the right choice when you actually have one.
18
+ */
19
+ banner?: React.ReactNode;
20
+ /** Banner proportions. Default 'wide' (3:1), which suits a header strip. */
21
+ bannerAspect?: keyof typeof bannerAspectClass;
22
+ /**
23
+ * Pinned to the bottom of the card. In a grid this is what keeps the
24
+ * primary action on a shared baseline across a row, however unevenly the
25
+ * titles above it wrap.
26
+ */
27
+ footer?: React.ReactNode;
28
+ children?: React.ReactNode;
29
+ }
30
+
31
+ /**
32
+ * A Card with an optional banner and a bottom-pinned footer.
33
+ *
34
+ * Deliberately knows nothing about what it is showing — the domain-shaped
35
+ * cards (a property, a monitor, an article) compose this and supply their own
36
+ * content. It exists because "banner, then content, then an action pinned to
37
+ * the bottom, all the same height across a row" was being hand-rolled per
38
+ * product, and the equal-height part in particular is easy to get subtly
39
+ * wrong.
40
+ *
41
+ * `h-full` is on the card itself so a bare `<MediaCard>` inside a grid cell
42
+ * fills that cell without every caller remembering to ask.
43
+ */
44
+ export function MediaCard({
45
+ banner,
46
+ bannerAspect = 'wide',
47
+ footer,
48
+ children,
49
+ className,
50
+ ...props
51
+ }: MediaCardProps) {
52
+ return (
53
+ <Card
54
+ className={cn('flex h-full w-full flex-col overflow-hidden', banner && 'pt-0', className)}
55
+ {...props}
56
+ >
57
+ {banner && (
58
+ <div className={cn('w-full shrink-0 overflow-hidden', bannerAspectClass[bannerAspect])}>
59
+ {banner}
60
+ </div>
61
+ )}
62
+ <div className="flex flex-1 flex-col gap-3 p-4">
63
+ {children}
64
+ {footer && <div className="mt-auto pt-1">{footer}</div>}
65
+ </div>
66
+ </Card>
67
+ );
68
+ }
@@ -0,0 +1,47 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { Button, cn } from '@olwiba/cn';
5
+ import { LayoutGrid, List } from 'lucide-react';
6
+ import type { ViewMode } from '../hooks/use-view-mode';
7
+
8
+ export interface ViewToggleProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> {
9
+ view: ViewMode;
10
+ onChange: (view: ViewMode) => void;
11
+ labels?: { cards?: string; list?: string };
12
+ }
13
+
14
+ /**
15
+ * Two-state segmented control for switching a collection between cards and a
16
+ * list. Controlled — pair it with useViewMode to persist the choice.
17
+ */
18
+ export function ViewToggle({ view, onChange, labels, className, ...props }: ViewToggleProps) {
19
+ const options = [
20
+ { mode: 'cards' as const, label: labels?.cards ?? 'Card view', Icon: LayoutGrid },
21
+ { mode: 'list' as const, label: labels?.list ?? 'List view', Icon: List },
22
+ ];
23
+
24
+ return (
25
+ <div
26
+ className={cn('inline-flex items-center rounded-md border p-0.5', className)}
27
+ role="group"
28
+ aria-label="View mode"
29
+ {...props}
30
+ >
31
+ {options.map(({ mode, label, Icon }) => (
32
+ <Button
33
+ key={mode}
34
+ type="button"
35
+ size="sm"
36
+ variant={view === mode ? 'secondary' : 'ghost'}
37
+ className="h-7 px-2"
38
+ aria-label={label}
39
+ aria-pressed={view === mode}
40
+ onClick={() => onChange(mode)}
41
+ >
42
+ <Icon className="size-4" />
43
+ </Button>
44
+ ))}
45
+ </div>
46
+ );
47
+ }
@@ -0,0 +1,36 @@
1
+ 'use client';
2
+
3
+ import { useLocalStorage } from './use-local-storage';
4
+ import { useMounted } from './use-mounted';
5
+
6
+ export type ViewMode = 'cards' | 'list';
7
+
8
+ export interface UseViewModeReturn {
9
+ view: ViewMode;
10
+ setView: (view: ViewMode) => void;
11
+ /**
12
+ * False until the stored preference is readable. Render a skeleton or hold
13
+ * the section until this is true — see the note below on why.
14
+ */
15
+ ready: boolean;
16
+ }
17
+
18
+ /**
19
+ * Remembered cards/list preference.
20
+ *
21
+ * Returns the fallback on the server and on the first client render, then
22
+ * settles to the stored value once mounted. Returning the stored value
23
+ * immediately would have the server emit card markup while the client builds
24
+ * a table — a hydration mismatch — and painting the fallback first flashes
25
+ * the wrong view at someone who chose the other one. `ready` lets callers
26
+ * wait for the real answer instead of doing either.
27
+ *
28
+ * The key is shared by default so the choice reads as one product-wide
29
+ * preference: pick list on one page and every page follows. Pass a distinct
30
+ * key where a page genuinely wants its own.
31
+ */
32
+ export function useViewMode(key = 'view-mode', fallback: ViewMode = 'cards'): UseViewModeReturn {
33
+ const [stored, setStored] = useLocalStorage<ViewMode>(key, fallback);
34
+ const mounted = useMounted();
35
+ return { view: mounted ? stored : fallback, setView: setStored, ready: mounted };
36
+ }
package/src/index.ts CHANGED
@@ -128,6 +128,7 @@ export { CommandMenu, type CommandMenuProps, type CommandMenuGroup, type Command
128
128
 
129
129
  // ─── Components — data ───────────────────────────────────────────────────────
130
130
  export { DataTable, type DataTableProps, type DataTableColumn } from './components/DataTable';
131
+ export { DataView, type DataViewProps } from './app/DataView';
131
132
  export { Chart, type ChartProps, type ChartSeries } from './components/Chart';
132
133
  export { FileUpload, type FileUploadProps, type FileUploadEntry } from './components/FileUpload';
133
134
 
@@ -147,6 +148,8 @@ export { StatCard, type StatCardProps } from './components/StatCard';
147
148
  export { TestimonialCard, type TestimonialCardProps } from './components/TestimonialCard';
148
149
  export { PricingCard, type PricingCardProps, type PricingFeature } from './components/PricingCard';
149
150
  export { ImageCard, type ImageCardProps } from './components/ImageCard';
151
+ export { MediaCard, type MediaCardProps } from './components/MediaCard';
152
+ export { ViewToggle, type ViewToggleProps } from './components/ViewToggle';
150
153
 
151
154
  // ─── Components — diagram ────────────────────────────────────────────────────
152
155
  export { FlowConnector, type FlowConnectorProps } from './components/FlowConnector';
@@ -183,5 +186,6 @@ export { useCopyToClipboard } from './hooks/use-copy-to-clipboard';
183
186
  export { useDebounce } from './hooks/use-debounce';
184
187
  export { useIntersectionObserver } from './hooks/use-intersection-observer';
185
188
  export { useLocalStorage } from './hooks/use-local-storage';
189
+ export { useViewMode, type ViewMode, type UseViewModeReturn } from './hooks/use-view-mode';
186
190
  export { useMediaQuery } from './hooks/use-media-query';
187
191
  export { usePagination, type UsePaginationReturn } from './hooks/use-pagination';
@@ -1,29 +1,48 @@
1
1
  import React from 'react'
2
2
  import { cn } from '../lib/utils'
3
3
 
4
+ /**
5
+ * Responsive ramps per column count. Every ramp starts at a single column —
6
+ * a card grid that stays multi-column on a phone is unreadable — and adds
7
+ * columns at breakpoints wide enough to keep each card legible.
8
+ *
9
+ * Written out in full rather than composed, because Tailwind scans source for
10
+ * complete class strings; a template literal like `xl:grid-cols-${n}` produces
11
+ * nothing at build time.
12
+ */
4
13
  const columnsMap = {
5
14
  1: 'grid-cols-1',
6
- 2: 'grid-cols-1 md:grid-cols-2',
7
- 3: 'grid-cols-1 md:grid-cols-2 xl:grid-cols-3',
8
- 4: 'grid-cols-1 md:grid-cols-2 xl:grid-cols-4',
15
+ 2: 'grid-cols-1 sm:grid-cols-2',
16
+ 3: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',
17
+ 4: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
18
+ 5: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5',
19
+ 6: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6',
9
20
  }
10
21
 
11
22
  const gapMap = {
23
+ none: 'gap-0',
12
24
  sm: 'gap-3',
13
25
  md: 'gap-4',
14
26
  lg: 'gap-6',
15
27
  }
16
28
 
17
29
  const spanMap = {
18
- 1: 'md:col-span-1',
19
- 2: 'md:col-span-2',
20
- 3: 'xl:col-span-3',
30
+ 1: 'sm:col-span-1',
31
+ 2: 'sm:col-span-2',
32
+ 3: 'lg:col-span-3',
21
33
  4: 'xl:col-span-4',
22
34
  full: 'col-span-full',
23
35
  }
24
36
 
37
+ export type AppGridColumns = keyof typeof columnsMap
38
+
25
39
  export interface AppGridProps extends React.HTMLAttributes<HTMLDivElement> {
26
- columns?: keyof typeof columnsMap
40
+ /**
41
+ * Columns at the widest breakpoint. Narrower screens step down through the
42
+ * ramp automatically, so this is "how dense at full width", not a fixed
43
+ * count. Default 3.
44
+ */
45
+ columns?: AppGridColumns
27
46
  gap?: keyof typeof gapMap
28
47
  }
29
48
 
@@ -7,6 +7,6 @@ export type { SectionProps } from './Section'
7
7
  export { AppContent } from './AppContent'
8
8
  export type { AppContentProps } from './AppContent'
9
9
  export { AppGrid, AppGridCell } from './AppGrid'
10
- export type { AppGridProps, AppGridCellProps } from './AppGrid'
10
+ export type { AppGridProps, AppGridCellProps, AppGridColumns } from './AppGrid'
11
11
  export { PublicPageFrame } from './PublicPageFrame'
12
12
  export type { PublicPageFrameProps } from './PublicPageFrame'
@@ -31,6 +31,7 @@ export function Navbar({
31
31
  const [open, setOpen] = React.useState(false);
32
32
  const brandHref = brand.href ?? '/';
33
33
  const hasRightContent = controls?.length || cta?.primary || cta?.secondary;
34
+ const closeMobileMenu = React.useCallback(() => setOpen(false), []);
34
35
 
35
36
  return (
36
37
  <section className="overflow-hidden rounded-2xl border bg-card">
@@ -92,7 +93,7 @@ export function Navbar({
92
93
  <SheetContent side="left" className="w-72">
93
94
  {/* Brand row alone at the top — leaves the top-right corner to the built-in close */}
94
95
  <div className="flex items-center pb-4">
95
- <span onClick={() => setOpen(false)} className="cursor-pointer">
96
+ <span onClickCapture={closeMobileMenu} className="cursor-pointer">
96
97
  {renderLink({
97
98
  href: brandHref,
98
99
  children: (
@@ -114,7 +115,7 @@ export function Navbar({
114
115
  <Separator />
115
116
  <nav className="mt-4 flex flex-col gap-1">
116
117
  {navLinks.map((link) => (
117
- <span key={link.label} onClick={() => setOpen(false)}>
118
+ <span key={link.label} onClickCapture={closeMobileMenu}>
118
119
  {renderLink({
119
120
  href: link.href,
120
121
  className: 'block rounded-md px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground',
@@ -124,16 +125,22 @@ export function Navbar({
124
125
  ))}
125
126
  </nav>
126
127
  <div className="mt-6 flex flex-col gap-2">
127
- {cta?.secondary &&
128
- renderLink({
129
- href: cta.secondary.href,
130
- children: <Button variant="outline" className="w-full">{cta.secondary.label}</Button>,
131
- })}
132
- {cta?.primary &&
133
- renderLink({
134
- href: cta.primary.href,
135
- children: <Button className="w-full">{cta.primary.label}</Button>,
136
- })}
128
+ {cta?.secondary && (
129
+ <span onClickCapture={closeMobileMenu}>
130
+ {renderLink({
131
+ href: cta.secondary.href,
132
+ children: <Button variant="outline" className="w-full">{cta.secondary.label}</Button>,
133
+ })}
134
+ </span>
135
+ )}
136
+ {cta?.primary && (
137
+ <span onClickCapture={closeMobileMenu}>
138
+ {renderLink({
139
+ href: cta.primary.href,
140
+ children: <Button className="w-full">{cta.primary.label}</Button>,
141
+ })}
142
+ </span>
143
+ )}
137
144
  </div>
138
145
  </SheetContent>
139
146
  </Sheet>