@charcuterie/ui 3.29.0 → 3.30.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.
@@ -0,0 +1,164 @@
1
+ import { useCallback, useLayoutEffect, useRef } from "react"
2
+
3
+ import {
4
+ recallScrollOffset,
5
+ rememberScrollOffset,
6
+ } from "./scrollMemory.ts"
7
+
8
+ /**
9
+ * How long a restore keeps trying while the content is still
10
+ * arriving.
11
+ *
12
+ * The offset cannot be applied until the scrollport is tall enough
13
+ * to hold it, and on a client-rendered page it is not: the commit
14
+ * that changes the route draws a skeleton, and the rows land a
15
+ * fetch later. So the restore re-applies as the content grows, and
16
+ * gives up after this. A page that takes longer than three seconds
17
+ * to fill has already been read from the top, and moving it then
18
+ * is a worse answer than leaving it where the reader put it.
19
+ */
20
+ const RESTORE_WINDOW_MS = 3000
21
+
22
+ /** A scroll the reader asked for. It ends the restore at once. */
23
+ const READER_INTENT_EVENTS = [
24
+ "keydown",
25
+ "pointerdown",
26
+ "touchstart",
27
+ "wheel",
28
+ ] as const
29
+
30
+ /**
31
+ * Remember where this history entry was scrolled to, and put it
32
+ * back when the reader returns to it.
33
+ *
34
+ * `scrollMemory.ts` says why the browser will not. This is the
35
+ * part that has to survive the two things a real page does:
36
+ *
37
+ * 1. **The content arrives late.** An offset applied against a
38
+ * 600px skeleton clamps to `0` and looks like it did nothing.
39
+ * A `ResizeObserver` on the scrollport and on its content
40
+ * column re-applies the offset every time either grows, until
41
+ * the offset lands or the window closes.
42
+ * 2. **The reader scrolls during all that.** Any wheel, touch,
43
+ * key or pointer press ends the restore immediately. The
44
+ * alternative is a page that jumps out from under somebody who
45
+ * has already started reading.
46
+ *
47
+ * The save is a passive `scroll` listener, suppressed while a
48
+ * restore is in flight. Without that suppression the feature eats
49
+ * itself: writing `scrollTop` on a still-short page clamps to `0`
50
+ * and fires a `scroll` event reading `0`, which would be saved
51
+ * over the very offset being restored.
52
+ *
53
+ * @param scrollKey Identifies the history entry — react-router's
54
+ * `useLocation().key`. `undefined` turns the memory off, which is
55
+ * what an app with no router gets.
56
+ */
57
+ export const useScrollMemory = (
58
+ scrollKey: string | undefined,
59
+ ) => {
60
+ const elementRef = useRef<HTMLElement | null>(null)
61
+ const isRestoringRef = useRef(false)
62
+
63
+ // A ref rather than a query for the `<main>` id: the effect
64
+ // below runs in the same commit that mounts the node, and only a
65
+ // ref can say which node that was.
66
+ //
67
+ // `unknown` in, narrowed here, so the returned function is both a
68
+ // `RefCallback<HTMLElement>` and something `mergeRefs` accepts —
69
+ // that library's merge is typed for the prop-merging path, where
70
+ // every value is `unknown`.
71
+ const setElement = useCallback((node: unknown) => {
72
+ elementRef.current =
73
+ node instanceof HTMLElement ? node : null
74
+ }, [])
75
+
76
+ useLayoutEffect(() => {
77
+ const element = elementRef.current
78
+
79
+ if (!element || scrollKey === undefined) {
80
+ return undefined
81
+ }
82
+
83
+ const offset = recallScrollOffset(scrollKey)
84
+
85
+ // The arrow defers the read of `applyOffset`, so the three
86
+ // pieces can be declared in the order they depend on each
87
+ // other rather than in the order they are used.
88
+ const observer = new ResizeObserver(() => {
89
+ applyOffset()
90
+ })
91
+
92
+ const deadline = globalThis.setTimeout(() => {
93
+ stopRestoring()
94
+ }, RESTORE_WINDOW_MS)
95
+
96
+ const stopRestoring = () => {
97
+ if (!isRestoringRef.current) {
98
+ return
99
+ }
100
+
101
+ isRestoringRef.current = false
102
+ observer.disconnect()
103
+ globalThis.clearTimeout(deadline)
104
+
105
+ for (const eventName of READER_INTENT_EVENTS) {
106
+ element.removeEventListener(
107
+ eventName,
108
+ stopRestoring,
109
+ )
110
+ }
111
+ }
112
+
113
+ const applyOffset = () => {
114
+ if (!isRestoringRef.current) {
115
+ return
116
+ }
117
+
118
+ element.scrollTop = offset
119
+
120
+ // Landed. Anything further would only fight the reader.
121
+ if (element.scrollTop === offset) {
122
+ stopRestoring()
123
+ }
124
+ }
125
+
126
+ const onScroll = () => {
127
+ if (isRestoringRef.current) {
128
+ return
129
+ }
130
+
131
+ rememberScrollOffset(scrollKey, element.scrollTop)
132
+ }
133
+
134
+ isRestoringRef.current = true
135
+
136
+ for (const eventName of READER_INTENT_EVENTS) {
137
+ element.addEventListener(eventName, stopRestoring, {
138
+ passive: true,
139
+ })
140
+ }
141
+
142
+ // The scrollport's own height changes with the window; its
143
+ // content column's changes when the rows land. Both move the
144
+ // ceiling the offset is clamped against.
145
+ observer.observe(element)
146
+
147
+ for (const child of Array.from(element.children)) {
148
+ observer.observe(child)
149
+ }
150
+
151
+ element.addEventListener("scroll", onScroll, {
152
+ passive: true,
153
+ })
154
+
155
+ applyOffset()
156
+
157
+ return () => {
158
+ stopRestoring()
159
+ element.removeEventListener("scroll", onScroll)
160
+ }
161
+ }, [scrollKey])
162
+
163
+ return setElement
164
+ }
@@ -9,6 +9,9 @@ The search field with a labelled icon button for clearing its value. The app sup
9
9
  glyph and owns the query. The component owns the field skin, the full control-sized target,
