@lovett/ui 0.0.2 → 0.0.4

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": "@lovett/ui",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "restricted"
package/src/badge.tsx CHANGED
@@ -17,6 +17,8 @@
17
17
 
18
18
  import type { ReactNode } from 'react'
19
19
 
20
+ import { cn } from './lib/utils'
21
+
20
22
  export type BadgeTone =
21
23
  | 'neutral'
22
24
  | 'accent'
@@ -57,33 +59,51 @@ export interface BadgeProps {
57
59
  children: ReactNode
58
60
  /** Optional leading icon — sized 12-14px expected. */
59
61
  icon?: ReactNode
60
- /** Size variant. `sm` = compact (10px text), `md` = default (11px). */
61
- size?: 'sm' | 'md'
62
- /** Optional className override on the outer span. */
62
+ /** Leading status dot in the tone color (gray-ui chip style). Ignored
63
+ * when `icon` is provided. */
64
+ dot?: boolean
65
+ /** Size variant. `sm` = compact (10px), `md` = default (11px),
66
+ * `lg` = roomier category-chip (12px, taller). */
67
+ size?: 'sm' | 'md' | 'lg'
68
+ /** Extra classes merged onto the outer span (no longer replaces base). */
63
69
  className?: string
64
70
  }
65
71
 
72
+ const SIZE_CLASS: Record<NonNullable<BadgeProps['size']>, string> = {
73
+ sm: 'text-[10px] px-1.5 py-0.5',
74
+ md: 'text-[11px] px-2 py-0.5',
75
+ lg: 'text-xs px-2.5 py-1',
76
+ }
77
+
66
78
  export function Badge({
67
79
  tone,
68
80
  children,
69
81
  icon,
82
+ dot = false,
70
83
  size = 'md',
71
84
  className,
72
85
  }: BadgeProps) {
73
86
  const { bg, color } = TONE_STYLE[tone]
74
- const sizeClass = size === 'sm' ? 'text-[10px] px-1.5 py-0.5' : 'text-[11px] px-2 py-0.5'
75
87
  return (
76
88
  <span
77
- className={
78
- className ??
79
- `inline-flex items-center gap-1 font-medium ${sizeClass}`
80
- }
89
+ className={cn(
90
+ 'inline-flex w-fit items-center gap-1.5 font-medium whitespace-nowrap',
91
+ SIZE_CLASS[size],
92
+ className,
93
+ )}
81
94
  style={{
82
95
  background: bg,
83
96
  color,
84
97
  borderRadius: 'var(--radius-full)',
85
98
  }}
86
99
  >
100
+ {dot && !icon ? (
101
+ <span
102
+ aria-hidden
103
+ className="size-1.5 shrink-0 rounded-full"
104
+ style={{ background: 'currentColor' }}
105
+ />
106
+ ) : null}
87
107
  {icon && <span className="flex-shrink-0">{icon}</span>}
88
108
  {children}
89
109
  </span>
@@ -415,6 +415,7 @@ export function DataGrid<Row extends DataGridRowBase, ColumnId extends string>({
415
415
  stickySummaryFooter={stickySummaryFooter}
416
416
  stickyHeader={stickyHeader}
417
417
  fillWidth={fillWidth}
418
+ fillHeight={fillAvailableHeight}
418
419
  scrollEdges={scrollEdges}
419
420
  tableContainerClassName={tableContainerClassName}
420
421
  isEmptyValue={isEmptyValue}
@@ -10,6 +10,7 @@
10
10
  */
11
11
 
12
12
  export { DataGrid } from './data-grid'
13
+ export { DataGridToolbar, type DataGridToolbarProps } from './toolbar'
13
14
  export { DataGridColumnOptionsMenu } from './column-options-menu'
14
15
  export { DrawerPanel } from './drawer-panel'
15
16
  export {
@@ -15,6 +15,7 @@
15
15
  * local table-elements (inlined per DEV-1)
16
16
  */
17
17
 
18
+ import { useRef } from 'react'
18
19
  import type {
19
20
  CSSProperties,
20
21
  KeyboardEvent as ReactKeyboardEvent,
@@ -138,6 +139,10 @@ export function SortableHeaderCell<ColumnId extends string>({
138
139
  }: SortableHeaderCellProps<ColumnId>) {
139
140
  // ADR-027 — column sortability: explicit `sortable: false` opts out;
140
141
  // missing `onSortChange` also disables (no parent handler).
142
+ // Suppress the sort click that the browser fires after a resize
143
+ // interaction (drag OR a stray click on the resize handle), so resizing
144
+ // never accidentally toggles the sort.
145
+ const justResizedRef = useRef(false)
141
146
  const isSortable =
142
147
  column.sortable !== false && typeof onSortChange === 'function'
143
148
  const ariaSort: 'ascending' | 'descending' | 'none' = !isSortable
@@ -150,9 +155,28 @@ export function SortableHeaderCell<ColumnId extends string>({
150
155
 
151
156
  function handleSortClick() {
152
157
  if (!isSortable) return
158
+ if (justResizedRef.current) return
153
159
  onSortChange!(column.id)
154
160
  }
155
161
 
162
+ function handleResizePointerDown(event: ReactPointerEvent<HTMLButtonElement>) {
163
+ // Don't bubble the resize-pointer-down up to the th's onClick sort
164
+ // handler — resize is its own interaction.
165
+ event.stopPropagation()
166
+ onResize(event, column.id)
167
+ const onUp = () => {
168
+ window.removeEventListener('pointerup', onUp)
169
+ justResizedRef.current = true
170
+ // Clear after the click that fires on this same pointerup has been
171
+ // dispatched (a later macrotask), so that click is swallowed but
172
+ // genuine subsequent clicks still sort.
173
+ window.setTimeout(() => {
174
+ justResizedRef.current = false
175
+ }, 0)
176
+ }
177
+ window.addEventListener('pointerup', onUp)
178
+ }
179
+
156
180
  function handleSortKey(event: ReactKeyboardEvent<HTMLDivElement>) {
157
181
  if (!isSortable) return
158
182
  if (event.key === 'Enter' || event.key === ' ') {
@@ -209,12 +233,8 @@ export function SortableHeaderCell<ColumnId extends string>({
209
233
  aria-label={`Resize ${column.label} column`}
210
234
  className="absolute top-0 right-0 flex h-full w-3 cursor-col-resize items-center justify-center transition-colors"
211
235
  style={{ color: 'rgb(var(--text-muted))' }}
212
- onPointerDown={(event) => {
213
- // Don't bubble the resize-pointer-down up to the th's onClick
214
- // sort handler — resize is its own interaction.
215
- event.stopPropagation()
216
- onResize(event, column.id)
217
- }}
236
+ onPointerDown={handleResizePointerDown}
237
+ onClick={(event) => event.stopPropagation()}
218
238
  />
219
239
  </TableHead>
220
240
  )
@@ -98,10 +98,11 @@ export function splitAtRightPinned<ColumnId extends string>(
98
98
 
99
99
  type Role = 'header' | 'body' | 'footer'
100
100
 
101
- /** Opaque background. Header composites its translucent tint over an
102
- * opaque base so it still reads tinted but never lets content through. */
101
+ /** Opaque background. Header + footer composite their translucent tint
102
+ * over an opaque base so they read as a light-gray band but never let
103
+ * content through. Body pinned cells stay plain card-colored. */
103
104
  function opaqueBg(role: Role): CSSProperties {
104
- if (role === 'header') {
105
+ if (role === 'header' || role === 'footer') {
105
106
  return {
106
107
  backgroundColor: 'rgb(var(--bg-card))',
107
108
  backgroundImage:
@@ -79,6 +79,7 @@ type DataGridTableBodyProps<
79
79
  pinOffsets: Map<ColumnId, PinInfo>
80
80
  scrollEdges: ScrollEdges
81
81
  fillWidth: boolean
82
+ fillHeight: boolean
82
83
  }
83
84
 
84
85
  export function DataGridTableBody<
@@ -107,6 +108,7 @@ export function DataGridTableBody<
107
108
  pinOffsets,
108
109
  scrollEdges,
109
110
  fillWidth,
111
+ fillHeight,
110
112
  }: DataGridTableBodyProps<Row, ColumnId>) {
111
113
  const { leading, rightPinned } = splitAtRightPinned(visibleColumns)
112
114
 
@@ -123,6 +125,7 @@ export function DataGridTableBody<
123
125
  rowIndex: number,
124
126
  column: DataGridColumn<ColumnId>,
125
127
  colIndex: number,
128
+ noRightBorder = false,
126
129
  ) => {
127
130
  const isEditing =
128
131
  editingCell?.rowId === row.id && editingCell.columnId === column.id
@@ -151,7 +154,9 @@ export function DataGridTableBody<
151
154
  style={{
152
155
  width: columnWidths[column.id],
153
156
  minWidth: columnWidths[column.id],
154
- borderRight: '1px solid rgb(var(--border))',
157
+ // The rightmost pinned cell drops its right border — the table
158
+ // container's own border already draws that edge (no double line).
159
+ borderRight: noRightBorder ? undefined : '1px solid rgb(var(--border))',
155
160
  ...sticky,
156
161
  ...(isDraggingThisColumn
157
162
  ? { background: 'rgb(var(--surface-overlay-soft))' }
@@ -276,10 +281,23 @@ export function DataGridTableBody<
276
281
  <td aria-hidden style={{ width: 'auto', padding: 0 }} />
277
282
  ) : null}
278
283
  {rightPinned.map((column, i) =>
279
- renderDataCell(row, rowIndex, column, leading.length + i),
284
+ renderDataCell(
285
+ row,
286
+ rowIndex,
287
+ column,
288
+ leading.length + i,
289
+ i === rightPinned.length - 1,
290
+ ),
280
291
  )}
281
292
  </TableRow>
282
293
  ))}
294
+ {fillHeight ? (
295
+ // Filler row absorbs the leftover height (table is height:100%) so
296
+ // the summary footer stays pinned to the bottom even with few rows.
297
+ <tr aria-hidden style={{ height: '100%' }}>
298
+ <td style={{ padding: 0, border: 0 }} colSpan={visibleColumns.length + 2} />
299
+ </tr>
300
+ ) : null}
283
301
  </TableBody>
284
302
  )
285
303
  }
@@ -34,6 +34,12 @@ export const Table = forwardRef<
34
34
  style={{
35
35
  border: '1px solid rgb(var(--border))',
36
36
  borderRadius: 'var(--radius-lg)',
37
+ // NOTE: intentionally NOT setting scrollbar-width / scrollbar-color.
38
+ // On macOS, any custom scrollbar property opts the element out of the
39
+ // platform's auto-hiding *overlay* scrollbars into *classic*
40
+ // always-visible ones — which left the vertical bar permanently
41
+ // covering the pinned action column. Native overlay bars are already
42
+ // thin + light AND auto-hide, so we leave them untouched.
37
43
  }}
38
44
  >
39
45
  <table
@@ -89,7 +89,10 @@ export function DataGridTableHeader<ColumnId extends string>({
89
89
  stickyFooter: false,
90
90
  })
91
91
 
92
- const renderHeadCell = (column: DataGridColumn<ColumnId>) => (
92
+ const renderHeadCell = (
93
+ column: DataGridColumn<ColumnId>,
94
+ noRightBorder = false,
95
+ ) => (
93
96
  <SortableHeaderCell
94
97
  key={column.id}
95
98
  column={column}
@@ -97,7 +100,10 @@ export function DataGridTableHeader<ColumnId extends string>({
97
100
  onResize={onResizeStart}
98
101
  sortDir={sort?.columnId === column.id ? sort.dir : null}
99
102
  onSortChange={onSortChange}
100
- cellStyle={styleFor(column)}
103
+ cellStyle={{
104
+ ...styleFor(column),
105
+ ...(noRightBorder ? { borderRight: 'none' } : null),
106
+ }}
101
107
  />
102
108
  )
103
109
 
@@ -132,11 +138,13 @@ export function DataGridTableHeader<ColumnId extends string>({
132
138
  className="mx-auto"
133
139
  />
134
140
  </TableHead>
135
- {leading.map(renderHeadCell)}
141
+ {leading.map((column) => renderHeadCell(column))}
136
142
  {fillWidth ? (
137
143
  <th aria-hidden style={{ width: 'auto', padding: 0, ...spacerStyle }} />
138
144
  ) : null}
139
- {rightPinned.map(renderHeadCell)}
145
+ {rightPinned.map((column, i) =>
146
+ renderHeadCell(column, i === rightPinned.length - 1),
147
+ )}
140
148
  </TableRow>
141
149
  </TableHeader>
142
150
  </>
@@ -74,7 +74,10 @@ export function DataGridSummaryFooter<
74
74
  stickyFooter: stickySummaryFooter,
75
75
  })
76
76
 
77
- const renderSummaryCell = (column: DataGridColumn<ColumnId>) => {
77
+ const renderSummaryCell = (
78
+ column: DataGridColumn<ColumnId>,
79
+ noRightBorder = false,
80
+ ) => {
78
81
  const summaryContent = renderSummary(column, visibleRows)
79
82
  const isDraggingThisColumn = draggingColumnId === column.id
80
83
 
@@ -86,7 +89,7 @@ export function DataGridSummaryFooter<
86
89
  width: columnWidths[column.id],
87
90
  minWidth: columnWidths[column.id],
88
91
  color: 'rgb(var(--text-muted))',
89
- borderRight: '1px solid rgb(var(--border))',
92
+ borderRight: noRightBorder ? undefined : '1px solid rgb(var(--border))',
90
93
  ...footerStyle(pinOffsets.get(column.id) ?? null),
91
94
  ...(isDraggingThisColumn
92
95
  ? { background: 'rgb(var(--surface-overlay-soft))' }
@@ -110,14 +113,16 @@ export function DataGridSummaryFooter<
110
113
  ...footerStyle({ side: 'left', offset: 0 }),
111
114
  }}
112
115
  />
113
- {leading.map(renderSummaryCell)}
116
+ {leading.map((column) => renderSummaryCell(column))}
114
117
  {fillWidth ? (
115
118
  <td
116
119
  aria-hidden
117
120
  style={{ width: 'auto', padding: 0, ...footerStyle(null) }}
118
121
  />
119
122
  ) : null}
120
- {rightPinned.map(renderSummaryCell)}
123
+ {rightPinned.map((column, i) =>
124
+ renderSummaryCell(column, i === rightPinned.length - 1),
125
+ )}
121
126
  </TableRow>
122
127
  </TableFooter>
123
128
  )
@@ -76,6 +76,7 @@ type DataGridTableViewProps<
76
76
  stickySummaryFooter: boolean
77
77
  stickyHeader: boolean
78
78
  fillWidth: boolean
79
+ fillHeight: boolean
79
80
  scrollEdges: ScrollEdges
80
81
  tableContainerClassName: string | undefined
81
82
  isEmptyValue: (value: ReactNode) => boolean
@@ -120,6 +121,7 @@ export function DataGridTableView<
120
121
  stickySummaryFooter,
121
122
  stickyHeader,
122
123
  fillWidth,
124
+ fillHeight,
123
125
  scrollEdges,
124
126
  tableContainerClassName,
125
127
  isEmptyValue,
@@ -134,7 +136,13 @@ export function DataGridTableView<
134
136
  ref={tableRef}
135
137
  className="table-fixed"
136
138
  containerClassName={tableContainerClassName}
137
- style={{ width: `max(100%, ${gridMinWidth}px)` }}
139
+ style={{
140
+ width: `max(100%, ${gridMinWidth}px)`,
141
+ // When the grid fills its height, stretch the table to the full
142
+ // viewport so the spacer body row can push the summary footer to
143
+ // the bottom even with few rows.
144
+ ...(fillHeight ? { height: '100%' } : null),
145
+ }}
138
146
  >
139
147
  <DataGridTableHeader
140
148
  visibleColumns={visibleColumns}
@@ -174,6 +182,7 @@ export function DataGridTableView<
174
182
  pinOffsets={pinOffsets}
175
183
  scrollEdges={scrollEdges}
176
184
  fillWidth={fillWidth}
185
+ fillHeight={fillHeight}
177
186
  />
178
187
 
179
188
  <DataGridSummaryFooter
@@ -0,0 +1,44 @@
1
+ /**
2
+ * DataGridToolbar — the standard layout band that sits directly above a
3
+ * DataGrid: a search field on the left and filter / column / view
4
+ * controls on the right (gray-ui "table toolbar" pattern). Collapses to
5
+ * a stacked column on narrow viewports.
6
+ *
7
+ * Purely a layout primitive — pass whatever search input + action
8
+ * buttons you like into the slots.
9
+ */
10
+
11
+ import type { ReactNode } from 'react'
12
+
13
+ import { cn } from '../lib/utils'
14
+
15
+ export interface DataGridToolbarProps {
16
+ /** Left slot — typically a <SearchBar>. Stretches up to a comfortable
17
+ * reading width, full-width when stacked. */
18
+ search?: ReactNode
19
+ /** Right slot — filter / columns / view-toggle controls. */
20
+ children?: ReactNode
21
+ className?: string
22
+ }
23
+
24
+ export function DataGridToolbar({
25
+ search,
26
+ children,
27
+ className,
28
+ }: DataGridToolbarProps) {
29
+ return (
30
+ <div
31
+ className={cn(
32
+ 'flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between',
33
+ className,
34
+ )}
35
+ >
36
+ {search ? <div className="w-full sm:max-w-sm">{search}</div> : <span />}
37
+ {children ? (
38
+ <div className="flex flex-wrap items-center gap-2 sm:justify-end">
39
+ {children}
40
+ </div>
41
+ ) : null}
42
+ </div>
43
+ )
44
+ }
@@ -101,7 +101,29 @@ export function useGridColumns<ColumnId extends string>({
101
101
  setVisibleColumnIds((current) => {
102
102
  const kept = current.filter((id) => allowedIds.has(id))
103
103
  const missing = columnOrder.filter((id) => !kept.includes(id))
104
- return [...kept, ...missing]
104
+ if (missing.length === 0) {
105
+ return kept.length === current.length ? current : kept
106
+ }
107
+ // Insert newly-visible columns at their natural position (per the
108
+ // `columns` prop order) instead of appending them at the very end.
109
+ // Appending broke pinned trailing columns (e.g. an actions column
110
+ // pinned right would stop being last once another column was toggled
111
+ // on). Existing columns keep their (possibly drag-reordered) order.
112
+ const orderIndex = (id: ColumnId) => columnOrder.indexOf(id)
113
+ const next = [...kept]
114
+ for (const id of missing) {
115
+ const target = orderIndex(id)
116
+ let insertAt = next.length
117
+ for (let i = 0; i < next.length; i += 1) {
118
+ const cur = next[i]
119
+ if (cur !== undefined && orderIndex(cur) > target) {
120
+ insertAt = i
121
+ break
122
+ }
123
+ }
124
+ next.splice(insertAt, 0, id)
125
+ }
126
+ return next
105
127
  })
106
128
 
107
129
  orderBeforeDragRef.current = columnOrder
package/src/index.ts CHANGED
@@ -109,6 +109,7 @@ export {
109
109
  } from './dropdown-menu'
110
110
  export {
111
111
  DataGrid,
112
+ DataGridToolbar,
112
113
  DataGridColumnOptionsMenu,
113
114
  DataGridDragOverlay,
114
115
  DataGridDropIndicator,
@@ -117,6 +118,7 @@ export {
117
118
  type DataGridProps,
118
119
  type DataGridRowBase,
119
120
  type DataGridSortState,
121
+ type DataGridToolbarProps,
120
122
  type DataGridToolbarRenderProps,
121
123
  type DataGridIcon,
122
124
  type EditingCell,