@shopify/shop-minis-react 0.4.16 → 0.4.17

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 (49) hide show
  1. package/dist/components/atoms/alert-dialog.js.map +1 -1
  2. package/dist/components/atoms/button.js.map +1 -1
  3. package/dist/components/atoms/icon-button.js.map +1 -1
  4. package/dist/components/atoms/image.js +65 -51
  5. package/dist/components/atoms/image.js.map +1 -1
  6. package/dist/components/atoms/list.js.map +1 -1
  7. package/dist/components/atoms/text-input.js.map +1 -1
  8. package/dist/components/atoms/touchable.js.map +1 -1
  9. package/dist/components/atoms/video-player.js +1 -1
  10. package/dist/components/atoms/video-player.js.map +1 -1
  11. package/dist/components/commerce/add-to-cart.js.map +1 -1
  12. package/dist/components/commerce/buy-now.js.map +1 -1
  13. package/dist/components/commerce/favorite-button.js +1 -4
  14. package/dist/components/commerce/favorite-button.js.map +1 -1
  15. package/dist/components/commerce/merchant-card.js.map +1 -1
  16. package/dist/components/commerce/product-link.js.map +1 -1
  17. package/dist/components/commerce/quantity-selector.js.map +1 -1
  18. package/dist/components/content/image-content-wrapper.js.map +1 -1
  19. package/dist/components/navigation/minis-router.js.map +1 -1
  20. package/dist/components/navigation/transition-link.js.map +1 -1
  21. package/dist/components/ui/alert.js.map +1 -1
  22. package/dist/components/ui/badge.js.map +1 -1
  23. package/dist/components/ui/input.js.map +1 -1
  24. package/dist/index.js +75 -73
  25. package/dist/utils/image.js +44 -24
  26. package/dist/utils/image.js.map +1 -1
  27. package/package.json +1 -1
  28. package/src/components/atoms/alert-dialog.tsx +3 -3
  29. package/src/components/atoms/button.tsx +22 -0
  30. package/src/components/atoms/icon-button.tsx +16 -8
  31. package/src/components/atoms/image.test.tsx +27 -13
  32. package/src/components/atoms/image.tsx +41 -8
  33. package/src/components/atoms/list.tsx +25 -2
  34. package/src/components/atoms/text-input.tsx +3 -1
  35. package/src/components/atoms/touchable.tsx +15 -4
  36. package/src/components/atoms/video-player.tsx +16 -6
  37. package/src/components/commerce/add-to-cart.tsx +7 -11
  38. package/src/components/commerce/buy-now.tsx +7 -10
  39. package/src/components/commerce/favorite-button.tsx +6 -5
  40. package/src/components/commerce/merchant-card.tsx +4 -0
  41. package/src/components/commerce/product-link.tsx +15 -0
  42. package/src/components/commerce/quantity-selector.tsx +6 -1
  43. package/src/components/content/image-content-wrapper.tsx +16 -1
  44. package/src/components/navigation/minis-router.tsx +2 -2
  45. package/src/components/navigation/transition-link.tsx +11 -1
  46. package/src/components/ui/alert.tsx +7 -0
  47. package/src/components/ui/badge.tsx +9 -0
  48. package/src/components/ui/input.tsx +15 -0
  49. package/src/utils/image.ts +38 -0
