@charcuterie/ui 3.28.1 → 3.29.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.
Files changed (48) hide show
  1. package/dist/SearchInput/SearchInput.d.ts +22 -0
  2. package/dist/SearchInput/SearchInput.d.ts.map +1 -0
  3. package/dist/SearchInput/SearchInput.js +36 -0
  4. package/dist/SearchInput/SearchInput.js.map +1 -0
  5. package/dist/VirtualizedGrid/ElementGrid.d.ts +7 -0
  6. package/dist/VirtualizedGrid/ElementGrid.d.ts.map +1 -0
  7. package/dist/VirtualizedGrid/ElementGrid.js +19 -0
  8. package/dist/VirtualizedGrid/ElementGrid.js.map +1 -0
  9. package/dist/VirtualizedGrid/GridRows.d.ts +4 -0
  10. package/dist/VirtualizedGrid/GridRows.d.ts.map +1 -0
  11. package/dist/VirtualizedGrid/GridRows.js +40 -0
  12. package/dist/VirtualizedGrid/GridRows.js.map +1 -0
  13. package/dist/VirtualizedGrid/VirtualizedGrid.d.ts +6 -8
  14. package/dist/VirtualizedGrid/VirtualizedGrid.d.ts.map +1 -1
  15. package/dist/VirtualizedGrid/VirtualizedGrid.js +30 -87
  16. package/dist/VirtualizedGrid/VirtualizedGrid.js.map +1 -1
  17. package/dist/VirtualizedGrid/WindowGrid.d.ts +3 -0
  18. package/dist/VirtualizedGrid/WindowGrid.d.ts.map +1 -0
  19. package/dist/VirtualizedGrid/WindowGrid.js +20 -0
  20. package/dist/VirtualizedGrid/WindowGrid.js.map +1 -0
  21. package/dist/VirtualizedGrid/virtualizedGridScroll.d.ts +25 -0
  22. package/dist/VirtualizedGrid/virtualizedGridScroll.d.ts.map +1 -0
  23. package/dist/VirtualizedGrid/virtualizedGridScroll.js +50 -0
  24. package/dist/VirtualizedGrid/virtualizedGridScroll.js.map +1 -0
  25. package/dist/VirtualizedGrid/virtualizedGridTypes.d.ts +27 -0
  26. package/dist/VirtualizedGrid/virtualizedGridTypes.d.ts.map +1 -0
  27. package/dist/VirtualizedGrid/virtualizedGridTypes.js +2 -0
  28. package/dist/VirtualizedGrid/virtualizedGridTypes.js.map +1 -0
  29. package/dist/index.d.ts +2 -0
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +1 -0
  32. package/dist/index.js.map +1 -1
  33. package/package.json +1 -1
  34. package/src/SearchInput/SearchInput.mdx +15 -0
  35. package/src/SearchInput/SearchInput.stories.tsx +136 -0
  36. package/src/SearchInput/SearchInput.test.tsx +28 -0
  37. package/src/SearchInput/SearchInput.tsx +105 -0
  38. package/src/VirtualizedGrid/ElementGrid.tsx +45 -0
  39. package/src/VirtualizedGrid/GridRows.tsx +90 -0
  40. package/src/VirtualizedGrid/VirtualizedGrid.mdx +9 -8
  41. package/src/VirtualizedGrid/VirtualizedGrid.stories.tsx +26 -0
  42. package/src/VirtualizedGrid/VirtualizedGrid.test.tsx +60 -1
  43. package/src/VirtualizedGrid/VirtualizedGrid.tsx +42 -142
  44. package/src/VirtualizedGrid/WindowGrid.tsx +39 -0
  45. package/src/VirtualizedGrid/virtualizedGridScroll.ts +69 -0
  46. package/src/VirtualizedGrid/virtualizedGridTypes.ts +32 -0
  47. package/src/index.ts +2 -0
  48. package/src/sourceRules.test.ts +1 -1
