@boyernick/standard-ui-react 0.4.2-canary.68 → 0.4.2-canary.69

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": "@boyernick/standard-ui-react",
3
- "version": "0.4.2-canary.68",
3
+ "version": "0.4.2-canary.69",
4
4
  "description": "React components for StandardUI",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/icons.tsx CHANGED
@@ -39,6 +39,8 @@ import { IconSun as IconSunBase } from "@central-icons-react/round-outlined-radi
39
39
  import { IconVolumeFull as IconVolumeFullBase } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconVolumeFull"
40
40
  import { IconVolumeOff as IconVolumeOffBase } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconVolumeOff"
41
41
  import { IconX as IconXBase } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconX"
42
+ import { IconZoomIn as IconZoomInBase } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconZoomIn"
43
+ import { IconZoomOut as IconZoomOutBase } from "@central-icons-react/round-outlined-radius-2-stroke-2/IconZoomOut"
42
44
  import type { ComponentType } from "react"
43
45
 
44
46
  /** Round outlined Central Icons: 2px radius, 2px stroke, default 20px. */
@@ -140,11 +142,15 @@ export const IconSun = withCentralIconDefaults(IconSunBase)
140
142
  export const IconVolumeFull = withCentralIconDefaults(IconVolumeFullBase)
141
143
  export const IconVolumeOff = withCentralIconDefaults(IconVolumeOffBase)
142
144
  export const IconX = withCentralIconDefaults(IconXBase)
145
+ export const IconZoomIn = withCentralIconDefaults(IconZoomInBase)
146
+ export const IconZoomOut = withCentralIconDefaults(IconZoomOutBase)
143
147
 
