concertina 0.5.0 → 0.5.1

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 (2) hide show
  1. package/README.md +128 -36
  2. package/package.json +1 -1
package/README.md CHANGED
@@ -5,67 +5,159 @@
5
5
  <h1 align="center">concertina</h1>
6
6
 
7
7
  <p align="center">
8
- Scroll-pinned <a href="https://www.radix-ui.com/primitives/docs/components/accordion">Radix Accordion</a> panels. One hook, zero dependencies.
8
+ Layout stability primitives + scroll-pinned <a href="https://www.radix-ui.com/primitives/docs/components/accordion">Radix Accordion</a> panels.
9
9
  </p>
10
10
 
11
- ## Quick start
11
+ ## Install
12
12
 
13
13
  ```bash
14
14
  npm install concertina
15
15
  ```
16
16
 
17
17
  ```tsx
18
- import { useConcertina } from "concertina";
18
+ import * as Concertina from "concertina";
19
19
  import "concertina/styles.css";
20
20
  ```
21
21
 
22
- Add it to your existing accordion:
22
+ ---
23
+
24
+ ## Layer 1: Primitives
25
+
26
+ ### StableSlot + Slot — Zero-shift variant switching
27
+
28
+ A UI slot toggles between variants of different sizes (Add button ↔ quantity stepper). Surrounding content reflows. The fix: render **all variants simultaneously** in the same CSS grid cell. The cell auto-sizes to the largest child. Only the active variant is visible.
23
29
 
24
30
  ```tsx
25
- function MyAccordion({ items }) {
26
- const { rootProps, getItemRef } = useConcertina();
27
-
28
- return (
29
- <Accordion.Root type="single" collapsible {...rootProps}>
30
- {items.map((item) => (
31
- <Accordion.Item key={item.id} value={item.id} ref={getItemRef(item.id)}>
32
- <Accordion.Header>
33
- <Accordion.Trigger>{item.title}</Accordion.Trigger>
34
- </Accordion.Header>
35
- <Accordion.Content className="concertina-content">
36
- {item.body}
37
- </Accordion.Content>
38
- </Accordion.Item>
39
- ))}
40
- </Accordion.Root>
41
- );
42
- }
31
+ <Concertina.StableSlot axis="width" className="action-slot">
32
+ <Concertina.Slot active={!isInCart}>
33
+ <AddButton />
34
+ </Concertina.Slot>
35
+ <Concertina.Slot active={isInCart}>
36
+ <QuantityControl />
37
+ </Concertina.Slot>
38
+ </Concertina.StableSlot>
39
+ ```
40
+
41
+ **How it works:**
42
+ 1. `display: grid` on container, `grid-area: 1/1` on all slots — everything overlaps
43
+ 2. `visibility: hidden` on inactive slots — invisible but still in layout flow
44
+ 3. `inert` attribute on inactive slots — no focus, no clicks, no screen reader
45
+ 4. Axis-aware collapse (`max-height: 0` or `max-width: 0`) so only the relevant axis contributes to sizing
46
+ 5. Zero JS measurement — pure CSS, works on first frame
47
+
48
+ **StableSlot props:**
49
+
50
+ | Prop | Type | Default | Description |
51
+ |------|------|---------|-------------|
52
+ | `axis` | `"width"` \| `"height"` \| `"both"` | `"both"` | Which axis to stabilize |
53
+ | `className` | `string` | — | Passed to wrapper div |
54
+
55
+ All other div attributes are forwarded.
56
+
57
+ **Slot props:**
58
+
59
+ | Prop | Type | Description |
60
+ |------|------|-------------|
61
+ | `active` | `boolean` | Controls visibility |
62
+
63
+ ### useStableSlot — ResizeObserver ratchet for dynamic content
64
+
65
+ For content that changes size unpredictably (prices, names, status messages) where you can't enumerate all variants upfront. Watches the container, tracks the maximum size ever observed, applies min-width/min-height that only ratchets up.
66
+
67
+ ```tsx
68
+ const slot = Concertina.useStableSlot({ axis: "width" });
69
+
70
+ <div ref={slot.ref} style={slot.style} className="price-amount">
71
+ {formattedPrice}
72
+ </div>
43
73
  ```
44
74
 
45
- When you switch between items, the new one pins to the top of the scroll container. Animations are suppressed during the switch and restored after.
75
+ | Option | Type | Default | Description |
76
+ |--------|------|---------|-------------|
77
+ | `axis` | `"width"` \| `"height"` \| `"both"` | `"both"` | Which axis to ratchet |
78
+
79
+ Returns `{ ref, style }` — attach both to the container element.
46
80
 
47
- ## Why
81
+ ### useTransitionLock — Animation suppression
48
82
 
