@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.
- package/dist/components/atoms/alert-dialog.js.map +1 -1
- package/dist/components/atoms/button.js.map +1 -1
- package/dist/components/atoms/icon-button.js.map +1 -1
- package/dist/components/atoms/image.js +65 -51
- package/dist/components/atoms/image.js.map +1 -1
- package/dist/components/atoms/list.js.map +1 -1
- package/dist/components/atoms/text-input.js.map +1 -1
- package/dist/components/atoms/touchable.js.map +1 -1
- package/dist/components/atoms/video-player.js +1 -1
- package/dist/components/atoms/video-player.js.map +1 -1
- package/dist/components/commerce/add-to-cart.js.map +1 -1
- package/dist/components/commerce/buy-now.js.map +1 -1
- package/dist/components/commerce/favorite-button.js +1 -4
- package/dist/components/commerce/favorite-button.js.map +1 -1
- package/dist/components/commerce/merchant-card.js.map +1 -1
- package/dist/components/commerce/product-link.js.map +1 -1
- package/dist/components/commerce/quantity-selector.js.map +1 -1
- package/dist/components/content/image-content-wrapper.js.map +1 -1
- package/dist/components/navigation/minis-router.js.map +1 -1
- package/dist/components/navigation/transition-link.js.map +1 -1
- package/dist/components/ui/alert.js.map +1 -1
- package/dist/components/ui/badge.js.map +1 -1
- package/dist/components/ui/input.js.map +1 -1
- package/dist/index.js +75 -73
- package/dist/utils/image.js +44 -24
- package/dist/utils/image.js.map +1 -1
- package/package.json +1 -1
- package/src/components/atoms/alert-dialog.tsx +3 -3
- package/src/components/atoms/button.tsx +22 -0
- package/src/components/atoms/icon-button.tsx +16 -8
- package/src/components/atoms/image.test.tsx +27 -13
- package/src/components/atoms/image.tsx +41 -8
- package/src/components/atoms/list.tsx +25 -2
- package/src/components/atoms/text-input.tsx +3 -1
- package/src/components/atoms/touchable.tsx +15 -4
- package/src/components/atoms/video-player.tsx +16 -6
- package/src/components/commerce/add-to-cart.tsx +7 -11
- package/src/components/commerce/buy-now.tsx +7 -10
- package/src/components/commerce/favorite-button.tsx +6 -5
- package/src/components/commerce/merchant-card.tsx +4 -0
- package/src/components/commerce/product-link.tsx +15 -0
- package/src/components/commerce/quantity-selector.tsx +6 -1
- package/src/components/content/image-content-wrapper.tsx +16 -1
- package/src/components/navigation/minis-router.tsx +2 -2
- package/src/components/navigation/transition-link.tsx +11 -1
- package/src/components/ui/alert.tsx +7 -0
- package/src/components/ui/badge.tsx +9 -0
- package/src/components/ui/input.tsx +15 -0
- package/src/utils/image.ts +38 -0
package/dist/utils/image.js.map
CHANGED
|
@@ -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;
|
|
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
|
@@ -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
|
|
23
|
+
cancelButtonText?: string
|
|
24
24
|
/** The text shown in the confirmation button */
|
|
25
|
-
confirmationButtonText
|
|
25
|
+
confirmationButtonText?: string
|
|
26
26
|
/** Whether the alert dialog is open */
|
|
27
|
-
open
|
|
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
|
-
|
|
10
|
-
thumbhash ? `
|
|
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
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
56
|
-
() =>
|
|
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
|
|
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
|
-
}:
|
|
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
|
-
|
|
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
|
-
}:
|
|
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 =
|
|
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
|
|
6
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}:
|
|
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
|
-
|
|
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.
|