@adamosuiteservices/ui 2.19.3 → 2.20.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/components/layout/sticky-section/index.d.ts +1 -0
- package/dist/components/layout/sticky-section/sticky-section.d.ts +28 -0
- package/dist/components/ui/badge/badge.d.ts +1 -1
- package/dist/hooks/use-element-rect.d.ts +11 -0
- package/dist/sticky-section.cjs +1 -0
- package/dist/sticky-section.js +65 -0
- package/dist/styles.css +1 -1
- package/docs/components/layout/sticky-section.md +248 -0
- package/llm.txt +3 -2
- package/package.json +5 -1
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# Sticky section
|
|
2
|
+
|
|
3
|
+
## Description
|
|
4
|
+
|
|
5
|
+
A **layout utility component** that uses the `IntersectionObserver` API to detect when a section has scrolled past the top of the visible viewport area. It exposes `isSticky` and `topOffset` to children via a React context and a render-prop pattern, allowing any descendant to adapt its appearance once the section "sticks".
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- ✅ Automatic sticky detection via `IntersectionObserver` (no scroll listeners on the sticky element itself)
|
|
10
|
+
- ✅ Render-prop pattern: `children(isSticky, topOffset)`
|
|
11
|
+
- ✅ `useStickySection()` hook for deep descendants
|
|
12
|
+
- ✅ Configurable top offset via `topOffset` (fixed value) or `offsetSelector` (measured from a DOM element)
|
|
13
|
+
- ✅ Controlled mode: bypass internal detection by providing the `isSticky` prop
|
|
14
|
+
- ✅ `onIsStickyChange` callback for side effects without owning the state
|
|
15
|
+
- ✅ Defaults to measuring the library's `[data-slot='sidebar-top-bar']` so it works out of the box with `Sidebar`
|
|
16
|
+
|
|
17
|
+
## Import
|
|
18
|
+
|
|
19
|
+
```typescript
|
|
20
|
+
import {
|
|
21
|
+
StickySection,
|
|
22
|
+
useStickySection,
|
|
23
|
+
type StickySectionProps,
|
|
24
|
+
} from "@adamosuiteservices/ui/sticky-section";
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Basic usage
|
|
28
|
+
|
|
29
|
+
### Render-prop pattern
|
|
30
|
+
|
|
31
|
+
```tsx
|
|
32
|
+
import { StickySection } from "@adamosuiteservices/ui/sticky-section";
|
|
33
|
+
|
|
34
|
+
function PageHeader() {
|
|
35
|
+
return (
|
|
36
|
+
<StickySection>
|
|
37
|
+
{(isSticky, topOffset) => (
|
|
38
|
+
<header
|
|
39
|
+
style={{ top: isSticky ? topOffset : undefined }}
|
|
40
|
+
className={
|
|
41
|
+
isSticky ? "fixed left-0 right-0 shadow-md z-50" : "relative"
|
|
42
|
+
}
|
|
43
|
+
>
|
|
44
|
+
<h1>Page title</h1>
|
|
45
|
+
</header>
|
|
46
|
+
)}
|
|
47
|
+
</StickySection>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Hook pattern (deep consumers)
|
|
53
|
+
|
|
54
|
+
```tsx
|
|
55
|
+
import {
|
|
56
|
+
StickySection,
|
|
57
|
+
useStickySection,
|
|
58
|
+
} from "@adamosuiteservices/ui/sticky-section";
|
|
59
|
+
|
|
60
|
+
function Toolbar() {
|
|
61
|
+
const { isSticky, topOffset } = useStickySection();
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<div
|
|
65
|
+
style={{ top: isSticky ? topOffset : undefined }}
|
|
66
|
+
className={isSticky ? "fixed ..." : ""}
|
|
67
|
+
>
|
|
68
|
+
Actions
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function Section() {
|
|
74
|
+
return (
|
|
75
|
+
<StickySection>
|
|
76
|
+
<Toolbar />
|
|
77
|
+
{/* other children */}
|
|
78
|
+
</StickySection>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Props
|
|
84
|
+
|
|
85
|
+
| Prop | Type | Default | Description |
|
|
86
|
+
| ------------------ | -------------------------------------------------------------------- | --------------------------------- | ---------------------------------------------------------------------------------------------------- |
|
|
87
|
+
| `topOffset` | `number` | — | Fixed top offset in pixels. Skips `offsetSelector` measurement when provided. |
|
|
88
|
+
| `offsetSelector` | `string` | `"[data-slot='sidebar-top-bar']"` | CSS selector of the element whose height is used as the top offset. Ignored when `topOffset` is set. |
|
|
89
|
+
| `isSticky` | `boolean` | — | Controlled sticky state. When provided, internal `IntersectionObserver` detection is bypassed. |
|
|
90
|
+
| `onIsStickyChange` | `(isSticky: boolean, topOffset: number) => void` | — | Called whenever the sticky state changes. |
|
|
91
|
+
| `children` | `ReactNode \| ((isSticky: boolean, topOffset: number) => ReactNode)` | required | ReactNode or a render-prop function. |
|
|
92
|
+
|
|
93
|
+
## `useStickySection()` hook
|
|
94
|
+
|
|
95
|
+
Returns the value of the nearest `StickySection` context.
|
|
96
|
+
|
|
97
|
+
```typescript
|
|
98
|
+
const { isSticky, topOffset } = useStickySection();
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
| Value | Type | Description |
|
|
102
|
+
| ----------- | --------- | ------------------------------------------------------- |
|
|
103
|
+
| `isSticky` | `boolean` | Whether the section is currently stuck to the top. |
|
|
104
|
+
| `topOffset` | `number` | The computed top offset in pixels used by the observer. |
|
|
105
|
+
|
|
106
|
+
Throws if called outside a `StickySection`.
|
|
107
|
+
|
|
108
|
+
## Usage patterns
|
|
109
|
+
|
|
110
|
+
### With the Sidebar component (default behaviour)
|
|
111
|
+
|
|
112
|
+
No configuration needed. `StickySection` automatically measures the height of `[data-slot='sidebar-top-bar']` and applies it as the offset.
|
|
113
|
+
|
|
114
|
+
```tsx
|
|
115
|
+
import { Sidebar, SidebarTopBar } from "@adamosuiteservices/ui/sidebar";
|
|
116
|
+
import { StickySection } from "@adamosuiteservices/ui/sticky-section";
|
|
117
|
+
|
|
118
|
+
function Page() {
|
|
119
|
+
return (
|
|
120
|
+
<Sidebar>
|
|
121
|
+
<SidebarTopBar>…</SidebarTopBar>
|
|
122
|
+
<main>
|
|
123
|
+
<StickySection>
|
|
124
|
+
{(isSticky) => (
|
|
125
|
+
<div className={isSticky ? "sticky top-16 shadow" : ""}>
|
|
126
|
+
Page actions
|
|
127
|
+
</div>
|
|
128
|
+
)}
|
|
129
|
+
</StickySection>
|
|
130
|
+
</main>
|
|
131
|
+
</Sidebar>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### With a custom top bar
|
|
137
|
+
|
|
138
|
+
```tsx
|
|
139
|
+
<div data-slot="main-header" className="sticky top-0 h-20 …">My app header</div>
|
|
140
|
+
|
|
141
|
+
<StickySection offsetSelector="[data-slot='main-header']">
|
|
142
|
+
{(isSticky, topOffset) => (
|
|
143
|
+
<nav style={{ top: isSticky ? topOffset : undefined }} className={isSticky ? "fixed …" : ""}>
|
|
144
|
+
Sub-navigation
|
|
145
|
+
</nav>
|
|
146
|
+
)}
|
|
147
|
+
</StickySection>
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### With a fixed top offset
|
|
151
|
+
|
|
152
|
+
```tsx
|
|
153
|
+
<StickySection topOffset={80}>
|
|
154
|
+
{(isSticky, topOffset) => (
|
|
155
|
+
<div
|
|
156
|
+
style={{ top: isSticky ? topOffset : undefined }}
|
|
157
|
+
className={isSticky ? "fixed …" : ""}
|
|
158
|
+
>
|
|
159
|
+
Actions bar
|
|
160
|
+
</div>
|
|
161
|
+
)}
|
|
162
|
+
</StickySection>
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### Controlled mode
|
|
166
|
+
|
|
167
|
+
```tsx
|
|
168
|
+
const [pinned, setPinned] = useState(false);
|
|
169
|
+
|
|
170
|
+
<StickySection isSticky={pinned} topOffset={64}>
|
|
171
|
+
<ActionBar />
|
|
172
|
+
</StickySection>;
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### Reacting to changes
|
|
176
|
+
|
|
177
|
+
```tsx
|
|
178
|
+
<StickySection
|
|
179
|
+
topOffset={64}
|
|
180
|
+
onIsStickyChange={(isSticky, topOffset) => {
|
|
181
|
+
analytics.track("sticky_change", { isSticky, topOffset });
|
|
182
|
+
}}
|
|
183
|
+
>
|
|
184
|
+
<ActionBar />
|
|
185
|
+
</StickySection>
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## Behavior
|
|
189
|
+
|
|
190
|
+
### Sentinel element
|
|
191
|
+
|
|
192
|
+
`StickySection` places an invisible 1 px `<div>` (`position: absolute; top: 0`) at the very start of its render output. The `IntersectionObserver` watches this sentinel — when it leaves the viewport (factoring in `topOffset` via `rootMargin`), `isSticky` becomes `true`.
|
|
193
|
+
|
|
194
|
+
### Top offset resolution order
|
|
195
|
+
|
|
196
|
+
1. `topOffset` prop (explicit, takes priority)
|
|
197
|
+
2. Height of the element matched by `offsetSelector` (measured via `useElementRect`)
|
|
198
|
+
3. Fallback: `64` px
|
|
199
|
+
|
|
200
|
+
### Controlled vs uncontrolled
|
|
201
|
+
|
|
202
|
+
| Mode | How to activate | Behavior |
|
|
203
|
+
| ------------ | ----------------------- | --------------------------------------------------------------------------------------- |
|
|
204
|
+
| Uncontrolled | Omit `isSticky` prop | `IntersectionObserver` manages state internally. |
|
|
205
|
+
| Controlled | Provide `isSticky` prop | Observer still fires `onIsStickyChange`, but internal `setInternalIsSticky` is skipped. |
|
|
206
|
+
|
|
207
|
+
## Best practices
|
|
208
|
+
|
|
209
|
+
### ✅ DO: Apply `position: fixed` inside the section
|
|
210
|
+
|
|
211
|
+
```tsx
|
|
212
|
+
<StickySection topOffset={64}>
|
|
213
|
+
{(isSticky, topOffset) => (
|
|
214
|
+
<div
|
|
215
|
+
style={{ top: isSticky ? topOffset : undefined }}
|
|
216
|
+
className={isSticky ? "fixed left-0 right-0 shadow z-50" : "relative"}
|
|
217
|
+
>
|
|
218
|
+
Toolbar
|
|
219
|
+
</div>
|
|
220
|
+
)}
|
|
221
|
+
</StickySection>
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
### ❌ DON'T: Nest multiple `StickySection` components with conflicting selectors
|
|
225
|
+
|
|
226
|
+
Each `StickySection` creates its own sentinel and observer. Nesting without explicit `topOffset`
|
|
227
|
+
values can produce unexpected offsets. Prefer sibling sections or set explicit `topOffset` on nested ones.
|
|
228
|
+
|
|
229
|
+
### ✅ DO: Wrap the layout section that owns the sticky header
|
|
230
|
+
|
|
231
|
+
The sentinel must be the first rendered element inside the container that scrolls.
|
|
232
|
+
|
|
233
|
+
### ❌ DON'T: Use `StickySection` for static layouts
|
|
234
|
+
|
|
235
|
+
`IntersectionObserver` is set up on mount and torn down on unmount. Avoid mounting and unmounting
|
|
236
|
+
the component rapidly in tight render loops.
|
|
237
|
+
|
|
238
|
+
## Accessibility
|
|
239
|
+
|
|
240
|
+
- `StickySection` renders one invisible `<div>` sentinel. It carries no semantic role and is not focusable.
|
|
241
|
+
- The sticky visual state is purely presentational; ensure that no information is conveyed exclusively through the sticky appearance.
|
|
242
|
+
- If the sticky element covers page content, verify that keyboard focus order is still logical and that overlapped content is reachable.
|
|
243
|
+
|
|
244
|
+
## References
|
|
245
|
+
|
|
246
|
+
- [MDN — IntersectionObserver](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver)
|
|
247
|
+
- [MDN — rootMargin](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver/rootMargin)
|
|
248
|
+
- [`useElementRect` hook](../../src/hooks/use-element-rect.ts)
|
package/llm.txt
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Adamo UI Component Library - LLM Context
|
|
2
2
|
|
|
3
|
-
> **CRITICAL**: This is a COMPLETE React component library with
|
|
3
|
+
> **CRITICAL**: This is a COMPLETE React component library with 50+ components. DO NOT create components that already exist. ALWAYS check this file first and use existing components.
|
|
4
4
|
|
|
5
5
|
## 🚨 MOST IMPORTANT RULES
|
|
6
6
|
|
|
@@ -75,7 +75,7 @@ Before creating ANY component, verify it doesn't exist here. For implementation
|
|
|
75
75
|
- **Separator** [`docs/components/ui/separator.md`] - `@adamosuiteservices/ui/separator`
|
|
76
76
|
- **Scroll Area** [`docs/components/ui/scroll-area.md`] - `@adamosuiteservices/ui/scroll-area`
|
|
77
77
|
|
|
78
|
-
### Other Components (
|
|
78
|
+
### Other Components (8)
|
|
79
79
|
- **Icon** [`docs/components/ui/icon.md`] - `@adamosuiteservices/ui/icon` (Material Symbols)
|
|
80
80
|
- **Calendar** [`docs/components/ui/calendar.md`] - `@adamosuiteservices/ui/calendar`
|
|
81
81
|
- **Date Picker Selector** [`docs/components/ui/date-picker-selector.md`] - `@adamosuiteservices/ui/date-picker-selector`
|
|
@@ -83,6 +83,7 @@ Before creating ANY component, verify it doesn't exist here. For implementation
|
|
|
83
83
|
- **Kbd** [`docs/components/ui/kbd.md`] - `@adamosuiteservices/ui/kbd`
|
|
84
84
|
- **Input OTP** [`docs/components/ui/input-otp.md`] - `@adamosuiteservices/ui/input-otp`
|
|
85
85
|
- **Full Screen Loader** [`docs/components/layout/full-screen-loader.md`] - `@adamosuiteservices/ui/full-screen-loader`
|
|
86
|
+
- **Sticky Section** [`docs/components/layout/sticky-section.md`] - `@adamosuiteservices/ui/sticky-section` (scroll-aware sticky detection with context + render-prop)
|
|
86
87
|
|
|
87
88
|
### Utilities
|
|
88
89
|
- **cn()** - `@adamosuiteservices/ui/lib` - Merge Tailwind classes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adamosuiteservices/ui",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.20.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -42,6 +42,10 @@
|
|
|
42
42
|
"types": "./dist/components/layout/full-screen-loader/index.d.ts",
|
|
43
43
|
"import": "./dist/full-screen-loader.js"
|
|
44
44
|
},
|
|
45
|
+
"./sticky-section": {
|
|
46
|
+
"types": "./dist/components/layout/sticky-section/index.d.ts",
|
|
47
|
+
"import": "./dist/sticky-section.js"
|
|
48
|
+
},
|
|
45
49
|
"./accordion": {
|
|
46
50
|
"types": "./dist/components/ui/accordion/accordion.d.ts",
|
|
47
51
|
"import": "./dist/accordion.js"
|