144
148
  export const iconGallery = [
145
149
  { name: "IconHome", Icon: IconHome },
146
150
  { name: "IconCalendar1", Icon: IconCalendar1 },
147
151
  { name: "IconMagnifyingGlass", Icon: IconMagnifyingGlass },
152
+ { name: "IconZoomIn", Icon: IconZoomIn },
153
+ { name: "IconZoomOut", Icon: IconZoomOut },
148
154
  { name: "IconSettingsGear1", Icon: IconSettingsGear1 },
149
155
  { name: "IconBell", Icon: IconBell },
150
156
  { name: "IconPeople", Icon: IconPeople },
package/src/index.ts CHANGED
@@ -1007,6 +1007,8 @@ export {
1007
1007
  IconVolumeFull,
1008
1008
  IconVolumeOff,
1009
1009
  IconX,
1010
+ IconZoomIn,
1011
+ IconZoomOut,
1010
1012
  type CentralIconProps,
1011
1013
  type CentralIconComponent,
1012
1014
  } from "./icons";
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Magnify an image in place and pan it under the pointer.
3
+ *
4
+ * The image scales from its top-left corner and moves with `translate`, so
5
+ * the zoom can ease while panning stays instant. Crossing the frame maps to
6
+ * the image's whole travel: the pointer reaches every edge at any size, and
7
+ * the image never parks with an edge out of reach.
8
+ */
9
+
10
+ export const IMAGE_ZOOM = 2
11
+
12
+ export interface ZoomAxis {
13
+ /** Pointer position, in client pixels. */
14
+ cursor: number
15
+ /** Start and size of the frame the image is seen through. */
16
+ frameStart: number
17
+ frameSize: number
18
+ /** Where the unmoved image starts, in client pixels. */
19
+ layoutStart: number
20
+ /** The magnified image's size, in client pixels. */
21
+ rendered: number
22
+ /** Client pixels per image pixel from ancestors (an opening dialog's scale). */
23
+ ancestor: number
24
+ }
25
+
26
+ /** The translate, in the image's own pixels, for one axis. */
27
+ export function zoomTranslate({
28
+ cursor,
29
+ frameStart,
30
+ frameSize,
31
+ layoutStart,
32
+ rendered,
33
+ ancestor,
34
+ }: ZoomAxis) {
35
+ const unit = ancestor || 1
36
+ // Smaller than the frame: centre it, nothing to pan.
37
+ if (rendered <= frameSize) {
38
+ return (frameStart + frameSize / 2 - layoutStart - rendered / 2) / unit
39
+ }
40
+ const atStart = (frameStart - layoutStart) / unit
41
+ const atEnd = (frameStart + frameSize - rendered - layoutStart) / unit
42
+ const progress = Math.max(0, Math.min(1, (cursor - frameStart) / frameSize))
43
+ return atStart + (atEnd - atStart) * progress
44
+ }
45
+
46
+ /** The presented scale and translate, including an in-flight transition. */
47
+ function presented(image: HTMLElement) {
48
+ const style = getComputedStyle(image)
49
+ const scale = Number.parseFloat(style.scale) || 1
50
+ const [x = 0, y = 0] = (style.translate || "").split(" ").map(Number.parseFloat)
51
+ return { scale, x: Number.isFinite(x) ? x : 0, y: Number.isFinite(y) ? y : 0 }
52
+ }
53
+
54
+ /** Move a magnified image so the point under the pointer comes into view. */
55
+ export function panZoomedImage(
56
+ image: HTMLElement,
57
+ frame: DOMRect,
58
+ clientX: number,
59
+ clientY: number,
60
+ scale = IMAGE_ZOOM,
61
+ ) {
62
+ const width = image.offsetWidth
63
+ const height = image.offsetHeight
64
+ if (!width || !height) return
65
+
66
+ const applied = presented(image)
67
+ const rect = image.getBoundingClientRect()
68
+ const ancestor = rect.width / (applied.scale * width) || 1
69
+ const x = zoomTranslate({
70
+ cursor: clientX,
71
+ frameStart: frame.left,
72
+ frameSize: frame.width,
73
+ layoutStart: rect.left - ancestor * applied.x,
74
+ rendered: ancestor * scale * width,
75
+ ancestor,
76
+ })
77
+ const y = zoomTranslate({
78
+ cursor: clientY,
79
+ frameStart: frame.top,
80
+ frameSize: frame.height,
81
+ layoutStart: rect.top - ancestor * applied.y,
82
+ rendered: ancestor * scale * height,
83
+ ancestor,
84
+ })
85
+ image.style.translate = `${x}px ${y}px`
86
+ }
package/src/modal.tsx CHANGED
@@ -36,9 +36,12 @@ import {
36
36
  IconArrowDownSmall,
37
37
  IconChevronRightSmall,
38
38
  IconCrossSmall,
39
+ IconZoomIn,
40
+ IconZoomOut,
39
41
  } from "./icons"
40
42
  import { cn } from "./lib/cn"
41
43
  import { focusRing, focusRingBorder } from "./lib/focus"
44
+ import { IMAGE_ZOOM, panZoomedImage } from "./lib/image-zoom"
42
45
  import { motion } from "./lib/motion"
43
46
  import {
44
47
  Tooltip,
@@ -86,6 +89,11 @@ export type ModalContentProps = Omit<
86
89
  downloadName?: string
87
90
  imgProps?: Omit<ImgHTMLAttributes<HTMLImageElement>, "src" | "alt">
88
91
  children?: ReactNode
92
+ /**
93
+ * Magnification when the image is clicked or the zoom control pressed.
94
+ * `false` turns zoom off. Default 2.
95
+ */
96
+ zoom?: number | false
89
97
  }
90
98
 
91
99
  export type GalleryItem = {
@@ -176,18 +184,24 @@ const ModalImage = ({
176
184
  alt,
177
185
  imgProps,
178
186
  fit = "viewport",
187
+ imageRef,
188
+ zoom,
179
189
  }: Pick<GalleryItem, "src" | "alt" | "imgProps"> & {
180
190
  fit?: "viewport" | "contain"
191
+ imageRef?: { current: HTMLImageElement | null }
192
+ /** Zoom state and toggle, for an image that magnifies in place. */
193
+ zoom?: { scale: number; zoomed: boolean; toggle: (x: number, y: number) => void }
181
194
  }) => {
182
- const { className, onLoad, onError, ...props } = imgProps ?? {}
195
+ const { className, onLoad, onError, onClick, style, ...props } = imgProps ?? {}
183
196
  // Held transparent until decoded, then faded in: a large file otherwise
184
197
  // paints in top-down bands inside the lightbox. The ref catches an image the
185
198
  // browser already has (a cached file, or the one the trigger just showed)
186
199
  // before first paint, so it appears at once with nothing to fade.
187
200
  const [loaded, setLoaded] = useState(false)
188
201
  const catchLoaded = useCallback((image: HTMLImageElement | null) => {
202
+ if (imageRef) imageRef.current = image
189
203
  if (image?.complete && image.naturalWidth > 0) setLoaded(true)
190
- }, [])
204
+ }, [imageRef])
191
205
 
192
206
  return (
193
207
  // eslint-disable-next-line @next/next/no-img-element
@@ -208,16 +222,29 @@ const ModalImage = ({
208
222
  // Reveal the broken image rather than leave an invisible one.
209
223
  setLoaded(true)
210
224
  }}
225
+ onClick={(event) => {
226
+ onClick?.(event)
227
+ if (!event.defaultPrevented) zoom?.toggle(event.clientX, event.clientY)
228
+ }}
211
229
  className={cn(
212
- // Linear, like every fade in `motion` — see the note there.
213
- "transition-opacity duration-[var(--duration-lg)] ease-linear motion-reduce:transition-none",
230
+ // Linear, like every fade in `motion` — see the note there. The zoom
231
+ // eases scale and translate; while the pointer pans, translate
232
+ // follows it without easing.
233
+ zoom
234
+ ? "motion-safe:[transition:opacity_var(--duration-lg)_linear,scale_var(--duration-md)_var(--ease-move),translate_var(--duration-md)_var(--ease-move)] motion-safe:data-[panning]:[transition-property:opacity,scale]"
235
+ : "transition-opacity duration-[var(--duration-lg)] ease-linear motion-reduce:transition-none",
214
236
  !loaded && "opacity-0",
215
237
  "rounded-lg object-contain shadow-lg select-none",
238
+ // Scaled from the top-left, so the pan lands the image exactly where
239
+ // it is measured.
240
+ zoom && "origin-top-left",
241
+ zoom && (zoom.zoomed ? "cursor-zoom-out touch-none" : "cursor-zoom-in"),
216
242
  fit === "contain"
217
243
  ? "max-h-full max-w-full"
218
244
  : "max-h-[82dvh] max-w-[88vw] max-sm:max-h-[calc(100dvh-2rem)] max-sm:max-w-[calc(100vw-2rem)]",
219
245
  className,
220
246
  )}
247
+ style={zoom ? { ...style, scale: zoom.zoomed ? String(zoom.scale) : "1" } : style}
221
248
  {...props}
222
249
  />
223
250
  )
@@ -298,12 +325,137 @@ const ModalDismiss = () => (
298
325
  </ModalControlTooltip>
299
326
  )
300
327
 
301
- const ModalCaption = ({ children }: { children: ReactNode }) => (
302
- <p className="mt-3 max-w-[min(40rem,88vw)] shrink-0 px-4 text-center text-sm text-fg-scrim-secondary">
328
+ const ModalZoom = ({
329
+ zoomed,
330
+ onToggle,
331
+ }: {
332
+ zoomed: boolean
333
+ onToggle: () => void
334
+ }) => (
335
+ <ModalControlTooltip label={zoomed ? "Zoom out" : "Zoom in"}>
336
+ <GlassButton
337
+ type="button"
338
+ config={mediaGlass}
339
+ size="md"
340
+ iconOnly
341
+ rounded
342
+ aria-label={zoomed ? "Zoom out" : "Zoom in"}
343
+ aria-pressed={zoomed}
344
+ onClick={onToggle}
345
+ className={cn(
346
+ modalControlClassName,
347
+ // Beside Download: 16px inset, a 36px control and an 8px gap.
348
+ "absolute top-4 left-15 z-20",
349
+ )}
350
+ >
351
+ {zoomed ? <IconZoomOut aria-hidden /> : <IconZoomIn aria-hidden />}
352
+ </GlassButton>
353
+ </ModalControlTooltip>
354
+ )
355
+
356
+ const ModalCaption = ({ children, hidden }: { children: ReactNode; hidden?: boolean }) => (
357
+ <p
358
+ className={cn(
359
+ "mt-3 max-w-[min(40rem,88vw)] shrink-0 px-4 text-center text-sm text-fg-scrim-secondary",
360
+ "transition-opacity duration-[var(--duration-sm)] ease-linear motion-reduce:transition-none",
361
+ // The magnified image covers the caption's place; don't show through it.
362
+ hidden && "opacity-0",
363
+ )}
364
+ >
303
365
  {children}
304
366
  </p>
305
367
  )
306
368
 
369
+ /**
370
+ * Zoom for one image: magnify at a point, then pan under the pointer. A
371
+ * mouse or pen pans by moving; a finger drags the image.
372
+ */
373
+ function useImageZoom(scale: number | false) {
374
+ const [zoomed, setZoomed] = useState(false)
375
+ const imageRef = useRef<HTMLImageElement | null>(null)
376
+ const frameRef = useRef<HTMLDivElement | null>(null)
377
+ // Where the pan is aimed, in client pixels.
378
+ const aim = useRef({ x: 0, y: 0 })
379
+
380
+ const toggle = useCallback(
381
+ (x?: number, y?: number) => {
382
+ const image = imageRef.current
383
+ const frame = frameRef.current
384
+ if (!scale || !image || !frame) return
385
+ const bounds = frame.getBoundingClientRect()
386
+ // The zoom itself eases, towards the point.
387
+ delete image.dataset.panning
388
+ if (zoomed) {
389
+ image.style.translate = "0px 0px"
390
+ } else {
391
+ // No point (the control): open on the middle.
392
+ aim.current = {
393
+ x: x ?? bounds.left + bounds.width / 2,
394
+ y: y ?? bounds.top + bounds.height / 2,
395
+ }
396
+ panZoomedImage(image, bounds, aim.current.x, aim.current.y, scale)
397
+ }
398
+ setZoomed(!zoomed)
399
+ },
400
+ [scale, zoomed],
401
+ )
402
+
403
+ useEffect(() => {
404
+ const image = imageRef.current
405
+ const frame = frameRef.current
406
+ if (!zoomed || !scale || !image || !frame) return
407
+ let pending = 0
408
+ let drag: { x: number; y: number } | null = null
409
+ const pan = () => {
410
+ pending = 0
411
+ image.dataset.panning = ""
412
+ panZoomedImage(image, frame.getBoundingClientRect(), aim.current.x, aim.current.y, scale)
413
+ }
414
+ const schedule = () => {
415
+ if (!pending) pending = requestAnimationFrame(pan)
416
+ }
417
+ const down = (event: PointerEvent) => {
418
+ if (event.pointerType === "touch") drag = { x: event.clientX, y: event.clientY }
419
+ }
420
+ const move = (event: PointerEvent) => {
421
+ if (event.pointerType !== "touch") {
422
+ aim.current = { x: event.clientX, y: event.clientY }
423
+ schedule()
424
+ return
425
+ }
426
+ if (!drag) return
427
+ // A finger carries the image: aim the opposite way, by the ratio of
428
+ // the frame to the image's travel past it.
429
+ const bounds = frame.getBoundingClientRect()
430
+ const rendered = image.getBoundingClientRect()
431
+ const travelX = Math.max(1, rendered.width - bounds.width)
432
+ const travelY = Math.max(1, rendered.height - bounds.height)
433
+ aim.current = {
434
+ x: Math.max(bounds.left, Math.min(bounds.right, aim.current.x - ((event.clientX - drag.x) * bounds.width) / travelX)),
435
+ y: Math.max(bounds.top, Math.min(bounds.bottom, aim.current.y - ((event.clientY - drag.y) * bounds.height) / travelY)),
436
+ }
437
+ drag = { x: event.clientX, y: event.clientY }
438
+ schedule()
439
+ }
440
+ const up = () => {
441
+ drag = null
442
+ }
443
+ document.addEventListener("pointerdown", down, { passive: true })
444
+ document.addEventListener("pointermove", move, { passive: true })
445
+ document.addEventListener("pointerup", up, { passive: true })
446
+ document.addEventListener("pointercancel", up, { passive: true })
447
+ return () => {
448
+ document.removeEventListener("pointerdown", down)
449
+ document.removeEventListener("pointermove", move)
450
+ document.removeEventListener("pointerup", up)
451
+ document.removeEventListener("pointercancel", up)
452
+ cancelAnimationFrame(pending)
453
+ }
454
+ }, [scale, zoomed])
455
+
456
+ return { zoomed, toggle, imageRef, frameRef }
457
+ }
458
+
307
459
  export const ModalContent = ({
308
460
  src,
309
461
  alt,
@@ -314,8 +466,11 @@ export const ModalContent = ({
314
466
  className,
315
467
  imgProps,
316
468
  children,
469
+ zoom = IMAGE_ZOOM,
317
470
  ...props
318
- }: ModalContentProps) => (
471
+ }: ModalContentProps) => {
472
+ const { zoomed, toggle, imageRef, frameRef } = useImageZoom(zoom)
473
+ return (
319
474
  <DialogPortal>
320
475
  <ModalBackdrop />
321
476
  <DialogPopup
@@ -332,12 +487,19 @@ export const ModalContent = ({
332
487
  <ModalBackground src={src} />
333
488
  <DialogTitle className="sr-only">{alt}</DialogTitle>
334
489
  <div
490
+ ref={frameRef}
335
491
  data-slot="modal-stage"
336
492
  className="relative z-10 flex size-full flex-col items-center justify-center p-4"
337
493
  >
338
- <ModalImage src={src} alt={alt} imgProps={imgProps} />
494
+ <ModalImage
495
+ src={src}
496
+ alt={alt}
497
+ imgProps={imgProps}
498
+ imageRef={imageRef}
499
+ zoom={zoom ? { scale: zoom, zoomed, toggle } : undefined}
500
+ />
339
501
  {variant === "caption" && (caption || children) ? (
340
- <ModalCaption>{caption ?? children}</ModalCaption>
502
+ <ModalCaption hidden={zoomed}>{caption ?? children}</ModalCaption>
341
503
  ) : null}
342
504
  </div>
343
505
  <ModalDismiss />
@@ -345,11 +507,13 @@ export const ModalContent = ({
345
507
  src={downloadSrc ?? src}
346
508
  name={downloadName}
347
509
  />
510
+ {zoom ? <ModalZoom zoomed={zoomed} onToggle={() => toggle()} /> : null}
348
511
  </GlassRoot>
349
512
  </TooltipProvider>
350
513
  </DialogPopup>
351
514
  </DialogPortal>
352
- )
515
+ )
516
+ }
353
517
 
354
518
  export const GalleryTrigger = ({
355
519
  index,