49
- Radix Accordion in a scrollable container jumps around when switching items. `scrollIntoView` fixes desktop but yanks the viewport on mobile. `flushSync` with inline styles fights Radix re-renders. Layout measurement races against animations. Fixing one breaks another.
83
+ Suppress CSS transitions during batched state changes. Sets a flag synchronously (batched with state updates in React 18), auto-clears after paint.
84
+
85
+ ```tsx
86
+ const { locked, lock } = Concertina.useTransitionLock();
87
+
88
+ const handleChange = (newValue) => {
89
+ lock();
90
+ setValue(newValue);
91
+ };
92
+
93
+ <div data-locked={locked || undefined}>
94
+ {/* CSS: [data-locked] .animated { transition-duration: 0s } */}
95
+ </div>
96
+ ```
50
97
 
51
- This hook coordinates all of it: animation suppression via a `data-switching` attribute, scroll adjustment via `scrollTop` (not `scrollIntoView`), and automatic cleanup after paint.
98
+ ### pinToScrollTop(el)
52
99
 
53
- ## API
100
+ Scrolls `el` to the top of its nearest scrollable ancestor. Adjusts `scrollTop` only — never cascades to the viewport (critical on mobile where `scrollIntoView` yanks the whole page). Accounts for sticky headers automatically.
54
101
 
55
- ### `useConcertina()`
102
+ ### When to use which
56
103
 
57
- | Property | Type | What it does |
104
+ | Content type | Tool | Shift behavior |
105
+ |-------------|------|----------------|
106
+ | Discrete variants (button A ↔ button B) | `StableSlot` + `Slot` | Zero — ever |
107
+ | Dynamic text (prices, names, messages) | `useStableSlot` | Once per new max, then stable |
108
+ | Numeric text specifically | CSS `tabular-nums` | Zero (font-level) |
109
+
110
+ ---
111
+
112
+ ## Layer 2: Accordion
113
+
114
+ Wraps Radix Accordion with scroll pinning, animation suppression during switches, and per-item memoization via `useSyncExternalStore`.
115
+
116
+ ### Component API
117
+
118
+ ```tsx
119
+ <Concertina.Root className="my-accordion">
120
+ {items.map((item) => (
121
+ <Concertina.Item key={item.id} value={item.id}>
122
+ <Concertina.Header>
123
+ <Concertina.Trigger>{item.title}</Concertina.Trigger>
124
+ </Concertina.Header>
125
+ <Concertina.Content>{item.body}</Concertina.Content>
126
+ </Concertina.Item>
127
+ ))}
128
+ </Concertina.Root>
129
+ ```
130
+
131
+ When you switch between items, the new one pins to the top of the scroll container. Animations are suppressed during the switch and restored after paint.
132
+
133
+ **`useExpanded(id)`** — per-item expansion hook. Only re-renders when this item's boolean flips:
134
+
135
+ ```tsx
136
+ function MyItem({ item }) {
137
+ const expanded = Concertina.useExpanded(item.id);
138
+ // only re-renders when this specific item opens/closes
139
+ }
140
+ ```
141
+
142
+ ### Hook API (legacy)
143
+
144
+ ```tsx
145
+ const { rootProps, getItemRef } = Concertina.useConcertina();
146
+
147
+ <Accordion.Root type="single" collapsible {...rootProps}>
148
+ <Accordion.Item value="a" ref={getItemRef("a")}>
149
+ ...
150
+ </Accordion.Item>
151
+ </Accordion.Root>
152
+ ```
153
+
154
+ | Property | Type | Description |
58
155
  |---|---|---|
59
- | `rootProps` | `object` | Pass to `Accordion.Root` via `{...rootProps}`. Contains `value`, `onValueChange`, `data-switching`. |
60
- | `getItemRef` | `(id: string) => RefCallback` | Pass to `ref` on each `Accordion.Item` |
156
+ | `rootProps` | `object` | Spread onto `Accordion.Root` contains `value`, `onValueChange`, `data-switching` |
157
+ | `getItemRef` | `(id: string) => RefCallback` | Attach to each `Accordion.Item` |
61
158
  | `value` | `string` | Currently expanded item (empty string when collapsed) |
62
- | `onValueChange` | `(value: string) => void` | Change handler, available if you need it directly |
63
159
  | `switching` | `boolean` | True during a switch between items |
64
160
 
65
- ### `pinToScrollTop(el)`
66
-
67
- Also exported standalone. Scrolls `el` to the top of its nearest scrollable ancestor without touching the viewport.
68
-
69
161
  ## Customize animation timing
70
162
 
71
163
  ```css
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "concertina",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Layout stability primitives + scroll-pinned Radix Accordion panels with per-item memoization.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",