@@ -1,17 +1,17 @@
1
- import { useWindowVirtualizer } from "@tanstack/react-virtual"
2
1
  import type { ReactNode } from "react"
3
- import {
4
- useCallback,
5
- useLayoutEffect,
6
- useRef,
7
- useState,
8
- } from "react"
2
+ import { useLayoutEffect, useState } from "react"
9
3
 
10
- import { getContentMaxInlineSize } from "../AdaptiveGrid/chooseColumns.ts"
11
4
  import type { BlockSizeResolver } from "../AdaptiveGrid/useAdaptiveColumns.ts"
12
5
  import { useAdaptiveColumns } from "../AdaptiveGrid/useAdaptiveColumns.ts"
13
6
  import { toClassName } from "../toClassName.ts"
7
+ import { ElementGrid } from "./ElementGrid.tsx"
14
8
  import { DEFAULT_GRID_GAP_PX } from "./gridGap.ts"
9
+ import { findScrollOwner } from "./virtualizedGridScroll.ts"
10
+ import type {
11
+ GridWindowProps,
12
+ ScrollOwner,
13
+ } from "./virtualizedGridTypes.ts"
14
+ import { WindowGrid } from "./WindowGrid.tsx"
15
15
 
16
16
  export type VirtualizedGridProps<Item> = {
17
17
  /**
@@ -140,15 +140,13 @@ export type VirtualizedGridProps<Item> = {
140
140
  * idea of the distance from one row to the next and the browser's
141
141
  * the same number.
142
142
  *
143
- * ### Why the window and not a scroll box of its own
143
+ * ### It follows the page's existing scroll owner
144
144
  *
145
- * `useWindowVirtualizer`, so the page scrolls the way every other
146
- * page in the fleet scrolls — the header leaves, the scrollbar is
147
- * the browser's, `Ctrl+End` works. A component that grew its own
148
- * `overflow: auto` well would trap the wheel and strand the page
149
- * header above it. The cost is `scrollMargin`: the virtualizer has
150
- * to be told how far down the document the grid starts, which is
151
- * measured below.
145
+ * A standalone grid follows the browser window. Inside `Shell`, it
146
+ * follows `Main`, which is the shell's one vertical scroll region.
147
+ * It never creates a scroll box of its own. The cost is
148
+ * `scrollMargin`: the virtualizer has to be told how far down its
149
+ * scroll owner the grid starts, which is measured below.
152
150
  *
153
151
  * ### What windowing costs, and what is done about it
154
152
  *
@@ -188,85 +186,38 @@ export const VirtualizedGrid = <Item,>({
188
186
 
189
187
  const drawnColumns = columns ?? layout.columns
190
188
 
191
- // Folded from what is DRAWN rather than read off the hook, so an
192
- // override widens the page cap with it — the same rule
193
- // `AdaptiveGrid` follows, and wrong in the same way if it does
194
- // not.
195
- const contentMaxInlineSize = getContentMaxInlineSize({
196
- columns: drawnColumns,
197
- })
198
-
199
189
  const rowCount = Math.ceil(items.length / drawnColumns)
200
190
 
201
191
  /**
202
- * How far down the document the grid starts.
203
- *
204
- * `useWindowVirtualizer` measures against the document, so
205
- * without this every row is placed as though the grid began at
206
- * the top of the page, and the whole list sits shifted by the
207
- * height of the header above it.
208
- *
209
- * State and not a ref, because it feeds `scrollMargin` and a
210
- * change has to re-render. Measured on every commit rather than
211
- * on a dependency list: a filter row that grows a line moves the
212
- * grid down the page, and nothing in this component's own props
213
- * would report that.
192
+ * Main owns vertical scrolling inside an app Shell. Older pages
193
+ * and a standalone grid still use the window. Resolve the owner
194
+ * from the actual tree rather than making every app wire a ref
195
+ * between two Charcuterie components.
214
196
  */
215
- const gridElement = useRef<HTMLUListElement>(null)
216
- const [scrollMargin, setScrollMargin] = useState(0)
197
+ const [scrollOwner, setScrollOwner] =
198
+ useState<ScrollOwner | null>(null)
217
199
 
218
200
  useLayoutEffect(() => {
219
- const element = gridElement.current
201
+ const element = layout.containerRef.current
220
202
 
221
203
  if (!element) {
222
204
  return
223
205
  }
224
206
 
225
- const next =
226
- element.getBoundingClientRect().top + window.scrollY
227
-
228
- setScrollMargin((current) =>
229
- current === next ? current : next,
230
- )
231
- })
207
+ setScrollOwner(findScrollOwner(element))
208
+ }, [layout.containerRef])
232
209
 