10
10
  the native cancel-widget suppression, and focus return after a clear.
11
11
 
12
+ Search fields accept medium and large sizes. They do not offer the small control variant,
13
+ because the clear button is a primary pointer target rather than a compact toolbar action.
14
+
12
15
  Use `Field` for a visible label, or pass `aria-label` when the surrounding layout already
13
16
  names the search.
14
17
 
@@ -25,7 +25,7 @@ const ClearIcon = () => (
25
25
  const ControlledSearch = ({
26
26
  size = "md",
27
27
  }: {
28
- size?: "sm" | "md" | "lg"
28
+ size?: "md" | "lg"
29
29
  }) => {
30
30
  const [value, setValue] = useState("filter paper")
31
31
 
@@ -69,7 +69,7 @@ export const AllVariants: Story = {
69
69
  render: () => (
70
70
  <StorySection title="The field and its clear button share one density-aware control size.">
71
71
  <StoryGrid columns={3}>
72
- {(["sm", "md", "lg"] as const).map((size) => (
72
+ {(["md", "lg"] as const).map((size) => (
73
73
  <StoryCell key={size} label={size}>
74
74
  <ControlledSearch size={size} />
75
75
  </StoryCell>
@@ -12,8 +12,9 @@ import {
12
12
  } from "../intentStyles.ts"
13
13
  import { toClassName } from "../toClassName.ts"
14
14
 
15
- const INPUT_SIZE_CLASS: Record<ControlSize, string> = {
16
- sm: "h-(--control-height-sm) ps-(--control-padding-inline-sm) pe-(--control-height-sm) text-sm",
15
+ export type SearchInputSize = Exclude<ControlSize, "sm">
16
+
17
+ const INPUT_SIZE_CLASS: Record<SearchInputSize, string> = {
17
18
  md: "h-(--control-height-md) ps-(--control-padding-inline-md) pe-(--control-height-md) text-md",
18
19
  lg: "h-(--control-height-lg) ps-(--control-padding-inline-lg) pe-(--control-height-lg) text-lg",
19
20
  }
@@ -28,7 +29,8 @@ export type SearchInputProps = Omit<
28
29
  /** Override value-derived visibility for an uncontrolled input. */
29
30
  isClearVisible?: boolean
30
31
  onClear: () => void
31
- size?: ControlSize
32
+ /** Search and clear targets are never smaller than the medium control size. */
33
+ size?: SearchInputSize
32
34
  }
33
35
 
34
36
  /**