@shopify/shop-minis-react 0.4.15 → 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/eslint/rules/validate-manifest.cjs +91 -41
- 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;"}
|
|
@@ -31,8 +31,8 @@ const hookPermissionsMap = {
|
|
|
31
31
|
useImagePicker: ['CAMERA'],
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
// Extract domain from URL (returns
|
|
35
|
-
function
|
|
34
|
+
// Extract domain and path from URL (returns {hostname, pathname} for CSP matching)
|
|
35
|
+
function extractDomainAndPath(url) {
|
|
36
36
|
if (!url || typeof url !== 'string') return null
|
|
37
37
|
|
|
38
38
|
try {
|
|
@@ -48,25 +48,65 @@ function extractDomain(url) {
|
|
|
48
48
|
|
|
49
49
|
// Parse URL
|
|
50
50
|
const urlObj = new URL(url)
|
|
51
|
-
return
|
|
51
|
+
return {
|
|
52
|
+
hostname: urlObj.hostname,
|
|
53
|
+
pathname: urlObj.pathname,
|
|
54
|
+
}
|
|
52
55
|
} catch (err) {
|
|
53
56
|
// If URL parsing fails, might be a relative path
|
|
54
57
|
return null
|
|
55
58
|
}
|
|
56
59
|
}
|
|
57
60
|
|
|
58
|
-
// Check if
|
|
59
|
-
|
|
61
|
+
// Check if a URL matches a trusted_domain pattern
|
|
62
|
+
// Supports: wildcards (*.example.com), paths with CSP semantics
|
|
63
|
+
// CSP path matching: path with trailing slash matches directory, without matches exact or prefix
|
|
64
|
+
function urlMatchesPattern(urlInfo, pattern) {
|
|
60
65
|
if (pattern === '*') return true
|
|
61
|
-
if (pattern === domain) return true
|
|
62
66
|
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
+
// Parse the pattern to extract hostname and optional path
|
|
68
|
+
let patternHostname = pattern
|
|
69
|
+
let patternPath = null
|
|
70
|
+
|
|
71
|
+
// Check if pattern contains a path (has / after the domain)
|
|
72
|
+
const slashIndex = pattern.indexOf('/')
|
|
73
|
+
if (slashIndex !== -1) {
|
|
74
|
+
patternHostname = pattern.slice(0, slashIndex)
|
|
75
|
+
patternPath = pattern.slice(slashIndex)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// First check hostname match
|
|
79
|
+
let hostnameMatches = false
|
|
80
|
+
|
|
81
|
+
if (patternHostname === urlInfo.hostname) {
|
|
82
|
+
hostnameMatches = true
|
|
83
|
+
} else if (patternHostname.startsWith('*.')) {
|
|
84
|
+
// Handle wildcard subdomains (*.example.com)
|
|
85
|
+
const basePattern = patternHostname.slice(2)
|
|
86
|
+
hostnameMatches =
|
|
87
|
+
urlInfo.hostname === basePattern ||
|
|
88
|
+
urlInfo.hostname.endsWith(`.${basePattern}`)
|
|
67
89
|
}
|
|
68
90
|
|
|
69
|
-
|
|
91
|
+
if (!hostnameMatches) {
|
|
92
|
+
return false
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// If no path in pattern, hostname match is sufficient
|
|
96
|
+
if (!patternPath) {
|
|
97
|
+
return true
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// CSP path matching semantics:
|
|
101
|
+
// - Path ending with / matches that directory and everything under it (prefix match)
|
|
102
|
+
// - Path without trailing / matches exact path only
|
|
103
|
+
if (patternPath.endsWith('/')) {
|
|
104
|
+
// Directory match: URL path must start with pattern path
|
|
105
|
+
return urlInfo.pathname.startsWith(patternPath)
|
|
106
|
+
} else {
|
|
107
|
+
// Exact match only
|
|
108
|
+
return urlInfo.pathname === patternPath
|
|
109
|
+
}
|
|
70
110
|
}
|
|
71
111
|
|
|
72
112
|
module.exports = {
|
|
@@ -308,10 +348,11 @@ module.exports = {
|
|
|
308
348
|
// Check for fetch() calls
|
|
309
349
|
if (node.callee.name === 'fetch' && firstArg.type === 'Literal') {
|
|
310
350
|
const url = firstArg.value
|
|
311
|
-
const
|
|
312
|
-
if (
|
|
351
|
+
const urlInfo = extractDomainAndPath(url)
|
|
352
|
+
if (urlInfo) {
|
|
313
353
|
requiredDomains.add({
|
|
314
|
-
|
|
354
|
+
urlInfo,
|
|
355
|
+
url,
|
|
315
356
|
reason: 'fetch() call',
|
|
316
357
|
node,
|
|
317
358
|
})
|
|
@@ -326,10 +367,11 @@ module.exports = {
|
|
|
326
367
|
node.arguments[1].type === 'Literal'
|
|
327
368
|
) {
|
|
328
369
|
const url = node.arguments[1].value
|
|
329
|
-
const
|
|
330
|
-
if (
|
|
370
|
+
const urlInfo = extractDomainAndPath(url)
|
|
371
|
+
if (urlInfo) {
|
|
331
372
|
requiredDomains.add({
|
|
332
|
-
|
|
373
|
+
urlInfo,
|
|
374
|
+
url,
|
|
333
375
|
reason: 'XMLHttpRequest.open() call',
|
|
334
376
|
node,
|
|
335
377
|
})
|
|
@@ -340,11 +382,12 @@ module.exports = {
|
|
|
340
382
|
if (node.callee.name === 'WebSocket' && firstArg.type === 'Literal') {
|
|
341
383
|
const url = firstArg.value
|
|
342
384
|
// WebSocket URLs use ws:// or wss://
|
|
343
|
-
const
|
|
344
|
-
const
|
|
345
|
-
if (
|
|
385
|
+
const normalizedUrl = url.replace(/^wss?:\/\//, 'https://')
|
|
386
|
+
const urlInfo = extractDomainAndPath(normalizedUrl)
|
|
387
|
+
if (urlInfo) {
|
|
346
388
|
requiredDomains.add({
|
|
347
|
-
|
|
389
|
+
urlInfo,
|
|
390
|
+
url,
|
|
348
391
|
reason: 'WebSocket connection',
|
|
349
392
|
node,
|
|
350
393
|
})
|
|
@@ -354,10 +397,11 @@ module.exports = {
|
|
|
354
397
|
// Check for EventSource
|
|
355
398
|
if (node.callee.name === 'EventSource' && firstArg.type === 'Literal') {
|
|
356
399
|
const url = firstArg.value
|
|
357
|
-
const
|
|
358
|
-
if (
|
|
400
|
+
const urlInfo = extractDomainAndPath(url)
|
|
401
|
+
if (urlInfo) {
|
|
359
402
|
requiredDomains.add({
|
|
360
|
-
|
|
403
|
+
urlInfo,
|
|
404
|
+
url,
|
|
361
405
|
reason: 'EventSource connection',
|
|
362
406
|
node,
|
|
363
407
|
})
|
|
@@ -371,10 +415,11 @@ module.exports = {
|
|
|
371
415
|
firstArg.type === 'Literal'
|
|
372
416
|
) {
|
|
373
417
|
const url = firstArg.value
|
|
374
|
-
const
|
|
375
|
-
if (
|
|
418
|
+
const urlInfo = extractDomainAndPath(url)
|
|
419
|
+
if (urlInfo) {
|
|
376
420
|
requiredDomains.add({
|
|
377
|
-
|
|
421
|
+
urlInfo,
|
|
422
|
+
url,
|
|
378
423
|
reason: 'navigator.sendBeacon() call',
|
|
379
424
|
node,
|
|
380
425
|
})
|
|
@@ -389,10 +434,11 @@ module.exports = {
|
|
|
389
434
|
firstArg.type === 'Literal'
|
|
390
435
|
) {
|
|
391
436
|
const url = firstArg.value
|
|
392
|
-
const
|
|
393
|
-
if (
|
|
437
|
+
const urlInfo = extractDomainAndPath(url)
|
|
438
|
+
if (urlInfo) {
|
|
394
439
|
requiredDomains.add({
|
|
395
|
-
|
|
440
|
+
urlInfo,
|
|
441
|
+
url,
|
|
396
442
|
reason: 'window.open() call',
|
|
397
443
|
node,
|
|
398
444
|
})
|
|
@@ -499,9 +545,9 @@ module.exports = {
|
|
|
499
545
|
|
|
500
546
|
const attrName = node.name.name
|
|
501
547
|
const url = node.value.value
|
|
502
|
-
const
|
|
548
|
+
const urlInfo = extractDomainAndPath(url)
|
|
503
549
|
|
|
504
|
-
if (!
|
|
550
|
+
if (!urlInfo) {
|
|
505
551
|
return
|
|
506
552
|
}
|
|
507
553
|
|
|
@@ -522,7 +568,8 @@ module.exports = {
|
|
|
522
568
|
]
|
|
523
569
|
if (supportedElements.includes(elementName)) {
|
|
524
570
|
requiredDomains.add({
|
|
525
|
-
|
|
571
|
+
urlInfo,
|
|
572
|
+
url,
|
|
526
573
|
reason: `<${elementName}> src attribute`,
|
|
527
574
|
node,
|
|
528
575
|
})
|
|
@@ -531,7 +578,8 @@ module.exports = {
|
|
|
531
578
|
// poster attribute (video)
|
|
532
579
|
else if (attrName === 'poster') {
|
|
533
580
|
requiredDomains.add({
|
|
534
|
-
|
|
581
|
+
urlInfo,
|
|
582
|
+
url,
|
|
535
583
|
reason: `<${elementName}> poster attribute`,
|
|
536
584
|
node,
|
|
537
585
|
})
|
|
@@ -539,7 +587,8 @@ module.exports = {
|
|
|
539
587
|
// data attribute (object)
|
|
540
588
|
else if (attrName === 'data') {
|
|
541
589
|
requiredDomains.add({
|
|
542
|
-
|
|
590
|
+
urlInfo,
|
|
591
|
+
url,
|
|
543
592
|
reason: `<${elementName}> data attribute`,
|
|
544
593
|
node,
|
|
545
594
|
})
|
|
@@ -547,7 +596,8 @@ module.exports = {
|
|
|
547
596
|
// action attribute (form)
|
|
548
597
|
else if (attrName === 'action') {
|
|
549
598
|
requiredDomains.add({
|
|
550
|
-
|
|
599
|
+
urlInfo,
|
|
600
|
+
url,
|
|
551
601
|
reason: `<${elementName}> action attribute`,
|
|
552
602
|
node,
|
|
553
603
|
})
|
|
@@ -692,26 +742,26 @@ module.exports = {
|
|
|
692
742
|
})
|
|
693
743
|
|
|
694
744
|
// Check trusted domains
|
|
695
|
-
requiredDomains.forEach(({
|
|
745
|
+
requiredDomains.forEach(({urlInfo, url, reason, node}) => {
|
|
696
746
|
if (!manifest) {
|
|
697
747
|
issues.push({
|
|
698
748
|
messageId: 'manifestNotFound',
|
|
699
|
-
data: {reason, domain},
|
|
749
|
+
data: {reason, domain: urlInfo.hostname},
|
|
700
750
|
})
|
|
701
751
|
return
|
|
702
752
|
}
|
|
703
753
|
|
|
704
754
|
const trustedDomains = manifest.trusted_domains || []
|
|
705
755
|
|
|
706
|
-
// Check if
|
|
756
|
+
// Check if URL is trusted (supports wildcards and paths)
|
|
707
757
|
const isTrusted = trustedDomains.some(pattern =>
|
|
708
|
-
|
|
758
|
+
urlMatchesPattern(urlInfo, pattern)
|
|
709
759
|
)
|
|
710
760
|
|
|
711
761
|
if (!isTrusted) {
|
|
712
762
|
issues.push({
|
|
713
763
|
type: 'domain',
|
|
714
|
-
domain,
|
|
764
|
+
domain: urlInfo.hostname,
|
|
715
765
|
reason,
|
|
716
766
|
node,
|
|
717
767
|
})
|
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,
|