233
- /**
234
- * A row's height, plus the gap that follows it.
235
- *
236
- * The gap is added here rather than left to the estimate because
237
- * it is real distance the next row starts after: leave it out
238
- * and the virtualizer's offsets drift by one gap per row, which
239
- * on a list of two thousand is a scrollbar off by half a page.
240
- */
241
- const measureRow = useCallback(
242
- (element: Element) =>
243
- element.getBoundingClientRect().height + gap,
244
- [gap],
245
- )
246
-
247
- const rowVirtualizer = useWindowVirtualizer({
248
- count: rowCount,
249
- estimateSize: () => itemBlockSize + gap,
250
- measureElement: measureRow,
251
- overscan: overscanRows,
252
- scrollMargin,
253
- })
254
-
255
- const virtualRows = rowVirtualizer.getVirtualItems()
256
-
257
- const firstRow = virtualRows[0]
258
- const lastRow = virtualRows[virtualRows.length - 1]
259
-
260
- // Everything scrolled past, and everything still to come. The two
261
- // numbers that stand in for the rows that do not exist.
262
- const paddingBlockStart = firstRow?.start ?? 0
263
-
264
- const paddingBlockEnd = lastRow
265
- ? Math.max(
266
- 0,
267
- rowVirtualizer.getTotalSize() - lastRow.end,
268
- )
269
- : 0
210
+ const gridProps: GridWindowProps<Item> = {
211
+ drawnColumns,
212
+ gap,
213
+ getItemKey,
214
+ itemBlockSize,
215
+ items,
216
+ label,
217
+ overscanRows,
218
+ renderItem,
219
+ rowCount,
220
+ }
270
221
 
271
222
  return (
272
223
  <div
@@ -276,65 +227,14 @@ export const VirtualizedGrid = <Item,>({
276
227
  )}
277
228
  ref={layout.containerRef}
278
229
  >
279
- <ul
280
- aria-label={label}
281
- className="grid w-full list-none p-0 [&>*]:min-w-0"
282
- ref={gridElement}
283
- style={{
284
- gap: `${gap}px`,
285
- gridTemplateColumns: `repeat(${drawnColumns}, minmax(0, 1fr))`,
286
- maxInlineSize: contentMaxInlineSize,
287
- // Offset by `scrollMargin` because `start` is measured
288
- // from the top of the document, and this padding is
289
- // measured from the top of the grid.
290
- paddingBlockEnd: `${paddingBlockEnd}px`,
291
- paddingBlockStart: `${Math.max(0, paddingBlockStart - scrollMargin)}px`,
292
- }}
293
- >
294
- {virtualRows.flatMap((row) => {
295
- const firstIndex = row.index * drawnColumns
296
-
297
- return items
298
- .slice(firstIndex, firstIndex + drawnColumns)
299
- .map((item, offset) => {
300
- const index = firstIndex + offset
301
-
302
- return (
303
- <li
304
- aria-posinset={index + 1}
305
- aria-setsize={items.length}
306
- // `data-index` names the ROW, so it goes on
307
- // every cell in it — they all sit in that row
308
- // and the attribute is true of all of them.
309
- //
310
- // Putting it only on the measured cell is the
311
- // version that looks tidier and warns on every
312
- // resize: the virtualizer keeps a
313
- // `ResizeObserver` on whatever it measured and
314
- // re-reads the attribute whenever that node
315
- // changes size. Let the node stop being the
316
- // leading cell — a column count change is all it
317
- // takes — and it is still observed, now with no
318
- // attribute to resolve.
319
- data-index={row.index}
320
- key={getItemKey?.(item, index) ?? index}
321
- // Measured on the leading cell alone. A CSS grid
322
- // stretches every cell to its row's height, so
323
- // one is enough — and measuring all of them
324
- // would have the virtualizer record the same row
325
- // once per column.
326
- ref={
327
- offset === 0
328
- ? rowVirtualizer.measureElement
329
- : undefined
330
- }
331
- >
332
- {renderItem(item, index)}
333
- </li>
334
- )
335
- })
336
- })}
337
- </ul>
230
+ {scrollOwner?.kind === "element" ? (
231
+ <ElementGrid
232
+ {...gridProps}
233
+ scrollOwner={scrollOwner}
234
+ />
235
+ ) : scrollOwner?.kind === "window" ? (
236
+ <WindowGrid {...gridProps} />
237
+ ) : null}
338
238
  </div>
339
239
  )
