@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.
- package/dist/Main/Main.d.ts +35 -1
- package/dist/Main/Main.d.ts.map +1 -1
- package/dist/Main/Main.js +38 -3
- package/dist/Main/Main.js.map +1 -1
- package/dist/Main/scrollMemory.d.ts +40 -0
- package/dist/Main/scrollMemory.d.ts.map +1 -0
- package/dist/Main/scrollMemory.js +63 -0
- package/dist/Main/scrollMemory.js.map +1 -0
- package/dist/Main/useScrollMemory.d.ts +29 -0
- package/dist/Main/useScrollMemory.d.ts.map +1 -0
- package/dist/Main/useScrollMemory.js +131 -0
- package/dist/Main/useScrollMemory.js.map +1 -0
- package/dist/SearchInput/SearchInput.d.ts +3 -1
- package/dist/SearchInput/SearchInput.d.ts.map +1 -1
- package/dist/SearchInput/SearchInput.js +0 -1
- package/dist/SearchInput/SearchInput.js.map +1 -1
- package/package.json +1 -1
- package/src/Main/Main.mdx +53 -0
- package/src/Main/Main.stories.tsx +152 -0
- package/src/Main/Main.test.tsx +195 -4
- package/src/Main/Main.tsx +58 -1
- package/src/Main/scrollMemory.test.ts +59 -0
- package/src/Main/scrollMemory.ts +73 -0
- package/src/Main/useScrollMemory.ts +164 -0
- package/src/SearchInput/SearchInput.mdx +3 -0
- package/src/SearchInput/SearchInput.stories.tsx +2 -2
- package/src/SearchInput/SearchInput.tsx +5 -3
|
@@ -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?: "
|
|
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
|
-
{(["
|
|
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
|
-
|
|
16
|
-
|
|
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
|
|
32
|
+
/** Search and clear targets are never smaller than the medium control size. */
|
|
33
|
+
size?: SearchInputSize
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
/**
|