@campfire-interactive/ui 0.4.0 → 0.5.0

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/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import * as react from 'react';
2
+ import { ReactNode } from 'react';
2
3
  import * as RadixMenu from '@radix-ui/react-dropdown-menu';
3
4
  import * as RadixCheckbox from '@radix-ui/react-checkbox';
4
5
  import * as RadixSwitch from '@radix-ui/react-switch';
@@ -169,6 +170,15 @@ interface FilterSelect {
169
170
  value: string;
170
171
  label: React.ReactNode;
171
172
  }[];
173
+ /**
174
+ * What the select reads when nothing is chosen. Defaults to "Any".
175
+ *
176
+ * Built from the label it was — `All ${label.toLowerCase()}` — which reads
177
+ * fine for "All statuses" and badly for everything else: the first two call
178
+ * sites produced "All area" and "All who". A caller that wants a plural
179
+ * passes one; the default is a word that works under any label.
180
+ */
181
+ placeholder?: string;
172
182
  }
173
183
  interface FilterToggle {
174
184
  id: string;
@@ -184,6 +194,8 @@ interface FilterBarProps {
184
194
  onChange: (next: FilterValue) => void;
185
195
  /** The button's label. Defaults to "Filters". */
186
196
  label?: string;
197
+ /** The empty state of every select that does not name its own. Defaults to "Any". */
198
+ anyLabel?: string;
187
199
  clearLabel?: string;
188
200
  /** Announced on each chip's remove button — "Remove {{name}} filter". */
189
201
  removeLabel?: (name: string) => string;
@@ -212,7 +224,7 @@ interface FilterBarProps {
212
224
  * wants a different chip — "OEM: 3 selected" — and inventing it before there
213
225
  * is a screen for it is how a shared component grows an API nobody uses.
214
226
  */
215
- declare function FilterBar({ selects, toggles, value, onChange, label, clearLabel, removeLabel, className, }: FilterBarProps): react.JSX.Element;
227
+ declare function FilterBar({ selects, toggles, value, onChange, label, anyLabel, clearLabel, removeLabel, className, }: FilterBarProps): react.JSX.Element;
216
228
 
217
229
  type InputSize = 'sm' | 'md' | 'lg';
218
230
  /**
@@ -1342,6 +1354,90 @@ interface PaginationProps {
1342
1354
  */
1343
1355
  declare function Pagination({ page, pageSize, totalItems, onPageChange, showRange, labels, className, }: PaginationProps): react.JSX.Element | null;
1344
1356
 
1357
+ interface ListToolbarProps {
1358
+ /** The search field. Normally a `SearchInput`. */
1359
+ search?: ReactNode;
1360
+ /** The filter control. Normally a `FilterBar`. */
1361
+ filters?: ReactNode;
1362
+ /**
1363
+ * The page's actions — the Add button, a column chooser, an export. Pushed
1364
+ * to the right.
1365
+ */
1366
+ actions?: ReactNode;
1367
+ className?: string;
1368
+ }
1369
+ /**
1370
+ * SEARCH, FILTERS AND ACTIONS, in the order every list in the suite wants them.
1371
+ *
1372
+ * ── Why this exists ───────────────────────────────────────────────────────
1373
+ *
1374
+ * `SearchInput`, `FilterBar`, `Table` and `Pagination` were all here already
1375
+ * and every screen wired its own row around them — so they drifted. Within one
1376
+ * app, one table put search left of the filters and another put it right; one
1377
+ * pushed the Add button into the card header and another into the toolbar. The
1378
+ * parts were shared and the arrangement was not, which is the half of
1379
+ * consistency users actually see.
1380
+ *
1381
+ * ── The order is the opinion ──────────────────────────────────────────────
1382
+ *
1383
+ * Search, then filters, then a gap, then actions. Search first because it is
1384
+ * how most people start and it is a text field rather than a value; filters
1385
+ * beside it because they narrow the same list; actions hard right because they
1386
+ * are the only controls here that CHANGE something rather than change what you
1387
+ * are looking at.
1388
+ *
1389
+ * ── What it is not ────────────────────────────────────────────────────────
1390
+ *
1391
+ * Not a data component. It takes no rows, owns no state and knows nothing
1392
+ * about paging — it is a row with three slots and a wrap rule. Pagination
1393
+ * lives UNDER the table, where the rows end, not up here: a pager above the
1394
+ * data is a control for something the reader has not seen yet.
1395
+ *
1396
+ * At phone width the slots stack and each takes the full row, so a search
1397
+ * field never ends up sharing 180px with a filter button.
1398
+ */
1399
+ declare function ListToolbar({ search, filters, actions, className }: ListToolbarProps): react.JSX.Element;
1400
+
1401
+ interface PagedRows<T> {
1402
+ /** The slice to render. */
1403
+ rows: T[];
1404
+ /** 1-based, for `Pagination`. */
1405
+ page: number;
1406
+ setPage: (page: number) => void;
1407
+ pageSize: number;
1408
+ setPageSize: (size: number) => void;
1409
+ /** The unpaged total, for `Pagination`'s range readout. */
1410
+ totalItems: number;
1411
+ }
1412
+ /**
1413
+ * CLIENT-SIDE PAGING over rows already in hand.
1414
+ *
1415
+ * ── When this is the right answer, and when it is not ─────────────────────
1416
+ *
1417
+ * Use it when the whole list is already loaded and the problem is the DOM:
1418
+ * five hundred rows in a table is slow to render, slow to scroll and
1419
+ * impossible to read. It does NOT make a request smaller, and a list that can
1420
+ * outgrow one response needs a paged ENDPOINT instead — this hook over an
1421
+ * unbounded fetch is pagination theatre, and the worst version of it, because
1422
+ * the screen then looks like it handles scale.
1423
+ *
1424
+ * ── The bug this exists to prevent ────────────────────────────────────────
1425
+ *
1426
+ * Page 7 of 9, then type into the search box and the list narrows to three
1427
+ * rows: the table renders EMPTY, because page 7 of a one-page list is past the
1428
+ * end. Every hand-rolled version of this has had it, and it reads as "the
1429
+ * search is broken" rather than as a paging fault.
1430
+ *
1431
+ * So the page is CLAMPED on every render — not reset. Clamping keeps you on
1432
+ * page 3 when a row is deleted from a long list, where a reset would throw you
1433
+ * back to the top for no reason the reader can see; and it still pulls you to
1434
+ * the last real page when the list shrinks under you.
1435
+ *
1436
+ * Changing the page SIZE keeps the first row you were looking at on screen,
1437
+ * rather than keeping the page number and jumping somewhere unrelated.
1438
+ */
1439
+ declare function usePagedRows<T>(all: readonly T[], initialPageSize?: number): PagedRows<T>;
1440
+
1345
1441
  /**
1346
1442
  * Date and money formatting, because eleven apps call `toLocaleDateString`
1347
1443
  * raw and two wrote their own `formatMoney`.
@@ -1384,4 +1480,4 @@ declare function formatNumber(value: number | null | undefined, decimals?: numbe
1384
1480
  */
1385
1481
  declare function formatPercent(value: number | null | undefined, decimals?: number): string;
1386
1482
 
1387
- export { Accordion, AccordionContent, AccordionItem, type AccordionProps, AccordionRoot, type AccordionSection, AccordionTrigger, type AccordionTriggerProps, Avatar, AvatarGroup, type AvatarGroupProps, type AvatarProps, Badge, type BadgeProps, type BadgeTone, Button, ButtonGroup, type ButtonGroupProps, type ButtonProps, type ButtonSize, type ButtonVariant, Card, CardBody, CardDescription, CardFooter, CardHeader, type CardHeaderProps, type CardProps, CardTitle, type CardTitleProps, Checkbox, type CheckboxProps, type ClassValue, Combobox, type ComboboxOption, type ComboboxProps, ComingSoon, type ComingSoonProps, ConfirmDialog, type ConfirmDialogProps, CountBadge, type CountBadgeProps, CreatableSelect, type CreatableSelectProps, Dialog, DialogBody, DialogClose, DialogContent, type DialogContentProps, DialogDescription, DialogFooter, DialogHeader, DialogPortal, DialogRoot, type DialogSize, DialogTitle, DialogTrigger, EmptyState, type EmptyStateProps, ErrorState, type ErrorStateProps, Field, type FieldProps, Fieldset, type FieldsetProps, FilterBar, type FilterBarProps, type FilterSelect, type FilterToggle, type FilterValue, IconButton, type IconButtonProps, Input, type InputProps, type InputSize, Label, type LabelProps, LoadingState, type LoadingStateProps, Menu, type MenuAction, MenuCheckboxItem, MenuContent, MenuGroup, MenuItem, type MenuItemProps, MenuLabel, type MenuProps, MenuRadioGroup, MenuRadioItem, MenuRoot, MenuSeparator, MenuSub, MenuSubContent, MenuSubTrigger, MenuTrigger, Modal, type ModalProps, type MoneyOptions, Pagination, type PaginationProps, Popover, PopoverAnchor, PopoverClose, PopoverContent, type PopoverContentProps, type PopoverProps, PopoverRoot, PopoverTrigger, Progress, type ProgressProps, ScrollArea, type ScrollAreaProps, SearchInput, type SearchInputProps, Select, SelectContent, SelectGroup, SelectItem, SelectLabel, type SelectOption, type SelectProps, SelectRoot, SelectSeparator, SelectTrigger, type SelectTriggerProps, SelectValue, Separator, type SeparatorProps, Sheet, SheetBody, SheetClose, SheetContent, type SheetContentProps, SheetDescription, SheetFooter, SheetHeader, SheetPortal, SheetTitle, SheetTrigger, SideModal, Skeleton, type SkeletonProps, Slider, type SliderProps, type SortDirection, type SortState, SplitButton, type SplitButtonProps, Switch, type SwitchProps, type TabDefinition, TabSet, type TabSetProps, Table, TableBody, TableCaption, TableCell, type TableCellProps, TableEmpty, TableFoot, TableFooter, TableHead, type TableHeadProps, TableHeader, type TableProps, TableRow, type TableRowProps, TableScroller, Tabs, TabsContent, TabsList, type TabsListProps, TabsRoot, TabsTrigger, type TabsTriggerProps, Textarea, type TextareaProps, type ToastOptions, ToastProvider, type ToastProviderProps, type ToastTone, ToggleGroup, type ToggleGroupProps, type ToggleOption, Tooltip, TooltipContent, type TooltipContentProps, type TooltipProps, TooltipProvider, TooltipRoot, TooltipTrigger, type UseSortStateResult, cn, formatDate, formatDateTime, formatMoney, formatNumber, formatPercent, useFieldControl, useSortState, useToast };
1483
+ export { Accordion, AccordionContent, AccordionItem, type AccordionProps, AccordionRoot, type AccordionSection, AccordionTrigger, type AccordionTriggerProps, Avatar, AvatarGroup, type AvatarGroupProps, type AvatarProps, Badge, type BadgeProps, type BadgeTone, Button, ButtonGroup, type ButtonGroupProps, type ButtonProps, type ButtonSize, type ButtonVariant, Card, CardBody, CardDescription, CardFooter, CardHeader, type CardHeaderProps, type CardProps, CardTitle, type CardTitleProps, Checkbox, type CheckboxProps, type ClassValue, Combobox, type ComboboxOption, type ComboboxProps, ComingSoon, type ComingSoonProps, ConfirmDialog, type ConfirmDialogProps, CountBadge, type CountBadgeProps, CreatableSelect, type CreatableSelectProps, Dialog, DialogBody, DialogClose, DialogContent, type DialogContentProps, DialogDescription, DialogFooter, DialogHeader, DialogPortal, DialogRoot, type DialogSize, DialogTitle, DialogTrigger, EmptyState, type EmptyStateProps, ErrorState, type ErrorStateProps, Field, type FieldProps, Fieldset, type FieldsetProps, FilterBar, type FilterBarProps, type FilterSelect, type FilterToggle, type FilterValue, IconButton, type IconButtonProps, Input, type InputProps, type InputSize, Label, type LabelProps, ListToolbar, type ListToolbarProps, LoadingState, type LoadingStateProps, Menu, type MenuAction, MenuCheckboxItem, MenuContent, MenuGroup, MenuItem, type MenuItemProps, MenuLabel, type MenuProps, MenuRadioGroup, MenuRadioItem, MenuRoot, MenuSeparator, MenuSub, MenuSubContent, MenuSubTrigger, MenuTrigger, Modal, type ModalProps, type MoneyOptions, type PagedRows, Pagination, type PaginationProps, Popover, PopoverAnchor, PopoverClose, PopoverContent, type PopoverContentProps, type PopoverProps, PopoverRoot, PopoverTrigger, Progress, type ProgressProps, ScrollArea, type ScrollAreaProps, SearchInput, type SearchInputProps, Select, SelectContent, SelectGroup, SelectItem, SelectLabel, type SelectOption, type SelectProps, SelectRoot, SelectSeparator, SelectTrigger, type SelectTriggerProps, SelectValue, Separator, type SeparatorProps, Sheet, SheetBody, SheetClose, SheetContent, type SheetContentProps, SheetDescription, SheetFooter, SheetHeader, SheetPortal, SheetTitle, SheetTrigger, SideModal, Skeleton, type SkeletonProps, Slider, type SliderProps, type SortDirection, type SortState, SplitButton, type SplitButtonProps, Switch, type SwitchProps, type TabDefinition, TabSet, type TabSetProps, Table, TableBody, TableCaption, TableCell, type TableCellProps, TableEmpty, TableFoot, TableFooter, TableHead, type TableHeadProps, TableHeader, type TableProps, TableRow, type TableRowProps, TableScroller, Tabs, TabsContent, TabsList, type TabsListProps, TabsRoot, TabsTrigger, type TabsTriggerProps, Textarea, type TextareaProps, type ToastOptions, ToastProvider, type ToastProviderProps, type ToastTone, ToggleGroup, type ToggleGroupProps, type ToggleOption, Tooltip, TooltipContent, type TooltipContentProps, type TooltipProps, TooltipProvider, TooltipRoot, TooltipTrigger, type UseSortStateResult, cn, formatDate, formatDateTime, formatMoney, formatNumber, formatPercent, useFieldControl, usePagedRows, useSortState, useToast };
package/dist/index.js CHANGED
@@ -21,7 +21,7 @@ function styleInject(css, { insertAt } = {}) {
21
21
  }
22
22
 
23
23
  // src/styles/base.css
24
- styleInject('@layer components {\n :where(:root) {\n --cfi-ui-surface: var(--cfi-surface, #ffffff);\n --cfi-ui-surface-raised: var(--cfi-surface-raised, #ffffff);\n --cfi-ui-surface-sunken: var(--cfi-surface-sunken, #f9fafb);\n --cfi-ui-surface-hover: var(--cfi-surface-hover, #f9fafb);\n --cfi-ui-surface-active: var(--cfi-surface-active, #f3f4f6);\n --cfi-ui-surface-selected: var(--cfi-surface-selected, #f3f4f6);\n --cfi-ui-surface-disabled: var(--cfi-surface-disabled, #f3f4f6);\n --cfi-ui-surface-muted: var(--cfi-surface-muted, #ececf0);\n --cfi-ui-input-bg: var(--cfi-input-bg, #f3f3f5);\n --cfi-ui-switch-bg: var(--cfi-switch-bg, #cbced4);\n --cfi-ui-content: var(--cfi-content, oklch(0.145 0 0));\n --cfi-ui-content-muted: var(--cfi-content-muted, #666676);\n --cfi-ui-content-subtle: var(--cfi-content-subtle, #9ca3af);\n --cfi-ui-content-inverse: var(--cfi-content-inverse, #ffffff);\n --cfi-ui-content-disabled: var(--cfi-content-disabled, #9ca3af);\n --cfi-ui-content-link: var(--cfi-content-link, #1d4ed8);\n --cfi-ui-border: var(--cfi-border, #e5e7eb);\n --cfi-ui-border-subtle: var(--cfi-border-subtle, #f3f4f6);\n --cfi-ui-border-strong: var(--cfi-border-strong, #d1d5db);\n --cfi-ui-accent: var(--cfi-action, #030213);\n --cfi-ui-accent-hover: var(--cfi-action-hover, #1a1a2e);\n --cfi-ui-accent-soft: var(--cfi-action-soft, #f3f4f6);\n --cfi-ui-accent-fg: var(--cfi-action-fg, #ffffff);\n --cfi-ui-success: var(--cfi-color-success, #10b981);\n --cfi-ui-success-soft: var(--cfi-color-success-soft, #ecfdf5);\n --cfi-ui-success-text: var(--cfi-color-success-text, #047857);\n --cfi-ui-success-border: var(--cfi-color-success-border, #a7f3d0);\n --cfi-ui-warning: var(--cfi-color-warning, #f59e0b);\n --cfi-ui-warning-soft: var(--cfi-color-warning-soft, #fffbeb);\n --cfi-ui-warning-text: var(--cfi-color-warning-text, #b45309);\n --cfi-ui-warning-border: var(--cfi-color-warning-border, #fde68a);\n --cfi-ui-error: var(--cfi-color-error, #ef4444);\n --cfi-ui-error-strong: var(--cfi-color-error-strong, #dc2626);\n --cfi-ui-error-soft: var(--cfi-color-error-soft, #fef2f2);\n --cfi-ui-error-text: var(--cfi-color-error-text, #b91c1c);\n --cfi-ui-error-border: var(--cfi-color-error-border, #fecaca);\n --cfi-ui-info: var(--cfi-color-info, #3b82f6);\n --cfi-ui-info-soft: var(--cfi-color-info-soft, #eff6ff);\n --cfi-ui-info-text: var(--cfi-color-info-text, #1d4ed8);\n --cfi-ui-info-border: var(--cfi-color-info-border, #bfdbfe);\n --cfi-ui-neutral-soft: var(--cfi-color-neutral-soft, #f3f4f6);\n --cfi-ui-neutral-text: var(--cfi-color-neutral-text, #374151);\n --cfi-ui-neutral-border: var(--cfi-color-neutral-border, #e5e7eb);\n --cfi-ui-focus-color: var(--cfi-focus-ring-color, var(--cfi-ui-accent));\n --cfi-ui-focus-width: var(--cfi-focus-ring-width, 2px);\n --cfi-ui-focus-offset: var(--cfi-focus-ring-offset, 2px);\n --cfi-ui-overlay: var(--cfi-overlay, rgb(17 24 39 / 0.45));\n --cfi-ui-font: var(--cfi-font-family, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif);\n --cfi-ui-font-mono: var(--cfi-font-family-mono, ui-monospace, Consolas, monospace);\n --cfi-ui-text-xs: var(--cfi-font-size-xs, 0.75rem);\n --cfi-ui-text-sm: var(--cfi-font-size-sm, 0.875rem);\n --cfi-ui-text-base: var(--cfi-font-size-base, 1rem);\n --cfi-ui-text-lg: var(--cfi-font-size-lg, 1.125rem);\n --cfi-ui-text-xl: var(--cfi-font-size-xl, 1.25rem);\n --cfi-ui-weight-normal: var(--cfi-font-weight-normal, 400);\n --cfi-ui-weight-medium: var(--cfi-font-weight-medium, 500);\n --cfi-ui-weight-semibold: var(--cfi-font-weight-semibold, 600);\n --cfi-ui-leading-tight: var(--cfi-line-height-tight, 1.2);\n --cfi-ui-leading-snug: var(--cfi-line-height-snug, 1.35);\n --cfi-ui-leading-normal: var(--cfi-line-height-normal, 1.5);\n --cfi-ui-tracking-caps: var(--cfi-letter-spacing-caps, 0.08em);\n --cfi-ui-space-3xs: var(--cfi-spacing-3xs, 0.125rem);\n --cfi-ui-space-2xs: var(--cfi-spacing-2xs, 0.1875rem);\n --cfi-ui-space-xs: var(--cfi-spacing-xs, 0.25rem);\n --cfi-ui-space-sm: var(--cfi-spacing-sm, 0.5rem);\n --cfi-ui-space-md: var(--cfi-spacing-md, 1rem);\n --cfi-ui-space-lg: var(--cfi-spacing-lg, 1.5rem);\n --cfi-ui-space-xl: var(--cfi-spacing-xl, 2rem);\n --cfi-ui-radius-sm: var(--cfi-radius-sm, 0.375rem);\n --cfi-ui-radius-md: var(--cfi-radius-md, 0.5rem);\n --cfi-ui-radius-lg: var(--cfi-radius-lg, 0.625rem);\n --cfi-ui-radius-xl: var(--cfi-radius-xl, 0.875rem);\n --cfi-ui-radius-full: var(--cfi-radius-full, 9999px);\n --cfi-ui-shadow-xs: var(--cfi-shadow-xs, 0 1px 1px rgb(17 24 39 / 0.04));\n --cfi-ui-shadow-sm: var(--cfi-shadow-sm, 0 1px 2px rgb(17 24 39 / 0.05));\n --cfi-ui-shadow-md: var(--cfi-shadow-md, 0 4px 6px rgb(17 24 39 / 0.07));\n --cfi-ui-shadow-lg: var(--cfi-shadow-lg, 0 10px 15px rgb(17 24 39 / 0.1));\n --cfi-ui-shadow-xl: var(--cfi-shadow-xl, 0 20px 40px -12px rgb(17 24 39 / 0.22));\n --cfi-ui-transition-fast: var(--cfi-transition-fast, 120ms cubic-bezier(0.4, 0, 0.2, 1));\n --cfi-ui-transition-base: var(--cfi-transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1));\n --cfi-ui-z-dropdown: var(--cfi-z-dropdown, 1000);\n --cfi-ui-z-overlay: var(--cfi-z-overlay, 1100);\n --cfi-ui-z-modal: var(--cfi-z-modal, 1200);\n --cfi-ui-z-popover: var(--cfi-z-popover, 1300);\n --cfi-ui-z-toast: var(--cfi-z-toast, 1400);\n --cfi-ui-z-tooltip: var(--cfi-z-tooltip, 1500);\n --cfi-ui-control-height-sm: 2rem;\n --cfi-ui-control-height-md: 2.25rem;\n --cfi-ui-control-height-lg: 2.75rem;\n }\n .cfi-ui {\n box-sizing: border-box;\n font-family: var(--cfi-ui-font);\n }\n .cfi-ui *,\n .cfi-ui *::before,\n .cfi-ui *::after {\n box-sizing: border-box;\n }\n .cfi-ui:focus-visible,\n .cfi-ui :focus-visible {\n outline: var(--cfi-ui-focus-width) solid var(--cfi-ui-focus-color);\n outline-offset: var(--cfi-ui-focus-offset);\n }\n .cfi-ui-sr-only {\n position: absolute;\n width: 1px;\n height: 1px;\n padding: 0;\n margin: -1px;\n overflow: hidden;\n clip-path: inset(50%);\n white-space: nowrap;\n border: 0;\n }\n @keyframes cfi-ui-spin {\n to {\n transform: rotate(360deg);\n }\n }\n @keyframes cfi-ui-pulse {\n 50% {\n opacity: 0.45;\n }\n }\n @keyframes cfi-ui-fade-in {\n from {\n opacity: 0;\n }\n }\n @keyframes cfi-ui-zoom-in {\n from {\n opacity: 0;\n transform: scale(0.97);\n }\n }\n @keyframes cfi-ui-slide-in-right {\n from {\n transform: translateX(100%);\n }\n }\n @keyframes cfi-ui-slide-in-left {\n from {\n transform: translateX(-100%);\n }\n }\n @media (prefers-reduced-motion: reduce) {\n .cfi-ui,\n .cfi-ui *,\n [data-cfi-ui-portal],\n [data-cfi-ui-portal] * {\n animation-duration: 1ms !important;\n animation-iteration-count: 1 !important;\n transition-duration: 1ms !important;\n }\n }\n}\n');
24
+ styleInject('@layer components {\n :where(:root) {\n --cfi-ui-surface: var(--cfi-surface, #ffffff);\n --cfi-ui-surface-raised: var(--cfi-surface-raised, #ffffff);\n --cfi-ui-surface-sunken: var(--cfi-surface-sunken, #f9fafb);\n --cfi-ui-surface-hover: var(--cfi-surface-hover, #f9fafb);\n --cfi-ui-surface-active: var(--cfi-surface-active, #f3f4f6);\n --cfi-ui-surface-selected: var(--cfi-surface-selected, #f3f4f6);\n --cfi-ui-surface-disabled: var(--cfi-surface-disabled, #f3f4f6);\n --cfi-ui-surface-muted: var(--cfi-surface-muted, #ececf0);\n --cfi-ui-input-bg: var(--cfi-input-bg, #f3f3f5);\n --cfi-ui-switch-bg: var(--cfi-switch-bg, #cbced4);\n --cfi-ui-content: var(--cfi-content, oklch(0.145 0 0));\n --cfi-ui-content-muted: var(--cfi-content-muted, #666676);\n --cfi-ui-content-subtle: var(--cfi-content-subtle, #9ca3af);\n --cfi-ui-content-inverse: var(--cfi-content-inverse, #ffffff);\n --cfi-ui-content-disabled: var(--cfi-content-disabled, #9ca3af);\n --cfi-ui-content-link: var(--cfi-content-link, #1d4ed8);\n --cfi-ui-border: var(--cfi-border, #e5e7eb);\n --cfi-ui-border-subtle: var(--cfi-border-subtle, #f3f4f6);\n --cfi-ui-border-strong: var(--cfi-border-strong, #d1d5db);\n --cfi-ui-accent: var(--cfi-action, #030213);\n --cfi-ui-accent-hover: var(--cfi-action-hover, #1a1a2e);\n --cfi-ui-accent-soft: var(--cfi-action-soft, #f3f4f6);\n --cfi-ui-accent-fg: var(--cfi-action-fg, #ffffff);\n --cfi-ui-success: var(--cfi-color-success, #10b981);\n --cfi-ui-success-soft: var(--cfi-color-success-soft, #ecfdf5);\n --cfi-ui-success-text: var(--cfi-color-success-text, #047857);\n --cfi-ui-success-border: var(--cfi-color-success-border, #a7f3d0);\n --cfi-ui-warning: var(--cfi-color-warning, #f59e0b);\n --cfi-ui-warning-soft: var(--cfi-color-warning-soft, #fffbeb);\n --cfi-ui-warning-text: var(--cfi-color-warning-text, #b45309);\n --cfi-ui-warning-border: var(--cfi-color-warning-border, #fde68a);\n --cfi-ui-error: var(--cfi-color-error, #ef4444);\n --cfi-ui-error-strong: var(--cfi-color-error-strong, #dc2626);\n --cfi-ui-error-soft: var(--cfi-color-error-soft, #fef2f2);\n --cfi-ui-error-text: var(--cfi-color-error-text, #b91c1c);\n --cfi-ui-error-border: var(--cfi-color-error-border, #fecaca);\n --cfi-ui-info: var(--cfi-color-info, #3b82f6);\n --cfi-ui-info-soft: var(--cfi-color-info-soft, #eff6ff);\n --cfi-ui-info-text: var(--cfi-color-info-text, #1d4ed8);\n --cfi-ui-info-border: var(--cfi-color-info-border, #bfdbfe);\n --cfi-ui-neutral-soft: var(--cfi-color-neutral-soft, #f3f4f6);\n --cfi-ui-neutral-text: var(--cfi-color-neutral-text, #374151);\n --cfi-ui-neutral-border: var(--cfi-color-neutral-border, #e5e7eb);\n --cfi-ui-focus-color: var(--cfi-focus-ring-color, var(--cfi-ui-accent));\n --cfi-ui-focus-width: var(--cfi-focus-ring-width, 2px);\n --cfi-ui-focus-offset: var(--cfi-focus-ring-offset, 2px);\n --cfi-ui-overlay: var(--cfi-overlay, rgb(17 24 39 / 0.45));\n --cfi-ui-font: var(--cfi-font-family, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif);\n --cfi-ui-font-mono: var(--cfi-font-family-mono, ui-monospace, Consolas, monospace);\n --cfi-ui-text-xs: var(--cfi-font-size-xs, 0.75rem);\n --cfi-ui-text-sm: var(--cfi-font-size-sm, 0.875rem);\n --cfi-ui-text-base: var(--cfi-font-size-base, 1rem);\n --cfi-ui-text-lg: var(--cfi-font-size-lg, 1.125rem);\n --cfi-ui-text-xl: var(--cfi-font-size-xl, 1.25rem);\n --cfi-ui-weight-normal: var(--cfi-font-weight-normal, 400);\n --cfi-ui-weight-medium: var(--cfi-font-weight-medium, 500);\n --cfi-ui-weight-semibold: var(--cfi-font-weight-semibold, 600);\n --cfi-ui-leading-tight: var(--cfi-line-height-tight, 1.2);\n --cfi-ui-leading-snug: var(--cfi-line-height-snug, 1.35);\n --cfi-ui-leading-normal: var(--cfi-line-height-normal, 1.5);\n --cfi-ui-tracking-caps: var(--cfi-letter-spacing-caps, 0.08em);\n --cfi-ui-space-3xs: var(--cfi-spacing-3xs, 0.125rem);\n --cfi-ui-space-2xs: var(--cfi-spacing-2xs, 0.1875rem);\n --cfi-ui-space-xs: var(--cfi-spacing-xs, 0.25rem);\n --cfi-ui-space-sm: var(--cfi-spacing-sm, 0.5rem);\n --cfi-ui-space-md: var(--cfi-spacing-md, 1rem);\n --cfi-ui-space-lg: var(--cfi-spacing-lg, 1.5rem);\n --cfi-ui-space-xl: var(--cfi-spacing-xl, 2rem);\n --cfi-ui-radius-sm: var(--cfi-radius-sm, 0.375rem);\n --cfi-ui-radius-md: var(--cfi-radius-md, 0.5rem);\n --cfi-ui-radius-lg: var(--cfi-radius-lg, 0.625rem);\n --cfi-ui-radius-xl: var(--cfi-radius-xl, 0.875rem);\n --cfi-ui-radius-full: var(--cfi-radius-full, 9999px);\n --cfi-ui-shadow-xs: var(--cfi-shadow-xs, 0 1px 1px rgb(17 24 39 / 0.04));\n --cfi-ui-shadow-sm: var(--cfi-shadow-sm, 0 1px 2px rgb(17 24 39 / 0.05));\n --cfi-ui-shadow-md: var(--cfi-shadow-md, 0 4px 6px rgb(17 24 39 / 0.07));\n --cfi-ui-shadow-lg: var(--cfi-shadow-lg, 0 10px 15px rgb(17 24 39 / 0.1));\n --cfi-ui-shadow-xl: var(--cfi-shadow-xl, 0 20px 40px -12px rgb(17 24 39 / 0.22));\n --cfi-ui-transition-fast: var(--cfi-transition-fast, 120ms cubic-bezier(0.4, 0, 0.2, 1));\n --cfi-ui-transition-base: var(--cfi-transition-base, 200ms cubic-bezier(0.4, 0, 0.2, 1));\n --cfi-ui-z-dropdown: max(var(--cfi-z-dropdown, 1350), calc(var(--cfi-z-popover, 1300) + 50));\n --cfi-ui-z-overlay: var(--cfi-z-overlay, 1100);\n --cfi-ui-z-modal: var(--cfi-z-modal, 1200);\n --cfi-ui-z-popover: var(--cfi-z-popover, 1300);\n --cfi-ui-z-toast: var(--cfi-z-toast, 1400);\n --cfi-ui-z-tooltip: var(--cfi-z-tooltip, 1500);\n --cfi-ui-control-height-sm: 2rem;\n --cfi-ui-control-height-md: 2.25rem;\n --cfi-ui-control-height-lg: 2.75rem;\n }\n .cfi-ui {\n box-sizing: border-box;\n font-family: var(--cfi-ui-font);\n }\n .cfi-ui *,\n .cfi-ui *::before,\n .cfi-ui *::after {\n box-sizing: border-box;\n }\n .cfi-ui:focus-visible,\n .cfi-ui :focus-visible {\n outline: var(--cfi-ui-focus-width) solid var(--cfi-ui-focus-color);\n outline-offset: var(--cfi-ui-focus-offset);\n }\n .cfi-ui-sr-only {\n position: absolute;\n width: 1px;\n height: 1px;\n padding: 0;\n margin: -1px;\n overflow: hidden;\n clip-path: inset(50%);\n white-space: nowrap;\n border: 0;\n }\n @keyframes cfi-ui-spin {\n to {\n transform: rotate(360deg);\n }\n }\n @keyframes cfi-ui-pulse {\n 50% {\n opacity: 0.45;\n }\n }\n @keyframes cfi-ui-fade-in {\n from {\n opacity: 0;\n }\n }\n @keyframes cfi-ui-zoom-in {\n from {\n opacity: 0;\n transform: scale(0.97);\n }\n }\n @keyframes cfi-ui-slide-in-right {\n from {\n transform: translateX(100%);\n }\n }\n @keyframes cfi-ui-slide-in-left {\n from {\n transform: translateX(-100%);\n }\n }\n @media (prefers-reduced-motion: reduce) {\n .cfi-ui,\n .cfi-ui *,\n [data-cfi-ui-portal],\n [data-cfi-ui-portal] * {\n animation-duration: 1ms !important;\n animation-iteration-count: 1 !important;\n transition-duration: 1ms !important;\n }\n }\n}\n');
25
25
 
26
26
  // src/lib/cn.ts
27
27
  function cn(...values) {
@@ -504,6 +504,7 @@ function FilterBar({
504
504
  value,
505
505
  onChange,
506
506
  label = "Filters",
507
+ anyLabel = "Any",
507
508
  clearLabel = "Clear filters",
508
509
  removeLabel = (name) => `Remove the ${name} filter`,
509
510
  className
@@ -550,7 +551,7 @@ function FilterBar({
550
551
  fullWidth: true,
551
552
  "aria-label": s.label,
552
553
  value: typeof value[s.id] === "string" ? value[s.id] : void 0,
553
- placeholder: `All ${s.label.toLowerCase()}`,
554
+ placeholder: s.placeholder ?? anyLabel,
554
555
  options: s.options,
555
556
  onValueChange: (v) => set(s.id, v)
556
557
  }
@@ -2040,6 +2041,42 @@ function Pagination({
2040
2041
  ] });
2041
2042
  }
2042
2043
 
2044
+ // src/ListToolbar.css
2045
+ styleInject("@layer components {\n :where(.cfi-ui-list-toolbar) {\n display: flex;\n align-items: center;\n flex-wrap: wrap;\n gap: var(--cfi-ui-space-sm);\n min-width: 0;\n }\n .cfi-ui-list-toolbar__search {\n flex: 1 1 14rem;\n min-width: 0;\n max-width: 22rem;\n }\n .cfi-ui-list-toolbar__filters {\n display: flex;\n align-items: center;\n min-width: 0;\n flex: 0 1 auto;\n }\n .cfi-ui-list-toolbar__actions {\n display: flex;\n align-items: center;\n gap: var(--cfi-ui-space-xs);\n margin-inline-start: auto;\n }\n @media (max-width: 40rem) {\n .cfi-ui-list-toolbar__search,\n .cfi-ui-list-toolbar__filters,\n .cfi-ui-list-toolbar__actions {\n flex: 1 1 100%;\n max-width: none;\n margin-inline-start: 0;\n }\n }\n}\n");
2046
+
2047
+ // src/ListToolbar.tsx
2048
+ import { jsx as jsx26, jsxs as jsxs25 } from "react/jsx-runtime";
2049
+ function ListToolbar({ search, filters, actions, className }) {
2050
+ return /* @__PURE__ */ jsxs25("div", { className: cn("cfi-ui", "cfi-ui-list-toolbar", className), children: [
2051
+ search && /* @__PURE__ */ jsx26("div", { className: "cfi-ui-list-toolbar__search", children: search }),
2052
+ filters && /* @__PURE__ */ jsx26("div", { className: "cfi-ui-list-toolbar__filters", children: filters }),
2053
+ actions && /* @__PURE__ */ jsx26("div", { className: "cfi-ui-list-toolbar__actions", children: actions })
2054
+ ] });
2055
+ }
2056
+
2057
+ // src/usePagedRows.ts
2058
+ import { useEffect as useEffect4, useMemo as useMemo6, useState as useState7 } from "react";
2059
+ function usePagedRows(all, initialPageSize = 25) {
2060
+ const [page, setPage] = useState7(1);
2061
+ const [pageSize, setSize] = useState7(initialPageSize);
2062
+ const totalItems = all.length;
2063
+ const pageCount = Math.max(1, Math.ceil(totalItems / Math.max(1, pageSize)));
2064
+ const clamped = Math.min(Math.max(1, page), pageCount);
2065
+ useEffect4(() => {
2066
+ if (clamped !== page) setPage(clamped);
2067
+ }, [clamped, page]);
2068
+ const rows = useMemo6(
2069
+ () => all.slice((clamped - 1) * pageSize, clamped * pageSize),
2070
+ [all, clamped, pageSize]
2071
+ );
2072
+ const setPageSize = (size) => {
2073
+ const firstRow = (clamped - 1) * pageSize;
2074
+ setSize(size);
2075
+ setPage(Math.floor(firstRow / Math.max(1, size)) + 1);
2076
+ };
2077
+ return { rows, page: clamped, setPage, pageSize, setPageSize, totalItems };
2078
+ }
2079
+
2043
2080
  // src/format.ts
2044
2081
  function formatDate(value) {
2045
2082
  const d = toDate(value);
@@ -2121,6 +2158,7 @@ export {
2121
2158
  IconButton,
2122
2159
  Input,
2123
2160
  Label3 as Label,
2161
+ ListToolbar,
2124
2162
  LoadingState,
2125
2163
  Menu,
2126
2164
  MenuCheckboxItem,
@@ -2204,6 +2242,7 @@ export {
2204
2242
  formatNumber,
2205
2243
  formatPercent,
2206
2244
  useFieldControl,
2245
+ usePagedRows,
2207
2246
  useSortState,
2208
2247
  useToast
2209
2248
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@campfire-interactive/ui",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Shared UI primitives for the Campfire Suite — Radix behavior, plain CSS styled entirely from the design tokens, no Tailwind required",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",