340
240
  }
@@ -0,0 +1,39 @@
1
+ import { useWindowVirtualizer } from "@tanstack/react-virtual"
2
+ import { useCallback } from "react"
3
+
4
+ import { GridRows } from "./GridRows.tsx"
5
+ import { useScrollMargin } from "./virtualizedGridScroll.ts"
6
+ import type { GridWindowProps } from "./virtualizedGridTypes.ts"
7
+
8
+ export const WindowGrid = <Item,>(
9
+ props: GridWindowProps<Item>,
10
+ ) => {
11
+ const { gridElement, scrollMargin } = useScrollMargin({
12
+ kind: "window",
13
+ })
14
+
15
+ const measureRow = useCallback(
16
+ (element: Element) =>
17
+ element.getBoundingClientRect().height + props.gap,
18
+ [props.gap],
19
+ )
20
+
21
+ const rowVirtualizer = useWindowVirtualizer({
22
+ count: props.rowCount,
23
+ estimateSize: () => props.itemBlockSize + props.gap,
24
+ measureElement: measureRow,
25
+ overscan: props.overscanRows,
26
+ scrollMargin,
27
+ })
28
+
29
+ return (
30
+ <GridRows
31
+ {...props}
32
+ gridElement={gridElement}
33
+ measureElement={rowVirtualizer.measureElement}
34
+ scrollMargin={scrollMargin}
35
+ totalSize={rowVirtualizer.getTotalSize()}
36
+ virtualRows={rowVirtualizer.getVirtualItems()}
37
+ />
38
+ )
39
+ }
@@ -0,0 +1,69 @@
1
+ import { useLayoutEffect, useRef, useState } from "react"
2
+
3
+ import type { ScrollOwner } from "./virtualizedGridTypes.ts"
4
+
5
+ /**
6
+ * The closest ancestor that owns vertical scrolling.
7
+ *
8
+ * The computed value is enough. An `overflow-y:auto` ancestor can
9
+ * be shorter than its contents only AFTER this grid installs its
10
+ * virtual spacer, so requiring `scrollHeight > clientHeight` here
11
+ * would miss the owner on the first commit and create the exact
12
+ * document scrollbar this lookup prevents.
13
+ */
14
+ export const findScrollOwner = (
15
+ element: HTMLElement,
16
+ ): ScrollOwner => {
17
+ let ancestor = element.parentElement
18
+
19
+ while (ancestor) {
20
+ const overflow = getComputedStyle(ancestor).overflowY
21
+
22
+ if (overflow === "auto" || overflow === "scroll") {
23
+ return { element: ancestor, kind: "element" }
24
+ }
25
+
26
+ ancestor = ancestor.parentElement
27
+ }
28
+
29
+ return { kind: "window" }
30
+ }
31
+
32
+ /**
33
+ * The offset from the scroll owner's content origin to the grid.
34
+ *
35
+ * Both formulas stay stable while their owner scrolls: the grid's
36
+ * viewport-relative top falls by the same amount that `scrollY` or
37
+ * `scrollTop` rises. The element path subtracts its border because
38
+ * a scroll offset starts at the padding edge, not at the outer
39
+ * border returned by `getBoundingClientRect()`.
40
+ */
41
+ export const useScrollMargin = (
42
+ scrollOwner: ScrollOwner,
43
+ ) => {
44
+ const gridElement = useRef<HTMLUListElement>(null)
45
+ const [scrollMargin, setScrollMargin] = useState(0)
46
+
47
+ useLayoutEffect(() => {
48
+ const element = gridElement.current
49
+
50
+ if (!element) {
51
+ return
52
+ }
53
+
54
+ const gridTop = element.getBoundingClientRect().top
55
+ const next =
56
+ scrollOwner.kind === "window"
57
+ ? gridTop + window.scrollY
58
+ : gridTop -
59
+ scrollOwner.element.getBoundingClientRect().top -
60
+ scrollOwner.element.clientTop +
61
+ scrollOwner.element.scrollTop
62
+
63
+ setScrollMargin((current) =>
64
+ current === next ? current : next,
65
+ )
66
+ })
67
+
68
+ return { gridElement, scrollMargin }
69
+ }
@@ -0,0 +1,32 @@
1
+ import type { VirtualItem } from "@tanstack/react-virtual"
2
+ import type { ReactNode, RefObject } from "react"
3
+
4
+ export type ScrollOwner =
5
+ | { kind: "element"; element: HTMLElement }
6
+ | { kind: "window" }
7
+
8
+ export type GridWindowProps<Item> = {
9
+ drawnColumns: number
10
+ gap: number
11
+ getItemKey?: (
12
+ item: Item,
13
+ index: number,
14
+ ) => string | number
15
+ itemBlockSize: number
16
+ items: readonly Item[]
17
+ label?: string
18
+ overscanRows: number
19
+ renderItem: (item: Item, index: number) => ReactNode
20
+ rowCount: number
21
+ }
22
+
23
+ export type GridRowsProps<Item> = Omit<
24
+ GridWindowProps<Item>,
25
+ "itemBlockSize" | "overscanRows" | "rowCount"
26
+ > & {
27
+ gridElement: RefObject<HTMLUListElement | null>
28
+ measureElement: (element: Element | null) => void
29
+ scrollMargin: number
30
+ totalSize: number
31
+ virtualRows: VirtualItem[]
32
+ }
package/src/index.ts CHANGED
@@ -360,6 +360,8 @@ export type {
360
360
  RouterLinkProps,
361
361
  } from "./RouterLink/routerLink.ts"
362
362
  export { getIsRoutedHref } from "./RouterLink/routerLink.ts"
363
+ export type { SearchInputProps } from "./SearchInput/SearchInput.tsx"
364
+ export { SearchInput } from "./SearchInput/SearchInput.tsx"
363
365
  export type {
364
366
  SegmentedControlProps,
365
367
  SegmentedItem,
@@ -537,7 +537,7 @@ test("the barrel is the only place components are re-exported", async () => {
537
537
  // active-path rule an app's tests want to assert directly, and
538
538
  // `useNavLayout.ts`, because the app has to read the width rule to
539
539
  // know which corner to render into.
540
- expect(componentNames.length).toBe(60)
540
+ expect(componentNames.length).toBe(61)
541
541
 
542
542
  for (const name of componentNames) {
543
543
  expect(barrel).toContain(`export { ${name} }`)