@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 +3 -3
- package/src/components/context-menu.tsx +34 -0
- package/src/components/table.tsx +193 -0
- package/src/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@podoba/react",
|
|
3
|
-
"version": "0.0.
|
|
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.
|
|
29
|
-
"@podoba/tailwind": "^0.0.
|
|
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";
|