@@ -1 +1 @@
1
- {"version":3,"file":"image.js","sources":["../../src/utils/image.ts"],"sourcesContent":["import {toUint8Array} from 'js-base64'\nimport {thumbHashToDataURL} from 'thumbhash'\n\n/**\n * Converts a thumbhash string to a data URL for use as an image placeholder\n * @param thumbhash Base64 encoded thumbhash string\n * @returns Data URL that can be used as image source or undefined if conversion fails\n */\nexport function getThumbhashDataURL(thumbhash?: string): string | undefined {\n if (!thumbhash) return\n\n try {\n const thumbhashArray = toUint8Array(thumbhash)\n return thumbHashToDataURL(thumbhashArray)\n } catch (error) {\n console.warn('Failed to decode thumbhash to data URL', error)\n return undefined\n }\n}\n\n/** Converts a file to a data URI\n * @param file The file to convert\n * @returns A promise that resolves to the data URI string\n */\nexport function fileToDataUri(file: File): Promise<string> {\n return new Promise((resolve, reject) => {\n const reader = new FileReader()\n reader.onloadend = () => resolve(reader.result as string)\n reader.onerror = reject\n reader.readAsDataURL(file)\n })\n}\n\nconst ImageSizes = {\n xxsUrl: 32,\n xsUrl: 64,\n sUrl: 128,\n xxsmUrl: 256,\n xsmUrl: 384,\n smUrl: 512,\n mUrl: 640,\n lUrl: 1080,\n xlUrl: 2048,\n} as const\n\ntype Key = keyof typeof ImageSizes\n\n/**\n * Acceptable offset for image sizes. An image could use the size that is within this offset.\n */\nconst offsetPercentage = 0.05\n\nconst sortedImageSizes = Object.entries(ImageSizes).sort(\n ([, firstSize], [, secondSize]) => firstSize - secondSize\n)\n\nconst getImageSizeKeyWithSize = (size: number): Key => {\n for (const [key, imgSize] of sortedImageSizes) {\n const upperBoundSize = imgSize + imgSize * offsetPercentage\n if (size <= upperBoundSize) return key as Key\n }\n\n return 'xlUrl'\n}\n\nconst resizeImage = (imageUrl: string, width: number) => {\n const pattern = new RegExp(/\\?+/g)\n const delimiter = pattern.test(imageUrl) ? '&' : '?'\n return `${imageUrl}${delimiter}width=${width}`\n}\n\n/**\n * Optimizes Shopify CDN image URLs by adding a width parameter based on screen size\n * @param url The image URL to optimize\n * @returns The optimized URL with width parameter if it's a Shopify CDN image, otherwise returns the original URL\n */\n\nexport const getResizedImageUrl = (url?: string): string => {\n if (!url) return ''\n\n // Only process Shopify CDN images\n if (!url.startsWith('https://cdn.shopify.com')) {\n return url\n }\n\n const width = window.innerWidth ?? screen.width\n\n const key = getImageSizeKeyWithSize(width)\n\n return resizeImage(url, ImageSizes[key])\n}\n"],"names":["getThumbhashDataURL","thumbhash","thumbhashArray","toUint8Array","thumbHashToDataURL","error","fileToDataUri","file","resolve","reject","reader","ImageSizes","offsetPercentage","sortedImageSizes","firstSize","secondSize","getImageSizeKeyWithSize","size","key","imgSize","upperBoundSize","resizeImage","imageUrl","width","delimiter","getResizedImageUrl","url"],"mappings":";;AAQO,SAASA,EAAoBC,GAAwC;AAC1E,MAAKA;AAED,QAAA;AACI,YAAAC,IAAiBC,EAAaF,CAAS;AAC7C,aAAOG,EAAmBF,CAAc;AAAA,aACjCG,GAAO;AACN,cAAA,KAAK,0CAA0CA,CAAK;AACrD;AAAA,IAAA;AAEX;AAMO,SAASC,EAAcC,GAA6B;AACzD,SAAO,IAAI,QAAQ,CAACC,GAASC,MAAW;AAChC,UAAAC,IAAS,IAAI,WAAW;AAC9B,IAAAA,EAAO,YAAY,MAAMF,EAAQE,EAAO,MAAgB,GACxDA,EAAO,UAAUD,GACjBC,EAAO,cAAcH,CAAI;AAAA,EAAA,CAC1B;AACH;AAEA,MAAMI,IAAa;AAAA,EACjB,QAAQ;AAAA,EACR,OAAO;AAAA,EACP,MAAM;AAAA,EACN,SAAS;AAAA,EACT,QAAQ;AAAA,EACR,OAAO;AAAA,EACP,MAAM;AAAA,EACN,MAAM;AAAA,EACN,OAAO;AACT,GAOMC,IAAmB,MAEnBC,IAAmB,OAAO,QAAQF,CAAU,EAAE;AAAA,EAClD,CAAC,CAAG,EAAAG,CAAS,GAAG,GAAGC,CAAU,MAAMD,IAAYC;AACjD,GAEMC,IAA0B,CAACC,MAAsB;AACrD,aAAW,CAACC,GAAKC,CAAO,KAAKN,GAAkB;AACvC,UAAAO,IAAiBD,IAAUA,IAAUP;AACvC,QAAAK,KAAQG,EAAuB,QAAAF;AAAA,EAAA;AAG9B,SAAA;AACT,GAEMG,IAAc,CAACC,GAAkBC,MAAkB;AAEvD,QAAMC,IADU,IAAI,OAAO,MAAM,EACP,KAAKF,CAAQ,IAAI,MAAM;AACjD,SAAO,GAAGA,CAAQ,GAAGE,CAAS,SAASD,CAAK;AAC9C,GAQaE,IAAqB,CAACC,MAAyB;AACtD,MAAA,CAACA,EAAY,QAAA;AAGjB,MAAI,CAACA,EAAI,WAAW,yBAAyB;AACpC,WAAAA;AAGH,QAAAH,IAAQ,OAAO,cAAc,OAAO,OAEpCL,IAAMF,EAAwBO,CAAK;AAEzC,SAAOF,EAAYK,GAAKf,EAAWO,CAAG,CAAC;AACzC;"}
1
+ {"version":3,"file":"image.js","sources":["../../src/utils/image.ts"],"sourcesContent":["import {toUint8Array} from 'js-base64'\nimport {thumbHashToDataURL} from 'thumbhash'\n\n/**\n * Converts a thumbhash string to a data URL for use as an image placeholder\n * @param thumbhash Base64 encoded thumbhash string\n * @returns Data URL that can be used as image source or undefined if conversion fails\n */\nexport function getThumbhashDataURL(thumbhash?: string): string | undefined {\n if (!thumbhash) return\n\n try {\n const thumbhashArray = toUint8Array(thumbhash)\n return thumbHashToDataURL(thumbhashArray)\n } catch (error) {\n console.warn('Failed to decode thumbhash to data URL', error)\n return undefined\n }\n}\n\n/**\n * Converts a data URL to a Blob\n * @param dataURL The data URL to convert\n * @returns A Blob object\n */\nexport function dataURLToBlob(dataURL: string): Blob {\n const [header, base64Data] = dataURL.split(',')\n const mimeMatch = header.match(/:(.*?);/)\n const mime = mimeMatch ? mimeMatch[1] : 'image/png'\n const binary = atob(base64Data)\n const array = new Uint8Array(binary.length)\n for (let i = 0; i < binary.length; i++) {\n array[i] = binary.charCodeAt(i)\n }\n return new Blob([array], {type: mime})\n}\n\n/**\n * Converts a thumbhash string to a blob URL for use as an image placeholder\n * This is useful when CSP restrictions prevent data URLs\n * @param thumbhash Base64 encoded thumbhash string\n * @returns Blob URL that can be used as image source or undefined if conversion fails\n */\nexport function getThumbhashBlobURL(thumbhash?: string): string | undefined {\n if (!thumbhash) return\n\n try {\n const dataURL = getThumbhashDataURL(thumbhash)\n if (!dataURL) return\n\n const blob = dataURLToBlob(dataURL)\n return URL.createObjectURL(blob)\n } catch (error) {\n console.warn('Failed to create thumbhash blob URL', error)\n return undefined\n }\n}\n\n/** Converts a file to a data URI\n * @param file The file to convert\n * @returns A promise that resolves to the data URI string\n */\nexport function fileToDataUri(file: File): Promise<string> {\n return new Promise((resolve, reject) => {\n const reader = new FileReader()\n reader.onloadend = () => resolve(reader.result as string)\n reader.onerror = reject\n reader.readAsDataURL(file)\n })\n}\n\nconst ImageSizes = {\n xxsUrl: 32,\n xsUrl: 64,\n sUrl: 128,\n xxsmUrl: 256,\n xsmUrl: 384,\n smUrl: 512,\n mUrl: 640,\n lUrl: 1080,\n xlUrl: 2048,\n} as const\n\ntype Key = keyof typeof ImageSizes\n\n/**\n * Acceptable offset for image sizes. An image could use the size that is within this offset.\n */\nconst offsetPercentage = 0.05\n\nconst sortedImageSizes = Object.entries(ImageSizes).sort(\n ([, firstSize], [, secondSize]) => firstSize - secondSize\n)\n\nconst getImageSizeKeyWithSize = (size: number): Key => {\n for (const [key, imgSize] of sortedImageSizes) {\n const upperBoundSize = imgSize + imgSize * offsetPercentage\n if (size <= upperBoundSize) return key as Key\n }\n\n return 'xlUrl'\n}\n\nconst resizeImage = (imageUrl: string, width: number) => {\n const pattern = new RegExp(/\\?+/g)\n const delimiter = pattern.test(imageUrl) ? '&' : '?'\n return `${imageUrl}${delimiter}width=${width}`\n}\n\n/**\n * Optimizes Shopify CDN image URLs by adding a width parameter based on screen size\n * @param url The image URL to optimize\n * @returns The optimized URL with width parameter if it's a Shopify CDN image, otherwise returns the original URL\n */\n\nexport const getResizedImageUrl = (url?: string): string => {\n if (!url) return ''\n\n // Only process Shopify CDN images\n if (!url.startsWith('https://cdn.shopify.com')) {\n return url\n }\n\n const width = window.innerWidth ?? screen.width\n\n const key = getImageSizeKeyWithSize(width)\n\n return resizeImage(url, ImageSizes[key])\n}\n"],"names":["getThumbhashDataURL","thumbhash","thumbhashArray","toUint8Array","thumbHashToDataURL","error","dataURLToBlob","dataURL","header","base64Data","mimeMatch","mime","binary","array","i","getThumbhashBlobURL","blob","fileToDataUri","file","resolve","reject","reader","ImageSizes","offsetPercentage","sortedImageSizes","firstSize","secondSize","getImageSizeKeyWithSize","size","key","imgSize","upperBoundSize","resizeImage","imageUrl","width","delimiter","getResizedImageUrl","url"],"mappings":";;AAQO,SAASA,EAAoBC,GAAwC;AAC1E,MAAKA;AAED,QAAA;AACI,YAAAC,IAAiBC,EAAaF,CAAS;AAC7C,aAAOG,EAAmBF,CAAc;AAAA,aACjCG,GAAO;AACN,cAAA,KAAK,0CAA0CA,CAAK;AACrD;AAAA,IAAA;AAEX;AAOO,SAASC,EAAcC,GAAuB;AACnD,QAAM,CAACC,GAAQC,CAAU,IAAIF,EAAQ,MAAM,GAAG,GACxCG,IAAYF,EAAO,MAAM,SAAS,GAClCG,IAAOD,IAAYA,EAAU,CAAC,IAAI,aAClCE,IAAS,KAAKH,CAAU,GACxBI,IAAQ,IAAI,WAAWD,EAAO,MAAM;AAC1C,WAASE,IAAI,GAAGA,IAAIF,EAAO,QAAQE;AACjC,IAAAD,EAAMC,CAAC,IAAIF,EAAO,WAAWE,CAAC;AAEzB,SAAA,IAAI,KAAK,CAACD,CAAK,GAAG,EAAC,MAAMF,GAAK;AACvC;AAQO,SAASI,EAAoBd,GAAwC;AAC1E,MAAKA;AAED,QAAA;AACI,YAAAM,IAAUP,EAAoBC,CAAS;AAC7C,UAAI,CAACM,EAAS;AAER,YAAAS,IAAOV,EAAcC,CAAO;AAC3B,aAAA,IAAI,gBAAgBS,CAAI;AAAA,aACxBX,GAAO;AACN,cAAA,KAAK,uCAAuCA,CAAK;AAClD;AAAA,IAAA;AAEX;AAMO,SAASY,EAAcC,GAA6B;AACzD,SAAO,IAAI,QAAQ,CAACC,GAASC,MAAW;AAChC,UAAAC,IAAS,IAAI,WAAW;AAC9B,IAAAA,EAAO,YAAY,MAAMF,EAAQE,EAAO,MAAgB,GACxDA,EAAO,UAAUD,GACjBC,EAAO,cAAcH,CAAI;AAAA,EAAA,CAC1B;AACH;AAEA,MAAMI,IAAa;AAAA,EACjB,QAAQ;AAAA,EACR,OAAO;AAAA,EACP,MAAM;AAAA,EACN,SAAS;AAAA,EACT,QAAQ;AAAA,EACR,OAAO;AAAA,EACP,MAAM;AAAA,EACN,MAAM;AAAA,EACN,OAAO;AACT,GAOMC,IAAmB,MAEnBC,IAAmB,OAAO,QAAQF,CAAU,EAAE;AAAA,EAClD,CAAC,CAAG,EAAAG,CAAS,GAAG,GAAGC,CAAU,MAAMD,IAAYC;AACjD,GAEMC,IAA0B,CAACC,MAAsB;AACrD,aAAW,CAACC,GAAKC,CAAO,KAAKN,GAAkB;AACvC,UAAAO,IAAiBD,IAAUA,IAAUP;AACvC,QAAAK,KAAQG,EAAuB,QAAAF;AAAA,EAAA;AAG9B,SAAA;AACT,GAEMG,IAAc,CAACC,GAAkBC,MAAkB;AAEvD,QAAMC,IADU,IAAI,OAAO,MAAM,EACP,KAAKF,CAAQ,IAAI,MAAM;AACjD,SAAO,GAAGA,CAAQ,GAAGE,CAAS,SAASD,CAAK;AAC9C,GAQaE,IAAqB,CAACC,MAAyB;AACtD,MAAA,CAACA,EAAY,QAAA;AAGjB,MAAI,CAACA,EAAI,WAAW,yBAAyB;AACpC,WAAAA;AAGH,QAAAH,IAAQ,OAAO,cAAc,OAAO,OAEpCL,IAAMF,EAAwBO,CAAK;AAEzC,SAAOF,EAAYK,GAAKf,EAAWO,CAAG,CAAC;AACzC;"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shopify/shop-minis-react",
3
3
  "license": "SEE LICENSE IN LICENSE.txt",
4
- "version": "0.4.16",
4
+ "version": "0.4.17",
5
5
  "sideEffects": false,
6
6
  "type": "module",
7
7
  "engines": {
@@ -20,11 +20,11 @@ export interface AlertDialogAtomProps {
20
20
  /** The description text shown in the alert dialog body */
21
21
  description: string
22
22
  /** The text shown in the cancel button */
23
- cancelButtonText: string
23
+ cancelButtonText?: string
24
24
  /** The text shown in the confirmation button */
25
- confirmationButtonText: string
25
+ confirmationButtonText?: string
26
26
  /** Whether the alert dialog is open */
27
- open: boolean
27
+ open?: boolean
28
28
  /** Callback fired when the alert dialog open state changes */
29
29
  onOpenChange: (open: boolean) => void
30
30
  /** Callback fired when the confirmation button is clicked */
@@ -7,6 +7,28 @@ import {BaseButton, buttonVariants} from '../ui/button'
7
7
 
8
8
  import {Touchable} from './touchable'
9
9
 
10
+ export interface ButtonDocProps {
11
+ /** Visual style variant */
12
+ variant?:
13
+ | 'default'
14
+ | 'secondary'
15
+ | 'destructive'
16
+ | 'outline'
17
+ | 'ghost'
18
+ | 'link'
19
+ | 'icon'
20
+ /** Button size */
21
+ size?: 'default' | 'sm' | 'lg' | 'icon'
22
+ /** Click handler */
23
+ onClick?: React.MouseEventHandler<HTMLButtonElement>
24
+ /** Prevent click from bubbling to parent elements */
25
+ stopPropagation?: boolean
26
+ /** Whether the button is disabled */
27
+ disabled?: boolean
28
+ /** Button content */
29
+ children?: React.ReactNode
30
+ }
31
+
10
32
  export function Button({
11
33
  className,
12
34
  variant,
@@ -4,6 +4,21 @@ import {cn} from '../../lib/utils'
4
4
 
5
5
  import {Button} from './button'
6
6
 
7
+ export interface IconButtonProps {
8
+ /** Click handler */
9
+ onClick?: () => void
10
+ /** Whether the button is in a filled/active state */
11
+ filled?: boolean
12
+ /** Button size variant */
13
+ size?: 'default' | 'sm' | 'lg'
14
+ /** Lucide icon component to render */
15
+ Icon: LucideIcon
16
+ /** Custom CSS classes for the button container */
17
+ buttonStyles?: string
18
+ /** Custom CSS classes for the icon */
19
+ iconStyles?: string
20
+ }
21
+
7
22
  export function IconButton({
8
23
  onClick,
9
24
  filled = false,
@@ -11,14 +26,7 @@ export function IconButton({
11
26
  Icon,
12
27
  buttonStyles,
13
28
  iconStyles,
14
- }: {
15
- onClick?: () => void
16
- filled?: boolean
17
- size?: 'default' | 'sm' | 'lg'
18
- Icon: LucideIcon
19
- buttonStyles?: string
20
- iconStyles?: string
21
- }) {
29
+ }: IconButtonProps) {
22
30
  const sizeMap = {
23
31
  sm: 'size-3',
24
32
  default: 'size-4',
@@ -1,13 +1,18 @@
1
- import {describe, expect, it, vi} from 'vitest'
1
+ import {describe, expect, it, vi, beforeAll} from 'vitest'
2
2
 
3
3
  import {render, screen, waitFor} from '../../test-utils'
4
4
 
5
5
  import {Image} from './image'
6
6
 
7
+ // Mock URL.revokeObjectURL for jsdom
8
+ beforeAll(() => {
9
+ URL.revokeObjectURL = vi.fn()
10
+ })
11
+
7
12
  // Mock the util functions
8
13
  vi.mock('../../utils', () => ({
9
- getThumbhashDataURL: vi.fn((thumbhash?: string) =>
10
- thumbhash ? `data:image/png;base64,${thumbhash}` : null
14
+ getThumbhashBlobURL: vi.fn((thumbhash?: string) =>
15
+ thumbhash ? `blob:http://localhost/${thumbhash}` : undefined
11
16
  ),
12
17
  getResizedImageUrl: vi.fn((url?: string) => url),
13
18
  }))
@@ -76,7 +81,7 @@ describe('Image', () => {
76
81
  expect(wrapper.style.aspectRatio).toBe('16/9')
77
82
  })
78
83
 
79
- it('uses thumbhash as background when provided with fixed aspect ratio', () => {
84
+ it('uses thumbhash as placeholder when provided with fixed aspect ratio', () => {
80
85
  const {container} = render(
81
86
  <Image
82
87
  src="https://example.com/image.jpg"
@@ -86,10 +91,15 @@ describe('Image', () => {
86
91
  />
87
92
  )
88
93
 
89
- const wrapper = container.firstChild as HTMLElement
90
- expect(wrapper.style.backgroundImage).toContain(
91
- 'data:image/png;base64,testThumbhash'
94
+ // Thumbhash is now rendered as a separate img element (placeholder)
95
+ const images = container.querySelectorAll('img')
96
+ expect(images).toHaveLength(2) // placeholder + main image
97
+ const placeholderImg = images[0]
98
+ expect(placeholderImg).toHaveAttribute(
99
+ 'src',
100
+ 'blob:http://localhost/testThumbhash'
92
101
  )
102
+ expect(placeholderImg).toHaveAttribute('aria-hidden', 'true')
93
103
  })
94
104
 
95
105
  it('renders with natural sizing when aspectRatio is auto', () => {
@@ -122,13 +132,17 @@ describe('Image', () => {
122
132
  />
123
133
  )
124
134
 
125
- const wrapper = container.firstChild as HTMLElement
126
- const img = screen.getByRole('img')
127
-
128
- expect(wrapper.style.backgroundImage).toContain(
129
- 'data:image/png;base64,testThumbhash'
135
+ // Thumbhash is now rendered as a separate img element (placeholder)
136
+ const images = container.querySelectorAll('img')
137
+ expect(images).toHaveLength(2) // placeholder + main image
138
+ const placeholderImg = images[0]
139
+ expect(placeholderImg).toHaveAttribute(
140
+ 'src',
141
+ 'blob:http://localhost/testThumbhash'
130
142
  )
131
- expect(img).toHaveClass('w-full', 'h-auto')
143
+
144
+ const mainImg = images[1]
145
+ expect(mainImg).toHaveClass('w-full', 'h-auto')
132
146
  })
133
147
 
134
148
  it('passes additional props to img element', () => {
@@ -10,7 +10,24 @@ import {
10
10
  } from 'react'
11
11
 
12
12
  import {cn} from '../../lib/utils'
13
- import {getThumbhashDataURL, getResizedImageUrl} from '../../utils'
13
+ import {getThumbhashBlobURL, getResizedImageUrl} from '../../utils'
14
+
15
+ export interface ImageDocProps {
16
+ /** Remote image URL */
17
+ src?: string
18
+ /** File object from useImagePicker (auto-manages blob URL lifecycle) */
19
+ file?: File
20
+ /** Thumbhash string for progressive loading placeholder */
21
+ thumbhash?: string | null
22
+ /** Aspect ratio (e.g., 16/9, "4/3", or "auto") */
23
+ aspectRatio?: number | string
24
+ /** How the image should fit within its container */
25
+ objectFit?: 'cover' | 'contain' | 'fill' | 'scale-down' | 'none'
26
+ /** Alt text for accessibility */
27
+ alt?: string
28
+ /** Callback when image finishes loading */
29
+ onLoad?: () => void
30
+ }
14
31
 
15
32
  type ImageProps = ImgHTMLAttributes<HTMLImageElement> & {
16
33
  src?: string
@@ -52,11 +69,20 @@ export const Image = memo(function Image(props: ImageProps) {
52
69
  }
53
70
  }, [file])
54
71
 
55
- const thumbhashDataURL = useMemo(
56
- () => getThumbhashDataURL(thumbhash ?? undefined),
72
+ const thumbhashBlobUrl = useMemo(
73
+ () => getThumbhashBlobURL(thumbhash ?? undefined),
57
74
  [thumbhash]
58
75
  )
59
76
 
77
+ // Cleanup blob URL when it changes or component unmounts
78
+ useEffect(() => {
79
+ return () => {
80
+ if (thumbhashBlobUrl) {
81
+ URL.revokeObjectURL(thumbhashBlobUrl)
82
+ }
83
+ }
84
+ }, [thumbhashBlobUrl])
85
+
60
86
  const handleLoad = useCallback(
61
87
  (event: React.SyntheticEvent<HTMLImageElement, Event>) => {
62
88
  setIsLoaded(true)
@@ -77,13 +103,20 @@ export const Image = memo(function Image(props: ImageProps) {
77
103
  style={{
78
104
  ...style,
79
105
  ...(aspectRatio !== 'auto' && {aspectRatio}),
80
- backgroundImage: thumbhashDataURL
81
- ? `url(${thumbhashDataURL})`
82
- : undefined,
83
- backgroundSize: 'cover',
84
- backgroundPosition: 'center',
85
106
  }}
86
107
  >
108
+ {thumbhashBlobUrl && !isLoaded && (
109
+ <img
110
+ className={cn(
111
+ aspectRatio === 'auto'
112
+ ? 'w-full h-auto'
113
+ : 'absolute inset-0 size-full',
114
+ 'object-cover'
115
+ )}
116
+ src={thumbhashBlobUrl}
117
+ aria-hidden="true"
118
+ />
119
+ )}
87
120
  <img
88
121
  className={cn(
89
122
  aspectRatio === 'auto'
@@ -12,7 +12,30 @@ import {Skeleton} from '../ui/skeleton'
12
12
  const DEFAULT_REFRESH_PULL_THRESHOLD = 200
13
13
  const ELEMENT_BIND_DELAY = 100
14
14
 
15
- interface Props<T = any>
15
+ export interface ListDocProps<T = any> {
16
+ /** Array of items to render */
17
+ items: T[]
18
+ /** Function to render each item */
19
+ renderItem: (item: T, index: number) => React.ReactNode
20
+ /** Height of the list container */
21
+ height?: string | number
22
+ /** Show scrollbar (default: false) */
23
+ showScrollbar?: boolean
24
+ /** Header element rendered at the top of the list */
25
+ header?: React.ReactNode
26
+ /** Callback to fetch more items when scrolled to bottom */
27
+ fetchMore?: () => Promise<void>
28
+ /** Custom loading component shown while fetching more */
29
+ loadingComponent?: React.ReactNode
30
+ /** Callback for pull-to-refresh */
31
+ onRefresh?: () => Promise<void>
32
+ /** Whether the list is currently refreshing */
33
+ refreshing?: boolean
34
+ /** Enable pull-to-refresh gesture (default: true) */
35
+ enablePullToRefresh?: boolean
36
+ }
37
+
38
+ export interface ListProps<T = any>
16
39
  extends Omit<
17
40
  VirtuosoProps<T, unknown>,
18
41
  'data' | 'itemContent' | 'endReached'
@@ -41,7 +64,7 @@ export function List<T = any>({
41
64
  refreshing,
42
65
  enablePullToRefresh = true,
43
66
  ...virtuosoProps
44
- }: Props<T>) {
67
+ }: ListProps<T>) {
45
68
  const inFlightFetchMoreRef = useRef<Promise<void> | null>(null)
46
69
  const virtuosoRef = useRef<any>(null)
47
70
  const containerRef = useRef<HTMLDivElement>(null)
@@ -3,7 +3,9 @@ import * as React from 'react'
3
3
  import {useKeyboardAvoidingView} from '../../hooks'
4
4
  import {Input} from '../ui/input'
5
5
 
6
- function TextInput({...props}: React.ComponentProps<'input'>) {
6
+ export type TextInputProps = React.ComponentProps<'input'>
7
+
8
+ function TextInput({...props}: TextInputProps) {
7
9
  const inputRef = React.useRef<HTMLInputElement>(null)
8
10
  const {onBlur, onFocus} = useKeyboardAvoidingView()
9
11
 
@@ -2,15 +2,26 @@ import * as React from 'react'
2
2
 
3
3
  import {motion, HTMLMotionProps, useAnimationControls} from 'motion/react'
4
4
 
5
+ export interface TouchableDocProps {
6
+ /** Click handler */
7
+ onClick?: React.MouseEventHandler<HTMLDivElement>
8
+ /** Prevent click event from bubbling to parent elements */
9
+ stopPropagation?: boolean
10
+ /** Content to render inside the touchable area */
11
+ children?: React.ReactNode
12
+ }
13
+
14
+ export interface TouchableProps extends HTMLMotionProps<'div'> {
15
+ onClick?: React.MouseEventHandler<HTMLDivElement>
16
+ stopPropagation?: boolean
17
+ }
18
+
5
19
  export const Touchable = ({
6
20
  children,
7
21
  onClick,
8
22
  stopPropagation = false,
9
23
  ...props
10
- }: HTMLMotionProps<'div'> & {
11
- onClick?: React.MouseEventHandler<HTMLDivElement>
12
- stopPropagation?: boolean
13
- }) => {
24
+ }: TouchableProps) => {
14
25
  const ref = React.useRef<HTMLDivElement>(null)
15
26
  const controls = useAnimationControls()
16
27
 
@@ -17,24 +17,34 @@ export interface VideoPlayerRef {
17
17
  pause: () => void
18
18
  }
19
19
 
20
- interface VideoPlayerProps {
20
+ export interface VideoPlayerProps {
21
+ /** The video source URL */
21
22
  src: string
22
- /**
23
- * The format/MIME type of the video.
24
- * @default 'video/mp4'
25
- */
23
+ /** The format/MIME type of the video (default: 'video/mp4') */
26
24
  format?: string
25
+ /** Whether the video should be muted */
27
26
  muted?: boolean
27
+ /** URL for the poster image shown before playback */
28
28
  poster?: string
29
+ /** Whether the video should autoplay */
29
30
  autoplay?: boolean
31
+ /** Preload behavior: 'none', 'metadata', or 'auto' */
30
32
  preload?: 'none' | 'metadata' | 'auto'
33
+ /** Whether the video should loop */
31
34
  loop?: boolean
35
+ /** Video width in pixels */
32
36
  width?: number
37
+ /** Video height in pixels */
33
38
  height?: number
39
+ /** Custom play button component */
34
40
  playButtonComponent?: React.ReactNode
41
+ /** Callback when video starts playing */
35
42
  onPlay?: () => void
43
+ /** Callback when video is paused */
36
44
  onPause?: () => void
45
+ /** Callback when video ends */
37
46
  onEnded?: () => void
47
+ /** Callback when video player is ready */
38
48
  onReady?: () => void
39
49
  }
40
50
 
@@ -49,7 +59,7 @@ export const VideoPlayer: React.ForwardRefExoticComponent<
49
59
  muted,
50
60
  autoplay,
51
61
  preload = 'auto',
52
- loop = 'false',
62
+ loop = false,
53
63
  width,
54
64
  height,
55
65
  playButtonComponent,
@@ -10,22 +10,18 @@ import {useShopCartActions} from '../../internal/useShopCartActions'
10
10
  import {cn} from '../../lib/utils'
11
11
  import {Button} from '../atoms/button'
12
12
 
13
- interface AddToCartButtonProps {
13
+ export interface AddToCartButtonProps {
14
+ /** Whether the button is disabled */
14
15
  disabled?: boolean
16
+ /** CSS class name */
15
17
  className?: string
18
+ /** Button size variant */
16
19
  size?: 'default' | 'sm' | 'lg'
17
- /**
18
- * The discount codes to apply to the cart.
19
- */
20
+ /** The discount codes to apply to the cart */
20
21
  discountCodes?: string[]
21
- /**
22
- * The GID of the product variant. E.g. `gid://shopify/ProductVariant/456`.
23
- */
22
+ /** The GID of the product variant. E.g. `gid://shopify/ProductVariant/456` */
24
23
  productVariantId: string
25
-
26
- /**
27
- * The product to add to the cart.
28
- */
24
+ /** The product to add to the cart */
29
25
  product?: Product
30
26
  }
31
27
 
@@ -8,21 +8,18 @@ import {useShopCartActions} from '../../internal/useShopCartActions'
8
8
  import {cn} from '../../lib/utils'
9
9
  import {Button} from '../atoms/button'
10
10
 
11
- interface BuyNowButtonProps {
11
+ export interface BuyNowButtonProps {
12
+ /** Whether the button is disabled */
12
13
  disabled?: boolean
14
+ /** CSS class name */
13
15
  className?: string
16
+ /** Button size variant */
14
17
  size?: 'default' | 'sm' | 'lg'
15
- /**
16
- * The discount code to apply to the purchase.
17
- */
18
+ /** The discount code to apply to the purchase */
18
19
  discountCode?: string
19
- /**
20
- * The GID of the product variant. E.g. `gid://shopify/ProductVariant/456`.
21
- */
20
+ /** The GID of the product variant. E.g. `gid://shopify/ProductVariant/456` */
22
21
  productVariantId: string
23
- /**
24
- * The product to buy now.
25
- */
22
+ /** The product to buy now */
26
23
  product?: Product
27
24
  }
28
25
 
@@ -2,13 +2,14 @@ import {Heart} from 'lucide-react'
2
2
 
3
3
  import {IconButton} from '../atoms/icon-button'
4
4
 
5
- export function FavoriteButton({
6
- onClick,
7
- filled = false,
8
- }: {
5
+ export interface FavoriteButtonProps {
6
+ /** Click handler for toggling favorite state */
9
7
  onClick?: () => void
8
+ /** Whether the product is currently favorited */
10
9
  filled?: boolean
11
- }) {
10
+ }
11
+
12
+ export function FavoriteButton({onClick, filled = false}: FavoriteButtonProps) {
12
13
  return (
13
14
  <IconButton
14
15
  Icon={Heart}
@@ -365,9 +365,13 @@ function MerchantCardHeader({
365
365
  }
366
366
 
367
367
  export interface MerchantCardProps {
368
+ /** The shop/merchant to display */
368
369
  shop: Shop
370
+ /** Whether the card is tappable to navigate to shop (default: true) */
369
371
  touchable?: boolean
372
+ /** Maximum number of featured product images to show (default: 4) */
370
373
  featuredImagesLimit?: number
374
+ /** Custom content to render inside the card */
371
375
  children?: React.ReactNode
372
376
  }
373
377
 
@@ -226,6 +226,21 @@ function ProductLinkActions({
226
226
  )
227
227
  }
228
228
 
229
+ export interface ProductLinkDocProps {
230
+ /** The product to display */
231
+ product: Product
232
+ /** Hide the favorite/save button */
233
+ hideFavoriteAction?: boolean
234
+ /** Callback when the product link is clicked */
235
+ onClick?: (product: Product) => void
236
+ /** Hide the review stars */
237
+ reviewsDisabled?: boolean
238
+ /** Custom action element to replace the favorite button. Must be provided with `onCustomActionClick`. */
239
+ customAction?: React.ReactNode
240
+ /** Callback when the custom action is clicked. Must be provided with `customAction`. */
241
+ onCustomActionClick?: () => void
242
+ }
243
+
229
244
  export type ProductLinkProps = {
230
245
  product: Product
231
246
  hideFavoriteAction?: boolean
@@ -5,11 +5,16 @@ import {Minus, Plus} from 'lucide-react'
5
5
  import {cn} from '../../lib/utils'
6
6
  import {IconButton} from '../atoms/icon-button'
7
7
 
8
- interface QuantitySelectorProps {
8
+ export interface QuantitySelectorProps {
9
+ /** Current quantity value */
9
10
  quantity: number
11
+ /** Callback when quantity changes */
10
12
  onQuantityChange: (quantity: number) => void
13
+ /** Maximum allowed quantity */
11
14
  maxQuantity: number
15
+ /** Minimum allowed quantity (default: 1) */
12
16
  minQuantity?: number
17
+ /** Whether the selector is disabled */
13
18
  disabled?: boolean
14
19
  }
15
20
 
@@ -1,7 +1,22 @@
1
1
  import {ContentWrapper} from '../atoms/content-wrapper'
2
2
  import {Image} from '../atoms/image'
3
3
 
4
- type ImageContentWrapperProps = (
4
+ export interface ImageContentWrapperDocProps {
5
+ /** The public ID of the uploaded image (use this OR externalId) */
6
+ publicId?: string
7
+ /** The external ID of the uploaded image (use this OR publicId) */
8
+ externalId?: string
9
+ /** Callback when the image loads */
10
+ onLoad?: () => void
11
+ /** Image width */
12
+ width?: number
13
+ /** Image height */
14
+ height?: number
15
+ /** Loading placeholder */
16
+ Loader?: React.ReactNode | string
17
+ }
18
+
19
+ export type ImageContentWrapperProps = (
5
20
  | {publicId: string; externalId?: never}
6
21
  | {externalId: string; publicId?: never}
7
22
  ) & {
@@ -2,7 +2,7 @@ import {BrowserRouter, BrowserRouterProps} from 'react-router'
2
2
 
3
3
  import {TransitionContainer} from './transition-container'
4
4
 
5
- type ShopMinisRouterProps = BrowserRouterProps & {
5
+ export interface MinisRouterProps extends BrowserRouterProps {
6
6
  viewTransitions?: boolean
7
7
  }
8
8
 
@@ -10,7 +10,7 @@ export function MinisRouter({
10
10
  children,
11
11
  viewTransitions = false,
12
12
  ...props
13
- }: ShopMinisRouterProps) {
13
+ }: MinisRouterProps) {
14
14
  if (viewTransitions) {
15
15
  return (
16
16
  <BrowserRouter {...props}>
@@ -4,7 +4,17 @@ import {useHref} from 'react-router'
4
4
 
5
5
  import {useNavigateWithTransition} from '../../hooks/navigation/useNavigateWithTransition'
6
6
 
7
- type TransitionLinkProps = AnchorHTMLAttributes<HTMLAnchorElement> & {
7
+ export interface TransitionLinkDocProps {
8
+ /** The target path to navigate to */
9
+ to: string
10
+ /** Click handler called before navigation */
11
+ onClick?: React.MouseEventHandler<HTMLAnchorElement>
12
+ /** Content to render inside the link */
13
+ children?: React.ReactNode
14
+ }
15
+
16
+ export interface TransitionLinkProps
17
+ extends AnchorHTMLAttributes<HTMLAnchorElement> {
8
18
  to: string
9
19
  }
10
20
 
@@ -4,6 +4,13 @@ import {cva, type VariantProps} from 'class-variance-authority'
4
4
 
5
5
  import {cn} from '../../lib/utils'
6
6
 
7
+ export interface AlertDocProps {
8
+ /** Visual style variant */
9
+ variant?: 'default' | 'destructive'
10
+ /** Content to render inside */
11
+ children?: React.ReactNode
12
+ }
13
+
7
14
  const alertVariants = cva(
8
15
  'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
9
16
  {
@@ -5,6 +5,15 @@ import {Slot as SlotPrimitive} from 'radix-ui'
5
5
 
6
6
  import {cn} from '../../lib/utils'
7
7
 
8
+ export interface BadgeDocProps {
9
+ /** Visual style variant */
10
+ variant?: 'primary' | 'secondary' | 'destructive' | 'outline' | 'none'
11
+ /** Render as child element instead of span */
12
+ asChild?: boolean
13
+ /** Content to render inside */
14
+ children?: React.ReactNode
15
+ }
16
+
8
17
  const badgeVariants = cva(
9
18
  'inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none transition-[color,box-shadow] overflow-hidden',
10
19
  {
@@ -2,6 +2,21 @@ import * as React from 'react'
2
2
 
3
3
  import {cn} from '../../lib/utils'
4
4
 
5
+ export interface InputDocProps {
6
+ /** Ref to the input element (use instead of ref) */
7
+ innerRef?: React.Ref<HTMLInputElement>
8
+ /** Input type (text, email, password, etc.) */
9
+ type?: string
10
+ /** Placeholder text */
11
+ placeholder?: string
12
+ /** Current value */
13
+ value?: string
14
+ /** Change handler */
15
+ onChange?: React.ChangeEventHandler<HTMLInputElement>
16
+ /** Whether the input is disabled */
17
+ disabled?: boolean
18
+ }
19
+
5
20
  // using the default ref doesn't seem to set the parent's ref object when mounted
6
21
  // Since this is a shadCN component, we need to make sure to add back the innerRef prop,
7
22
  // whenever the component is updated.