@duro-app/ui 0.47.0 → 0.48.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@duro-app/ui",
3
- "version": "0.47.0",
3
+ "version": "0.48.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -55,7 +55,7 @@
55
55
  },
56
56
  "dependencies": {
57
57
  "@tanstack/react-virtual": "^3.14.6",
58
- "@duro-app/tokens": "^0.47.0"
58
+ "@duro-app/tokens": "^0.48.0"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@babel/preset-typescript": "^7.28.0",
@@ -1198,12 +1198,26 @@ export const ScrollsHorizontallyWhenDense: Story = {
1198
1198
  play: async ({canvas}) => {
1199
1199
  const grid = canvas.getByRole('table')
1200
1200
  const scroller = grid.parentElement as HTMLElement
1201
+ const frame = scroller.parentElement as HTMLElement
1202
+ const rightFade = frame.lastElementChild as HTMLElement
1203
+ const leftFade = rightFade.previousElementSibling as HTMLElement
1201
1204
  await waitFor(async () => {
1202
1205
  // Still tabular: all 8 headers present (NOT carded up).
1203
1206
  await expect(canvas.queryAllByRole('columnheader')).toHaveLength(8)
1207
+ // The wrapper scrolls: content (8 × 120px ≈ 960) exceeds its 800px box.
1208
+ await expect(scroller.scrollWidth).toBeGreaterThan(scroller.clientWidth + 2)
1209
+ // Scroll affordance: at the start the RIGHT edge fades (more →), the
1210
+ // left edge doesn't.
1211
+ await expect(getComputedStyle(rightFade).opacity).toBe('1')
1212
+ await expect(getComputedStyle(leftFade).opacity).toBe('0')
1213
+ })
1214
+ // Scroll to the end → the cue flips: the left edge fades, the right clears.
1215
+ scroller.scrollLeft = scroller.scrollWidth
1216
+ scroller.dispatchEvent(new Event('scroll'))
1217
+ await waitFor(async () => {
1218
+ await expect(getComputedStyle(leftFade).opacity).toBe('1')
1219
+ await expect(getComputedStyle(rightFade).opacity).toBe('0')
1204
1220
  })
1205
- // The wrapper scrolls: content (8 × 120px ≈ 960) exceeds its 800px box.
1206
- await expect(scroller.scrollWidth).toBeGreaterThan(scroller.clientWidth + 2)
1207
1221
  },
1208
1222
  }
1209
1223
 
@@ -4,7 +4,9 @@ import {
4
4
  type MutableRefObject,
5
5
  createContext,
6
6
  useContext,
7
+ useEffect,
7
8
  useRef,
9
+ useState,
8
10
  Children,
9
11
  isValidElement,
10
12
  } from 'react'
@@ -169,6 +171,55 @@ function extractColumnMeta(
169
171
  }
170
172
  }
171
173
 
174
+ // --- ScrollFrame ---
175
+ //
176
+ // Wraps the grid in the horizontal-scroll port and paints a soft fade at
177
+ // whichever edge still has hidden columns, so a scrollable table advertises
178
+ // that there's more to see (and stops advertising once you reach the end).
179
+ // This is the ONLY JS the responsive Table uses, and it's purely cosmetic —
180
+ // it reads scroll position and toggles fade opacity; it never changes layout,
181
+ // so none of the flicker/oscillation risk of the old auto-stacking applies.
182
+ function ScrollFrame({children}: {children: ReactNode}) {
183
+ const scrollRef = useRef<HTMLDivElement>(null)
184
+ const [edges, setEdges] = useState({start: false, end: false})
185
+
186
+ useEffect(() => {
187
+ const el = scrollRef.current
188
+ if (!el) return
189
+ const update = () => {
190
+ const overflowing = el.scrollWidth - el.clientWidth > 1
191
+ setEdges({
192
+ start: overflowing && el.scrollLeft > 1,
193
+ end: overflowing && el.scrollLeft + el.clientWidth < el.scrollWidth - 1,
194
+ })
195
+ }
196
+ update()
197
+ el.addEventListener('scroll', update, {passive: true})
198
+ const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(update) : null
199
+ ro?.observe(el)
200
+ return () => {
201
+ el.removeEventListener('scroll', update)
202
+ ro?.disconnect()
203
+ }
204
+ }, [])
205
+
206
+ return (
207
+ <html.div style={styles.scrollFrame}>
208
+ <html.div ref={scrollRef} style={styles.scrollX}>
209
+ {children}
210
+ </html.div>
211
+ <html.div
212
+ aria-hidden
213
+ style={[styles.edgeFade, styles.edgeFadeLeft, edges.start && styles.edgeFadeVisible]}
214
+ />
215
+ <html.div
216
+ aria-hidden
217
+ style={[styles.edgeFade, styles.edgeFadeRight, edges.end && styles.edgeFadeVisible]}
218
+ />
219
+ </html.div>
220
+ )
221
+ }
222
+
172
223
  // --- Root ---
173
224
 
174
225
  interface RootProps {
@@ -240,7 +291,7 @@ export function Root({
240
291
  // a dense table overflows this wrapper and scrolls sideways instead of
241
292
  // crushing. Below it (@container ≤ sm) the grid is one column and there's
242
293
  // nothing to scroll. Non-responsive tables opt out of the frame entirely.
243
- const framedGrid = responsive ? <html.div style={styles.scrollX}>{grid}</html.div> : grid
294
+ const framedGrid = responsive ? <ScrollFrame>{grid}</ScrollFrame> : grid
244
295
 
245
296
  // Slots render unconditionally — `responsive=false` still wants its sort/
246
297
  // pagination chrome. Only the containerType:inline-size wrapper is
@@ -347,6 +347,42 @@ export const styles = css.create({
347
347
  WebkitOverflowScrolling: 'touch',
348
348
  },
349
349
 
350
+ // Positioning context for the scroll-affordance edge fades, which sit on
351
+ // top of the scroll port (not inside it, so they don't scroll away).
352
+ scrollFrame: {
353
+ position: 'relative',
354
+ },
355
+ // A soft fade pinned to a scroll edge that hints "there's more this way".
356
+ // It fades the content into the surface colour, so it reads in any theme.
357
+ // Toggled by opacity from JS scroll position; pointer-events off so it
358
+ // never eats clicks. Sits just inside the rounded frame border.
359
+ edgeFade: {
360
+ position: 'absolute',
361
+ top: 1,
362
+ bottom: 1,
363
+ width: spacing.xl,
364
+ pointerEvents: 'none',
365
+ opacity: 0,
366
+ transitionProperty: 'opacity',
367
+ transitionDuration: duration.fast,
368
+ transitionTimingFunction: easing.standard,
369
+ },
370
+ edgeFadeLeft: {
371
+ left: 1,
372
+ borderTopLeftRadius: radii.md,
373
+ borderBottomLeftRadius: radii.md,
374
+ backgroundImage: `linear-gradient(to left, transparent, ${colors.bgCard})`,
375
+ },
376
+ edgeFadeRight: {
377
+ right: 1,
378
+ borderTopRightRadius: radii.md,
379
+ borderBottomRightRadius: radii.md,
380
+ backgroundImage: `linear-gradient(to right, transparent, ${colors.bgCard})`,
381
+ },
382
+ edgeFadeVisible: {
383
+ opacity: 1,
384
+ },
385
+
350
386
  // Dynamic: grid columns applied on Root (non-responsive path only).
351
387
  gridColumns: (template: string) => ({
352
388
  gridTemplateColumns: template,