@podoba/react 0.0.26 → 0.0.28

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": "@podoba/react",
3
- "version": "0.0.26",
3
+ "version": "0.0.28",
4
4
  "type": "module",
5
5
  "description": "podoba React components — React Aria Components + Tailwind primitives + layout, built with uic.",
6
6
  "repository": {
@@ -25,8 +25,8 @@
25
25
  "typecheck": "tsc --build"
26
26
  },
27
27
  "dependencies": {
28
- "@podoba/tokens": "^0.0.26",
29
- "@podoba/tailwind": "^0.0.26",
28
+ "@podoba/tokens": "^0.0.28",
29
+ "@podoba/tailwind": "^0.0.28",
30
30
  "react-aria-components": "1.18.0",
31
31
  "class-variance-authority": "0.7.1",
32
32
  "clsx": "2.1.1",
@@ -95,6 +95,34 @@ interface ContextMenuWrapperProps {
95
95
  className?: string
96
96
  }
97
97
 
98
+ /**
99
+ * Lock page scroll while the menu is open — an open action menu should pin the view
100
+ * (gs `ContextActionPanel` behaviour), not let the content scroll out from under the
101
+ * cursor-anchored panel. Wheel + touch scrolling is prevented everywhere EXCEPT inside
102
+ * `allowRef` (the panel itself, so a long/overflowing menu still scrolls). No dep on
103
+ * react-aria's `usePreventScroll` (not a direct dependency here).
104
+ */
105
+ function useScrollLock(active: boolean, allowRef: React.RefObject<HTMLElement | null>): void {
106
+ useEffect(() => {
107
+ if (!active) {
108
+ return
109
+ }
110
+ const prevent = (e: Event) => {
111
+ const target = e.target as Node | null
112
+ if (target && allowRef.current?.contains(target)) {
113
+ return
114
+ }
115
+ e.preventDefault()
116
+ }
117
+ document.addEventListener('wheel', prevent, { passive: false })
118
+ document.addEventListener('touchmove', prevent, { passive: false })
119
+ return () => {
120
+ document.removeEventListener('wheel', prevent)
121
+ document.removeEventListener('touchmove', prevent)
122
+ }
123
+ }, [active, allowRef])
124
+ }
125
+
98
126
  const itemId = (item: ContextMenuItem, fallback: number): string => item.id ?? item.key ?? String(fallback)
99
127
  const groupId = (group: ContextMenuGroup, fallback: number): string => group.id ?? group.key ?? String(fallback)
100
128
 
@@ -202,7 +230,10 @@ function ControlledContextMenu({
202
230
  className,
203
231
  }: ContextMenuProps): React.JSX.Element | null {
204
232
  const anchorRef = useRef<HTMLSpanElement>(null)
233
+ const popoverRef = useRef<HTMLElement>(null)
205
234
  const visibleGroups = visibleGroupsOf(groups)
235
+ // Lock scroll while open (hook runs unconditionally — before the empty-groups return).
236
+ useScrollLock(isOpen && visibleGroups.length > 0, popoverRef)
206
237
  if (visibleGroups.length === 0) {
207
238
  return null
208
239
  }
@@ -219,6 +250,7 @@ function ControlledContextMenu({
219
250
  />
220
251
  <RACPopover
221
252
  key={`${pos.x}:${pos.y}`}
253
+ ref={popoverRef}
222
254
  isOpen={isOpen}
223
255
  onOpenChange={(open) => {
224
256
  if (!open) {
@@ -315,6 +347,8 @@ function WrapperContextMenu({
315
347
 
316
348
  const sourceGroups = typeof groups === 'function' ? resolved : groups
317
349
  const visibleGroups = visibleGroupsOf(sourceGroups)
350
+ // Lock scroll while the panel is open (still scrollable inside a long menu).
351
+ useScrollLock(isOpen && visibleGroups.length > 0, popoverRef)
318
352
 
319
353
  return (
320
354
  <div ref={wrapperRef} className={className} onContextMenu={onContextMenu}>
@@ -0,0 +1,193 @@
1
+ import { useMemo, useState, type ReactNode } from 'react'
2
+ import { ChevronDownIcon, ChevronUpIcon } from './icons'
3
+
4
+ /**
5
+ * Table — the design-system data table (port of gs-platform's `GSTable`). A light,
6
+ * presentational grid: a header row of (optionally sortable) column labels over a
7
+ * body of rows. Rows can be clickable (whole-row press → `onRowClick`, keyboard
8
+ * accessible). Sorting is CLIENT-SIDE and OPTIONAL: enable it with `enableSorting`
9
+ * and mark the sortable columns; clicking a sortable header cycles asc → desc.
10
+ *
11
+ * Presentational only (hard rule #3): no data fetching, no domain coupling. The
12
+ * caller supplies `columns` (how to render + sort each cell) and `data` (the rows).
13
+ * Styling = Tailwind + design-token CSS vars, matching the rest of podoba.
14
+ */
15
+ export type TableAlign = 'left' | 'right' | 'center'
16
+
17
+ export type TableColumn<Row> = {
18
+ /** Stable column id (also the default sort key). */
19
+ key: string
20
+ /** Header label. */
21
+ header: ReactNode
22
+ /** Cell renderer. Defaults to `String(row[key])` when omitted. */
23
+ render?: (row: Row) => ReactNode
24
+ /** Whether this column participates in sorting (needs `enableSorting` on the table). */
25
+ sortable?: boolean
26
+ /**
27
+ * Value used to sort this column (string / number). Defaults to the raw
28
+ * `row[key]` when the row is an object, else the stringified render output.
29
+ */
30
+ sortValue?: (row: Row) => string | number
31
+ /** Horizontal alignment of the header + cells (default `left`). */
32
+ align?: TableAlign
33
+ /** Optional fixed width (CSS length, e.g. `'12rem'` or `'30%'`). */
34
+ width?: string
35
+ }
36
+
37
+ export type TableProps<Row> = {
38
+ columns: TableColumn<Row>[]
39
+ data: Row[]
40
+ /** Stable per-row key. Defaults to the row index (fine for static lists). */
41
+ getRowKey?: (row: Row, index: number) => string
42
+ /** Enable client-side sorting on `sortable` columns. */
43
+ enableSorting?: boolean
44
+ /** Whole-row press handler — renders rows as interactive (hover + keyboard). */
45
+ onRowClick?: (row: Row) => void
46
+ /** Message shown in place of the body when `data` is empty. */
47
+ emptyMessage?: ReactNode
48
+ /** Accessible name for the table. */
49
+ 'aria-label'?: string
50
+ className?: string
51
+ }
52
+
53
+ const ALIGN_CLASS: Record<TableAlign, string> = {
54
+ left: 'text-left',
55
+ right: 'text-right',
56
+ center: 'text-center',
57
+ }
58
+
59
+ function defaultSortValue<Row>(column: TableColumn<Row>, row: Row): string | number {
60
+ if (column.sortValue) return column.sortValue(row)
61
+ if (row && typeof row === 'object' && column.key in row) {
62
+ const raw = (row as Record<string, unknown>)[column.key]
63
+ if (typeof raw === 'number' || typeof raw === 'string') return raw
64
+ }
65
+ return ''
66
+ }
67
+
68
+ export function Table<Row>({
69
+ columns,
70
+ data,
71
+ getRowKey,
72
+ enableSorting = false,
73
+ onRowClick,
74
+ emptyMessage = 'No rows.',
75
+ className,
76
+ ...aria
77
+ }: TableProps<Row>) {
78
+ const [sort, setSort] = useState<{ key: string; dir: 'asc' | 'desc' } | null>(null)
79
+
80
+ const toggleSort = (key: string) => {
81
+ setSort((prev) => {
82
+ if (prev?.key !== key) return { key, dir: 'asc' }
83
+ return { key, dir: prev.dir === 'asc' ? 'desc' : 'asc' }
84
+ })
85
+ }
86
+
87
+ const sorted = useMemo(() => {
88
+ if (!enableSorting || !sort) return data
89
+ const column = columns.find((c) => c.key === sort.key)
90
+ if (!column) return data
91
+ const dir = sort.dir === 'asc' ? 1 : -1
92
+ // Copy before sort — never mutate the caller's array.
93
+ return [...data].sort((a, b) => {
94
+ const av = defaultSortValue(column, a)
95
+ const bv = defaultSortValue(column, b)
96
+ if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * dir
97
+ return String(av).localeCompare(String(bv), undefined, { numeric: true }) * dir
98
+ })
99
+ }, [data, columns, enableSorting, sort])
100
+
101
+ const rowKey = getRowKey ?? ((_row: Row, index: number) => String(index))
102
+ const interactive = Boolean(onRowClick)
103
+
104
+ return (
105
+ <div className={['w-full overflow-x-auto', className].filter(Boolean).join(' ')}>
106
+ <table className="w-full border-collapse text-small" {...aria}>
107
+ <thead>
108
+ <tr className="border-b border-border">
109
+ {columns.map((column) => {
110
+ const align = column.align ?? 'left'
111
+ const canSort = enableSorting && column.sortable
112
+ const active = sort?.key === column.key
113
+ return (
114
+ <th
115
+ key={column.key}
116
+ scope="col"
117
+ style={column.width ? { width: column.width } : undefined}
118
+ aria-sort={
119
+ canSort ? (active ? (sort?.dir === 'asc' ? 'ascending' : 'descending') : 'none') : undefined
120
+ }
121
+ className={`${ALIGN_CLASS[align]} px-4 py-3 text-label font-medium tracking-wide text-fg-muted uppercase`}
122
+ >
123
+ {canSort ? (
124
+ <button
125
+ type="button"
126
+ onClick={() => toggleSort(column.key)}
127
+ className={`inline-flex items-center gap-1 outline-none transition-colors hover:text-fg focus-visible:text-fg ${
128
+ align === 'right' ? 'flex-row-reverse' : ''
129
+ } ${active ? 'text-fg' : ''}`}
130
+ >
131
+ {column.header}
132
+ {active ? (
133
+ sort?.dir === 'asc' ? (
134
+ <ChevronUpIcon className="h-3.5 w-3.5" />
135
+ ) : (
136
+ <ChevronDownIcon className="h-3.5 w-3.5" />
137
+ )
138
+ ) : null}
139
+ </button>
140
+ ) : (
141
+ column.header
142
+ )}
143
+ </th>
144
+ )
145
+ })}
146
+ </tr>
147
+ </thead>
148
+ <tbody>
149
+ {sorted.length === 0 ? (
150
+ <tr>
151
+ <td colSpan={columns.length} className="px-4 py-10 text-center text-small text-fg-muted">
152
+ {emptyMessage}
153
+ </td>
154
+ </tr>
155
+ ) : (
156
+ sorted.map((row, index) => (
157
+ <tr
158
+ key={rowKey(row, index)}
159
+ {...(interactive
160
+ ? {
161
+ tabIndex: 0,
162
+ role: 'button',
163
+ onClick: () => onRowClick?.(row),
164
+ onKeyDown: (event: React.KeyboardEvent) => {
165
+ if (event.key === 'Enter' || event.key === ' ') {
166
+ event.preventDefault()
167
+ onRowClick?.(row)
168
+ }
169
+ },
170
+ }
171
+ : {})}
172
+ className={`border-b border-border/60 outline-none ${
173
+ interactive
174
+ ? 'cursor-pointer transition-colors hover:bg-surface-muted focus-visible:bg-surface-muted focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring'
175
+ : ''
176
+ }`}
177
+ >
178
+ {columns.map((column) => {
179
+ const align = column.align ?? 'left'
180
+ return (
181
+ <td key={column.key} className={`${ALIGN_CLASS[align]} px-4 py-3 align-middle text-fg`}>
182
+ {column.render ? column.render(row) : String(defaultSortValue(column, row))}
183
+ </td>
184
+ )
185
+ })}
186
+ </tr>
187
+ ))
188
+ )}
189
+ </tbody>
190
+ </table>
191
+ </div>
192
+ )
193
+ }
package/src/index.ts CHANGED
@@ -60,6 +60,7 @@ export * from "./components/icons";
60
60
  export * from "./components/subtle";
61
61
  export * from "./components/stats-card";
62
62
  export * from "./components/tile";
63
+ export * from "./components/table";
63
64
  export * from "./components/dashboard-grid";
64
65
  export * from "./components/task-approval-modal";
65
66
  export * from "./components/request-changes-modal";