@moloodev/ui 0.5.0 → 0.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moloodev/ui",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "sideEffects": [
6
6
  "**/*.css"
@@ -94,6 +94,7 @@
94
94
  "@shadcn/react": "^0.2.1",
95
95
  "@tabler/icons-react": "^3.46.0",
96
96
  "@tanstack/react-table": "^9.2.4",
97
+ "@tanstack/table-core": "^9.2.4",
97
98
  "class-variance-authority": "^0.7.1",
98
99
  "clsx": "^2.1.1",
99
100
  "cmdk": "^1.1.1",
@@ -122,8 +123,8 @@
122
123
  "tailwindcss": "^4.3.3",
123
124
  "tsx": "^4.23.13",
124
125
  "typescript": "^7.0.2",
125
- "@moloodev/platform": "0.5.0",
126
- "@moloodev/typescript-config": "0.5.0"
126
+ "@moloodev/platform": "0.7.0",
127
+ "@moloodev/typescript-config": "0.7.0"
127
128
  },
128
129
  "publishConfig": {
129
130
  "access": "public"
@@ -95,7 +95,7 @@ export const components: ComponentMeta[] = [
95
95
  { slug: "kind-icon", title: "Kind Icon", description: "Lazily loads any Tabler icon by kebab-case name for asset kinds.", category: "Data Display" },
96
96
  { slug: "kind-mark", title: "Kind Mark", description: "Coloured kind tile with a Tabler glyph. Props match platform kind values.", category: "Data Display" },
97
97
  { slug: "chart", title: "Chart", description: "Charts built with Recharts in the moloo palette.", category: "Data Display" },
98
- { slug: "data-table", title: "Data Table", description: "Headless table from TanStack Table createTableHook, with sorting, row selection, and pagination.", category: "Data Display" },
98
+ { slug: "data-table", title: "Data Table", description: "Headless table from TanStack Table createTableHook, with sorting, row selection, pagination, and per-column size and class names.", category: "Data Display" },
99
99
  { slug: "bubble", title: "Bubble", description: "Chat bubble content with variants for messages.", category: "Data Display" },
100
100
  { slug: "message", title: "Message", description: "Compose chat messages with alignment and metadata.", category: "Data Display" },
101
101
  { slug: "message-scroller", title: "Message Scroller", description: "Scroll container helpers for chat threads.", category: "Data Display" },
@@ -28,11 +28,14 @@ import {
28
28
  sortFn_text,
29
29
  Subscribe,
30
30
  tableFeatures,
31
+ type CellData,
31
32
  type ColumnFiltersState,
32
33
  type PaginationState,
33
34
  type RowData,
34
35
  type RowSelectionState,
35
36
  type SortingState,
37
+ type TableFeature,
38
+ type TableFeatures,
36
39
  type TableState,
37
40
  } from "@tanstack/react-table"
38
41
 
@@ -65,6 +68,7 @@ import {
65
68
  } from "./table"
66
69
  import { toast } from "./toast"
67
70
  import { cn } from "../../lib/utils"
71
+ import { formatRelativeTime } from "../../lib/format-relative-time"
68
72
 
69
73
  export type EnumFilterOption = {
70
74
  value: string
@@ -83,7 +87,39 @@ export type ColumnMetaWithCopyable = {
83
87
  copyable?: boolean
84
88
  }
85
89
 
86
- type DataTableColumnMeta = ColumnMetaWithEnumFilter & ColumnMetaWithCopyable
90
+ export interface ColumnMetaWithLayout {
91
+ className?: string
92
+ headerClassName?: string
93
+ cellClassName?: string
94
+ }
95
+
96
+ declare module "@tanstack/table-core" {
97
+ interface ColumnMeta<
98
+ in out TFeatures extends TableFeatures,
99
+ in out TData extends RowData,
100
+ TValue extends CellData = CellData,
101
+ > extends ColumnMetaWithLayout {}
102
+
103
+ interface Plugins {
104
+ explicitColumnSizeFeature?: TableFeature
105
+ }
106
+
107
+ interface ColumnDef_FeatureMap<
108
+ in out TFeatures extends TableFeatures,
109
+ in out TData extends RowData,
110
+ TValue extends CellData,
111
+ > {
112
+ explicitColumnSizeFeature: { size?: number }
113
+ }
114
+ }
115
+
116
+ type DataTableColumnMeta = ColumnMetaWithEnumFilter &
117
+ ColumnMetaWithCopyable &
118
+ ColumnMetaWithLayout
119
+
120
+ const explicitColumnSizeFeature = {
121
+ getDefaultColumnDef: () => ({}),
122
+ } satisfies TableFeature
87
123
 
88
124
  export function compareValues(av: unknown, bv: unknown): number {
89
125
  if (av == null && bv == null) return 0
@@ -297,19 +333,26 @@ function BadgeCell({
297
333
  return <Badge variant={variant}>{String(value)}</Badge>
298
334
  }
299
335
 
300
- function formatDate(value: unknown, locale?: string) {
301
- if (value == null || value === "") return "—"
302
- const date = value instanceof Date ? value : new Date(String(value))
303
- if (Number.isNaN(date.getTime())) return "—"
304
- return date.toLocaleString(locale)
305
- }
306
-
307
- function DateTime({ locale }: { locale?: string } = {}) {
336
+ function DateTime({
337
+ locale,
338
+ relative = false,
339
+ }: { locale?: string; relative?: boolean } = {}) {
308
340
  const cell = useCellContext()
341
+ const value = cell.getValue()
342
+ const date = value instanceof Date ? value : new Date(String(value))
343
+ if (value == null || value === "" || Number.isNaN(date.getTime())) {
344
+ return <span className="text-muted-foreground tabular-nums">—</span>
345
+ }
346
+ const exact = date.toLocaleString(locale)
309
347
  return (
310
- <span className="text-muted-foreground tabular-nums">
311
- {formatDate(cell.getValue(), locale)}
312
- </span>
348
+ <time
349
+ dateTime={date.toISOString()}
350
+ title={exact}
351
+ suppressHydrationWarning
352
+ className="text-muted-foreground tabular-nums"
353
+ >
354
+ {relative ? formatRelativeTime(date, Date.now(), locale) : exact}
355
+ </time>
313
356
  )
314
357
  }
315
358
 
@@ -550,6 +593,7 @@ function SelectHeader({
550
593
 
551
594
  const features = tableFeatures({
552
595
  columnMeta: metaHelper<DataTableColumnMeta>(),
596
+ explicitColumnSizeFeature,
553
597
  columnFilteringFeature,
554
598
  rowPaginationFeature,
555
599
  rowSelectionFeature,
@@ -593,6 +637,8 @@ const {
593
637
 
594
638
  export const useTable = useAppTable
595
639
  export const createColumnHelper = createAppColumnHelper
640
+ export type ColumnDef<TData extends RowData> =
641
+ Parameters<typeof useTable<TData>>[0]["columns"][number]
596
642
  export { useTableContext, useCellContext, useHeaderContext, appFeatures, features }
597
643
 
598
644
  export type Table<
@@ -609,6 +655,7 @@ type RootProps<TData extends RowData, TSelected> = {
609
655
  loading?: boolean
610
656
  loadingFallback?: React.ReactNode
611
657
  empty?: React.ReactNode
658
+ onRowActivate?: (record: TData, element: HTMLTableRowElement) => void
612
659
  selector?: (state: TableState<typeof features>) => ViewState
613
660
  }
614
661
 
@@ -630,6 +677,30 @@ function reorderManualSortPage<TData extends RowData, TSelected>(
630
677
  })
631
678
  }
632
679
 
680
+ function explicitColumnSize(column: {
681
+ columnDef: { size?: number }
682
+ }): number | undefined {
683
+ const size = column.columnDef.size
684
+ if (typeof size !== "number" || !Number.isFinite(size) || size < 0) return undefined
685
+ return size
686
+ }
687
+
688
+ function columnClassName(
689
+ meta: ColumnMetaWithLayout | undefined,
690
+ part: "header" | "cell",
691
+ ): string | undefined {
692
+ const classes = cn(
693
+ meta?.className,
694
+ part === "header" ? meta?.headerClassName : meta?.cellClassName,
695
+ )
696
+ return classes === "" ? undefined : classes
697
+ }
698
+
699
+ function columnSizeStyle(size: number | undefined): React.CSSProperties | undefined {
700
+ if (size == null) return undefined
701
+ return { width: size, minWidth: size, maxWidth: size }
702
+ }
703
+
633
704
  function columnAriaSort(
634
705
  sorting: SortingState | undefined,
635
706
  columnId: string,
@@ -647,6 +718,7 @@ export function Root<TData extends RowData, TSelected>({
647
718
  loading,
648
719
  loadingFallback,
649
720
  empty = "No results.",
721
+ onRowActivate,
650
722
  selector = (state) => state,
651
723
  }: RootProps<TData, TSelected>) {
652
724
  return (
@@ -654,30 +726,51 @@ export function Root<TData extends RowData, TSelected>({
654
726
  {(state) => {
655
727
  const rows = reorderManualSortPage(table, state.sorting)
656
728
  const showEmpty = !loading && rows.length === 0
729
+ const leaves = table.getAllLeafColumns()
730
+ const sized = leaves.some((column) => explicitColumnSize(column) != null)
657
731
 
658
732
  return (
659
733
  <div data-slot="data-table" className="flex w-full flex-col gap-3">
660
734
  {toolbar}
661
735
  <div className="overflow-hidden rounded-md border-2 border-border">
662
- <Table>
736
+ <Table className={sized ? "table-fixed" : undefined}>
737
+ {sized ? (
738
+ <colgroup>
739
+ {leaves.map((column) => (
740
+ <col
741
+ key={column.id}
742
+ style={columnSizeStyle(explicitColumnSize(column))}
743
+ />
744
+ ))}
745
+ </colgroup>
746
+ ) : null}
663
747
  <TableHeader>
664
748
  {table.getHeaderGroups().map((headerGroup) => (
665
749
  <TableRow key={headerGroup.id}>
666
- {headerGroup.headers.map((header) => (
667
- <table.AppHeader key={header.id} header={header}>
668
- {(bound) => (
669
- <TableHead
670
- aria-sort={
671
- header.column.getCanSort()
672
- ? columnAriaSort(state.sorting, header.column.id)
673
- : undefined
674
- }
675
- >
676
- {header.isPlaceholder ? null : <bound.FlexRender />}
677
- </TableHead>
678
- )}
679
- </table.AppHeader>
680
- ))}
750
+ {headerGroup.headers.map((header) => {
751
+ const leaf = header.column.columns.length === 0
752
+ const size = leaf ? explicitColumnSize(header.column) : undefined
753
+ return (
754
+ <table.AppHeader key={header.id} header={header}>
755
+ {(bound) => (
756
+ <TableHead
757
+ aria-sort={
758
+ header.column.getCanSort()
759
+ ? columnAriaSort(state.sorting, header.column.id)
760
+ : undefined
761
+ }
762
+ className={cn(
763
+ size != null && "overflow-hidden",
764
+ columnClassName(header.column.columnDef.meta, "header"),
765
+ )}
766
+ style={columnSizeStyle(size)}
767
+ >
768
+ {header.isPlaceholder ? null : <bound.FlexRender />}
769
+ </TableHead>
770
+ )}
771
+ </table.AppHeader>
772
+ )
773
+ })}
681
774
  </TableRow>
682
775
  ))}
683
776
  </TableHeader>
@@ -699,16 +792,36 @@ export function Root<TData extends RowData, TSelected>({
699
792
  <TableRow
700
793
  key={row.id}
701
794
  data-state={row.getIsSelected() ? "selected" : undefined}
795
+ tabIndex={onRowActivate ? 0 : undefined}
796
+ className={onRowActivate ? "cursor-pointer focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-ring" : undefined}
797
+ onClick={onRowActivate ? (event) => {
798
+ if ((event.target as Element).closest("button, a, input, select, textarea, label, [role=button], [role=checkbox], [role=menuitem], [data-row-control]")) return
799
+ onRowActivate(row.original, event.currentTarget)
800
+ } : undefined}
801
+ onKeyDown={onRowActivate ? (event) => {
802
+ if (event.target !== event.currentTarget || !["Enter", " "].includes(event.key)) return
803
+ event.preventDefault()
804
+ onRowActivate(row.original, event.currentTarget)
805
+ } : undefined}
702
806
  >
703
- {row.getAllCells().map((cell) => (
704
- <table.AppCell key={cell.id} cell={cell}>
705
- {(bound) => (
706
- <TableCell>
707
- <bound.FlexRender />
708
- </TableCell>
709
- )}
710
- </table.AppCell>
711
- ))}
807
+ {row.getAllCells().map((cell) => {
808
+ const size = explicitColumnSize(cell.column)
809
+ return (
810
+ <table.AppCell key={cell.id} cell={cell}>
811
+ {(bound) => (
812
+ <TableCell
813
+ className={cn(
814
+ size != null && "overflow-hidden",
815
+ columnClassName(cell.column.columnDef.meta, "cell"),
816
+ )}
817
+ style={columnSizeStyle(size)}
818
+ >
819
+ <bound.FlexRender />
820
+ </TableCell>
821
+ )}
822
+ </table.AppCell>
823
+ )
824
+ })}
712
825
  </TableRow>
713
826
  ))
714
827
  )}
package/src/index.ts CHANGED
@@ -91,6 +91,7 @@ export type {
91
91
  ColumnFiltersState,
92
92
  ColumnMetaWithCopyable,
93
93
  ColumnMetaWithEnumFilter,
94
+ ColumnMetaWithLayout,
94
95
  EnumFilterMeta,
95
96
  EnumFilterOption,
96
97
  PaginationState,