@akinon/pz-list-hover-image 2.0.6-rc.2
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/.prettierrc +13 -0
- package/CHANGELOG.md +3 -0
- package/README.md +369 -0
- package/package.json +20 -0
- package/src/index.ts +19 -0
- package/src/types/index.ts +83 -0
- package/src/utils/index.ts +41 -0
- package/src/views/list-hover-image.tsx +197 -0
package/.prettierrc
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"bracketSameLine": false,
|
|
3
|
+
"tabWidth": 2,
|
|
4
|
+
"singleQuote": true,
|
|
5
|
+
"jsxSingleQuote": false,
|
|
6
|
+
"bracketSpacing": true,
|
|
7
|
+
"semi": true,
|
|
8
|
+
"useTabs": false,
|
|
9
|
+
"arrowParens": "always",
|
|
10
|
+
"endOfLine": "lf",
|
|
11
|
+
"proseWrap": "never",
|
|
12
|
+
"trailingComma": "none"
|
|
13
|
+
}
|
package/CHANGELOG.md
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
# @akinon/pz-list-hover-image
|
|
2
|
+
|
|
3
|
+
A plugin that adds hover-to-switch image functionality to product cards on listing pages. When users hover over a product image, different product images are displayed with dot indicators at the bottom showing the currently active image.
|
|
4
|
+
|
|
5
|
+
The plugin accepts only the data it needs (`images`, `href`, `alt`) instead of the entire `Product` object, preventing unnecessary DOM bloat and data serialization.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npx @akinon/projectzero@latest --plugins
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
After selecting the plugin, make sure it is added to `plugins.js`:
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
// src/plugins.js
|
|
17
|
+
module.exports = [
|
|
18
|
+
// ...other plugins
|
|
19
|
+
'pz-list-hover-image'
|
|
20
|
+
];
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Basic Usage
|
|
24
|
+
|
|
25
|
+
### With PluginModule (Recommended)
|
|
26
|
+
|
|
27
|
+
```tsx
|
|
28
|
+
import PluginModule, { Component } from '@akinon/next/components/plugin-module';
|
|
29
|
+
|
|
30
|
+
<PluginModule
|
|
31
|
+
component={Component.ListHoverImage}
|
|
32
|
+
props={{
|
|
33
|
+
images: product.productimage_set,
|
|
34
|
+
href: product.absolute_url,
|
|
35
|
+
alt: product.name,
|
|
36
|
+
fill: true,
|
|
37
|
+
sizes: '(max-width: 768px) 50vw, 33vw',
|
|
38
|
+
aspectRatio: 3 / 4
|
|
39
|
+
}}
|
|
40
|
+
>
|
|
41
|
+
{/* Fallback rendered when plugin is disabled */}
|
|
42
|
+
<Image src={product.productimage_set[0]?.image} alt={product.name} />
|
|
43
|
+
</PluginModule>
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Direct Import
|
|
47
|
+
|
|
48
|
+
```tsx
|
|
49
|
+
import { ListHoverImage } from '@akinon/pz-list-hover-image';
|
|
50
|
+
|
|
51
|
+
<ListHoverImage
|
|
52
|
+
images={product.productimage_set}
|
|
53
|
+
href={product.absolute_url}
|
|
54
|
+
alt={product.name}
|
|
55
|
+
fill
|
|
56
|
+
sizes="(max-width: 768px) 50vw, 33vw"
|
|
57
|
+
aspectRatio={3 / 4}
|
|
58
|
+
/>
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Props
|
|
62
|
+
|
|
63
|
+
### `ListHoverImageProps`
|
|
64
|
+
|
|
65
|
+
| Property | Type | Required | Default | Description |
|
|
66
|
+
|---|---|---|---|---|
|
|
67
|
+
| `images` | `ProductImage[]` | Yes | - | Product image array. Each element must contain at least `{ image: string }`. You can pass `product.productimage_set` directly. |
|
|
68
|
+
| `href` | `string` | Yes | - | Product detail page URL. Hover zones link to this URL. |
|
|
69
|
+
| `alt` | `string` | Yes | - | Image alt text for accessibility. |
|
|
70
|
+
| `width` | `number` | No | - | Image width in pixels. Required when `fill` is not used. |
|
|
71
|
+
| `height` | `number` | No | - | Image height in pixels. Required when `fill` is not used. |
|
|
72
|
+
| `fill` | `boolean` | No | `false` | When `true`, the image fills its parent container. Must be used with `sizes` and `aspectRatio`. |
|
|
73
|
+
| `sizes` | `string` | No | - | Responsive image sizes attribute. Required in `fill` mode. |
|
|
74
|
+
| `aspectRatio` | `number` | No | - | Image aspect ratio. Required in `fill` mode. |
|
|
75
|
+
| `maxImages` | `number` | No | `3` | Maximum number of images to display. |
|
|
76
|
+
| `fallbackImage` | `string` | No | Akinon noimage URL | Fallback image URL shown when the product has no images. |
|
|
77
|
+
| `className` | `string` | No | - | CSS class added to the root container. |
|
|
78
|
+
| `settings` | `ListHoverImageSettings` | No | `defaultSettings` | Detailed configuration object. |
|
|
79
|
+
|
|
80
|
+
### `ProductImage`
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
interface ProductImage {
|
|
84
|
+
image: string;
|
|
85
|
+
[key: string]: any;
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`product.productimage_set` is compatible with this interface and can be passed directly.
|
|
90
|
+
|
|
91
|
+
## Settings
|
|
92
|
+
|
|
93
|
+
Use the `settings` prop to customize the plugin's behavior and appearance in detail.
|
|
94
|
+
|
|
95
|
+
### `ListHoverImageSettings`
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
interface ListHoverImageSettings {
|
|
99
|
+
maxImages?: number;
|
|
100
|
+
fallbackImage?: string;
|
|
101
|
+
customStyles?: ListHoverImageCustomStyles;
|
|
102
|
+
customRenderers?: ListHoverImageCustomRenderers;
|
|
103
|
+
theme?: {
|
|
104
|
+
indicatorSize?: string;
|
|
105
|
+
indicatorActiveColor?: string;
|
|
106
|
+
indicatorBorderColor?: string;
|
|
107
|
+
indicatorBorderRadius?: string;
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### Usage with Settings
|
|
113
|
+
|
|
114
|
+
#### Via settings.js (Global)
|
|
115
|
+
|
|
116
|
+
```js
|
|
117
|
+
// src/settings.js
|
|
118
|
+
module.exports = {
|
|
119
|
+
plugins: {
|
|
120
|
+
'pz-list-hover-image': {
|
|
121
|
+
maxImages: 5,
|
|
122
|
+
customStyles: {
|
|
123
|
+
indicatorActive: 'bg-black'
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
#### As a Prop (Per Component)
|
|
131
|
+
|
|
132
|
+
```tsx
|
|
133
|
+
<ListHoverImage
|
|
134
|
+
images={product.productimage_set}
|
|
135
|
+
href={product.absolute_url}
|
|
136
|
+
alt={product.name}
|
|
137
|
+
fill
|
|
138
|
+
sizes="(max-width: 768px) 50vw, 33vw"
|
|
139
|
+
aspectRatio={3 / 4}
|
|
140
|
+
settings={{
|
|
141
|
+
maxImages: 4,
|
|
142
|
+
fallbackImage: '/custom-noimage.jpg',
|
|
143
|
+
theme: {
|
|
144
|
+
indicatorActiveColor: 'bg-black',
|
|
145
|
+
indicatorBorderColor: 'border-gray-300',
|
|
146
|
+
indicatorBorderRadius: 'rounded-sm'
|
|
147
|
+
}
|
|
148
|
+
}}
|
|
149
|
+
/>
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## Custom Styles
|
|
153
|
+
|
|
154
|
+
Use `customStyles` to override CSS classes on each sub-element. All classes are merged via `tailwind-merge`, so you can safely override defaults.
|
|
155
|
+
|
|
156
|
+
### `ListHoverImageCustomStyles`
|
|
157
|
+
|
|
158
|
+
| Property | Target | Default |
|
|
159
|
+
|---|---|---|
|
|
160
|
+
| `container` | Root wrapper `div` | `relative` |
|
|
161
|
+
| `imageWrapper` | Wrapper `div` around the image | `relative` |
|
|
162
|
+
| `image` | `Image` component | - |
|
|
163
|
+
| `hoverZoneContainer` | Wrapper `div` for hover zones | `absolute inset-0 flex cursor-pointer justify-between` |
|
|
164
|
+
| `hoverZone` | Each hover zone (`Link`) | `relative w-full` |
|
|
165
|
+
| `indicatorContainer` | Indicator wrapper `div` | `absolute bottom-4 left-0 flex w-full justify-center` |
|
|
166
|
+
| `indicatorList` | Dot list `ul` | `flex gap-1.5 lg:gap-2` |
|
|
167
|
+
| `indicator` | Inactive dot `li` | `w-2.5 h-2.5 lg:w-3.5 lg:h-3.5 border border-white rounded-full` |
|
|
168
|
+
| `indicatorActive` | Active dot `li` | `bg-secondary border-transparent` |
|
|
169
|
+
|
|
170
|
+
### Example: Custom Styles
|
|
171
|
+
|
|
172
|
+
```tsx
|
|
173
|
+
<ListHoverImage
|
|
174
|
+
images={product.productimage_set}
|
|
175
|
+
href={product.absolute_url}
|
|
176
|
+
alt={product.name}
|
|
177
|
+
width={300}
|
|
178
|
+
height={400}
|
|
179
|
+
settings={{
|
|
180
|
+
customStyles: {
|
|
181
|
+
container: 'rounded-xl overflow-hidden shadow-lg',
|
|
182
|
+
indicatorContainer: 'absolute bottom-2 left-0 flex w-full justify-center',
|
|
183
|
+
indicatorList: 'flex gap-1 bg-black/30 rounded-full px-2 py-1',
|
|
184
|
+
indicator: 'w-2 h-2 rounded-full bg-white/50',
|
|
185
|
+
indicatorActive: 'w-2 h-2 rounded-full bg-white'
|
|
186
|
+
}
|
|
187
|
+
}}
|
|
188
|
+
/>
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
## Custom Renderers
|
|
192
|
+
|
|
193
|
+
For advanced customization, use `customRenderers` to completely replace component parts with your own render functions.
|
|
194
|
+
|
|
195
|
+
### `ListHoverImageCustomRenderers`
|
|
196
|
+
|
|
197
|
+
| Property | Description | Props |
|
|
198
|
+
|---|---|---|
|
|
199
|
+
| `renderImage` | Completely overrides the image component | `{ src, alt, width, height, fill, sizes, aspectRatio }` |
|
|
200
|
+
| `renderIndicator` | Overrides the entire indicator section | `{ count, activeIndex, onSelect }` |
|
|
201
|
+
| `renderIndicatorDot` | Overrides a single dot | `{ index, isActive, onClick }` |
|
|
202
|
+
|
|
203
|
+
### Example: Custom Image Renderer
|
|
204
|
+
|
|
205
|
+
```tsx
|
|
206
|
+
<ListHoverImage
|
|
207
|
+
images={product.productimage_set}
|
|
208
|
+
href={product.absolute_url}
|
|
209
|
+
alt={product.name}
|
|
210
|
+
fill
|
|
211
|
+
sizes="50vw"
|
|
212
|
+
aspectRatio={1}
|
|
213
|
+
settings={{
|
|
214
|
+
customRenderers: {
|
|
215
|
+
renderImage: ({ src, alt }) => (
|
|
216
|
+
<img
|
|
217
|
+
src={src}
|
|
218
|
+
alt={alt}
|
|
219
|
+
className="w-full h-full object-cover"
|
|
220
|
+
loading="lazy"
|
|
221
|
+
/>
|
|
222
|
+
)
|
|
223
|
+
}
|
|
224
|
+
}}
|
|
225
|
+
/>
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
### Example: Custom Indicator Dots (Bar Style)
|
|
229
|
+
|
|
230
|
+
```tsx
|
|
231
|
+
<ListHoverImage
|
|
232
|
+
images={product.productimage_set}
|
|
233
|
+
href={product.absolute_url}
|
|
234
|
+
alt={product.name}
|
|
235
|
+
width={300}
|
|
236
|
+
height={400}
|
|
237
|
+
settings={{
|
|
238
|
+
customRenderers: {
|
|
239
|
+
renderIndicatorDot: ({ index, isActive, onClick }) => (
|
|
240
|
+
<button
|
|
241
|
+
onClick={onClick}
|
|
242
|
+
className={`w-6 h-1 rounded-full transition-all ${
|
|
243
|
+
isActive ? 'bg-primary w-10' : 'bg-gray-300'
|
|
244
|
+
}`}
|
|
245
|
+
aria-label={`Image ${index + 1}`}
|
|
246
|
+
/>
|
|
247
|
+
)
|
|
248
|
+
}
|
|
249
|
+
}}
|
|
250
|
+
/>
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
### Example: Counter Indicator (1/3 Style)
|
|
254
|
+
|
|
255
|
+
```tsx
|
|
256
|
+
<ListHoverImage
|
|
257
|
+
images={product.productimage_set}
|
|
258
|
+
href={product.absolute_url}
|
|
259
|
+
alt={product.name}
|
|
260
|
+
width={300}
|
|
261
|
+
height={400}
|
|
262
|
+
settings={{
|
|
263
|
+
customRenderers: {
|
|
264
|
+
renderIndicator: ({ count, activeIndex }) => (
|
|
265
|
+
<div className="absolute bottom-2 right-2 bg-black/60 text-white text-xs rounded px-2 py-1">
|
|
266
|
+
{activeIndex + 1} / {count}
|
|
267
|
+
</div>
|
|
268
|
+
)
|
|
269
|
+
}
|
|
270
|
+
}}
|
|
271
|
+
/>
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
## Image Modes
|
|
275
|
+
|
|
276
|
+
The plugin supports two image modes:
|
|
277
|
+
|
|
278
|
+
### Fixed Dimensions (width/height)
|
|
279
|
+
|
|
280
|
+
Use with fixed width and height values:
|
|
281
|
+
|
|
282
|
+
```tsx
|
|
283
|
+
<ListHoverImage
|
|
284
|
+
images={product.productimage_set}
|
|
285
|
+
href={product.absolute_url}
|
|
286
|
+
alt={product.name}
|
|
287
|
+
width={300}
|
|
288
|
+
height={400}
|
|
289
|
+
/>
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
### Responsive Fill Mode
|
|
293
|
+
|
|
294
|
+
Fills the parent container responsively:
|
|
295
|
+
|
|
296
|
+
```tsx
|
|
297
|
+
<div className="w-full">
|
|
298
|
+
<ListHoverImage
|
|
299
|
+
images={product.productimage_set}
|
|
300
|
+
href={product.absolute_url}
|
|
301
|
+
alt={product.name}
|
|
302
|
+
fill
|
|
303
|
+
sizes="(max-width: 768px) 50vw, (max-width: 1024px) 30vw, 25vw"
|
|
304
|
+
aspectRatio={3 / 4}
|
|
305
|
+
/>
|
|
306
|
+
</div>
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
## Full Integration Example
|
|
310
|
+
|
|
311
|
+
Full integration within a product card:
|
|
312
|
+
|
|
313
|
+
```tsx
|
|
314
|
+
'use client';
|
|
315
|
+
|
|
316
|
+
import { Product } from '@akinon/next/types';
|
|
317
|
+
import { Price, Link } from '@theme/components';
|
|
318
|
+
import { Image } from '@akinon/next/components/image';
|
|
319
|
+
import PluginModule, { Component } from '@akinon/next/components/plugin-module';
|
|
320
|
+
|
|
321
|
+
interface ProductCardProps {
|
|
322
|
+
product: Product;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export function ProductCard({ product }: ProductCardProps) {
|
|
326
|
+
const imageUrl = product.productimage_set[0]?.image;
|
|
327
|
+
|
|
328
|
+
return (
|
|
329
|
+
<div className="group">
|
|
330
|
+
<div className="relative mb-3 overflow-hidden rounded-lg">
|
|
331
|
+
<PluginModule
|
|
332
|
+
component={Component.ListHoverImage}
|
|
333
|
+
props={{
|
|
334
|
+
images: product.productimage_set,
|
|
335
|
+
href: product.absolute_url,
|
|
336
|
+
alt: product.name,
|
|
337
|
+
fill: true,
|
|
338
|
+
sizes: '(max-width: 768px) 50vw, 33vw',
|
|
339
|
+
aspectRatio: 3 / 4,
|
|
340
|
+
settings: {
|
|
341
|
+
maxImages: 4,
|
|
342
|
+
customStyles: {
|
|
343
|
+
container: 'rounded-lg overflow-hidden',
|
|
344
|
+
indicatorActive: 'bg-black'
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}}
|
|
348
|
+
>
|
|
349
|
+
{/* Fallback when plugin is disabled */}
|
|
350
|
+
<Link href={product.absolute_url}>
|
|
351
|
+
<Image
|
|
352
|
+
fill
|
|
353
|
+
src={imageUrl || '/noimage.jpg'}
|
|
354
|
+
alt={product.name}
|
|
355
|
+
aspectRatio={3 / 4}
|
|
356
|
+
sizes="(max-width: 768px) 50vw, 33vw"
|
|
357
|
+
/>
|
|
358
|
+
</Link>
|
|
359
|
+
</PluginModule>
|
|
360
|
+
</div>
|
|
361
|
+
|
|
362
|
+
<Link href={product.absolute_url}>
|
|
363
|
+
<h3 className="text-sm">{product.name}</h3>
|
|
364
|
+
</Link>
|
|
365
|
+
<Price value={product.price} className="font-semibold" />
|
|
366
|
+
</div>
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@akinon/pz-list-hover-image",
|
|
3
|
+
"version": "2.0.6-rc.2",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"peerDependencies": {
|
|
7
|
+
"react": "^18.0.0",
|
|
8
|
+
"react-dom": "^18.0.0"
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"clsx": "^2.0.0",
|
|
12
|
+
"tailwind-merge": "^2.0.0"
|
|
13
|
+
},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"@types/node": "^18.7.8",
|
|
16
|
+
"@types/react": "^18.0.17",
|
|
17
|
+
"@types/react-dom": "^18.0.6",
|
|
18
|
+
"typescript": "^5.2.2"
|
|
19
|
+
}
|
|
20
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Views
|
|
2
|
+
export { ListHoverImage } from './views/list-hover-image';
|
|
3
|
+
|
|
4
|
+
// Types
|
|
5
|
+
export type {
|
|
6
|
+
ListHoverImageProps,
|
|
7
|
+
ListHoverImageSettings,
|
|
8
|
+
ListHoverImageCustomStyles,
|
|
9
|
+
ListHoverImageCustomRenderers,
|
|
10
|
+
IndicatorProps,
|
|
11
|
+
HoverZoneProps,
|
|
12
|
+
ProductImage
|
|
13
|
+
} from './types';
|
|
14
|
+
|
|
15
|
+
// Utils
|
|
16
|
+
export { mergeSettings, defaultSettings } from './utils';
|
|
17
|
+
|
|
18
|
+
// Default export for PluginModule
|
|
19
|
+
export { ListHoverImage as default } from './views/list-hover-image';
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
export interface ProductImage {
|
|
4
|
+
image: string;
|
|
5
|
+
[key: string]: any;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface ListHoverImageProps {
|
|
9
|
+
images: ProductImage[];
|
|
10
|
+
href: string;
|
|
11
|
+
alt: string;
|
|
12
|
+
width?: number;
|
|
13
|
+
height?: number;
|
|
14
|
+
fill?: boolean;
|
|
15
|
+
sizes?: string;
|
|
16
|
+
aspectRatio?: number;
|
|
17
|
+
maxImages?: number;
|
|
18
|
+
fallbackImage?: string;
|
|
19
|
+
className?: string;
|
|
20
|
+
settings?: ListHoverImageSettings;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface IndicatorProps {
|
|
24
|
+
count: number;
|
|
25
|
+
activeIndex: number;
|
|
26
|
+
onSelect: (index: number) => void;
|
|
27
|
+
customStyles?: ListHoverImageCustomStyles;
|
|
28
|
+
customRenderers?: ListHoverImageCustomRenderers;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface HoverZoneProps {
|
|
32
|
+
count: number;
|
|
33
|
+
href: string;
|
|
34
|
+
onHover: (index: number) => void;
|
|
35
|
+
customStyles?: ListHoverImageCustomStyles;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ListHoverImageCustomStyles {
|
|
39
|
+
container?: string;
|
|
40
|
+
imageWrapper?: string;
|
|
41
|
+
image?: string;
|
|
42
|
+
hoverZoneContainer?: string;
|
|
43
|
+
hoverZone?: string;
|
|
44
|
+
indicatorContainer?: string;
|
|
45
|
+
indicatorList?: string;
|
|
46
|
+
indicator?: string;
|
|
47
|
+
indicatorActive?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ListHoverImageCustomRenderers {
|
|
51
|
+
renderImage?: (props: {
|
|
52
|
+
src: string;
|
|
53
|
+
alt: string;
|
|
54
|
+
width?: number;
|
|
55
|
+
height?: number;
|
|
56
|
+
fill?: boolean;
|
|
57
|
+
sizes?: string;
|
|
58
|
+
aspectRatio?: number;
|
|
59
|
+
}) => React.ReactNode;
|
|
60
|
+
renderIndicator?: (props: {
|
|
61
|
+
count: number;
|
|
62
|
+
activeIndex: number;
|
|
63
|
+
onSelect: (index: number) => void;
|
|
64
|
+
}) => React.ReactNode;
|
|
65
|
+
renderIndicatorDot?: (props: {
|
|
66
|
+
index: number;
|
|
67
|
+
isActive: boolean;
|
|
68
|
+
onClick: () => void;
|
|
69
|
+
}) => React.ReactNode;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface ListHoverImageSettings {
|
|
73
|
+
maxImages?: number;
|
|
74
|
+
fallbackImage?: string;
|
|
75
|
+
customStyles?: ListHoverImageCustomStyles;
|
|
76
|
+
customRenderers?: ListHoverImageCustomRenderers;
|
|
77
|
+
theme?: {
|
|
78
|
+
indicatorSize?: string;
|
|
79
|
+
indicatorActiveColor?: string;
|
|
80
|
+
indicatorBorderColor?: string;
|
|
81
|
+
indicatorBorderRadius?: string;
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { ListHoverImageSettings } from '../types';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_FALLBACK_IMAGE = '/noimage.jpg';
|
|
4
|
+
|
|
5
|
+
export const defaultSettings: Required<ListHoverImageSettings> = {
|
|
6
|
+
maxImages: 3,
|
|
7
|
+
fallbackImage: DEFAULT_FALLBACK_IMAGE,
|
|
8
|
+
customStyles: {},
|
|
9
|
+
customRenderers: {},
|
|
10
|
+
theme: {
|
|
11
|
+
indicatorSize: 'w-2.5 h-2.5 lg:w-3.5 lg:h-3.5',
|
|
12
|
+
indicatorActiveColor: 'bg-secondary',
|
|
13
|
+
indicatorBorderColor: 'border-white',
|
|
14
|
+
indicatorBorderRadius: 'rounded-full'
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function mergeSettings(
|
|
19
|
+
userSettings?: Partial<ListHoverImageSettings>
|
|
20
|
+
): Required<ListHoverImageSettings> {
|
|
21
|
+
if (!userSettings) {
|
|
22
|
+
return defaultSettings;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
...defaultSettings,
|
|
27
|
+
...userSettings,
|
|
28
|
+
customStyles: {
|
|
29
|
+
...defaultSettings.customStyles,
|
|
30
|
+
...userSettings.customStyles
|
|
31
|
+
},
|
|
32
|
+
customRenderers: {
|
|
33
|
+
...defaultSettings.customRenderers,
|
|
34
|
+
...userSettings.customRenderers
|
|
35
|
+
},
|
|
36
|
+
theme: {
|
|
37
|
+
...defaultSettings.theme,
|
|
38
|
+
...userSettings.theme
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import React, { useState, useCallback, useMemo } from 'react';
|
|
4
|
+
import { twMerge } from 'tailwind-merge';
|
|
5
|
+
import clsx from 'clsx';
|
|
6
|
+
import { Image } from '@akinon/next/components/image';
|
|
7
|
+
import { Link } from '@akinon/next/components/link';
|
|
8
|
+
import type {
|
|
9
|
+
ListHoverImageProps,
|
|
10
|
+
IndicatorProps,
|
|
11
|
+
HoverZoneProps
|
|
12
|
+
} from '../types';
|
|
13
|
+
import { mergeSettings } from '../utils';
|
|
14
|
+
|
|
15
|
+
function HoverZones({ count, href, onHover, customStyles }: HoverZoneProps) {
|
|
16
|
+
return (
|
|
17
|
+
<div
|
|
18
|
+
className={twMerge(
|
|
19
|
+
'absolute inset-0 flex cursor-pointer justify-between',
|
|
20
|
+
customStyles?.hoverZoneContainer
|
|
21
|
+
)}
|
|
22
|
+
>
|
|
23
|
+
{Array.from({ length: count }, (_, index) => (
|
|
24
|
+
<Link
|
|
25
|
+
href={href}
|
|
26
|
+
key={index}
|
|
27
|
+
className={twMerge('relative w-full', customStyles?.hoverZone)}
|
|
28
|
+
onMouseEnter={() => onHover(index)}
|
|
29
|
+
aria-label={`View image ${index + 1}`}
|
|
30
|
+
/>
|
|
31
|
+
))}
|
|
32
|
+
</div>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function Indicators({
|
|
37
|
+
count,
|
|
38
|
+
activeIndex,
|
|
39
|
+
onSelect,
|
|
40
|
+
customStyles,
|
|
41
|
+
customRenderers
|
|
42
|
+
}: IndicatorProps) {
|
|
43
|
+
if (count <= 1) return null;
|
|
44
|
+
|
|
45
|
+
if (customRenderers?.renderIndicator) {
|
|
46
|
+
return (
|
|
47
|
+
<>
|
|
48
|
+
{customRenderers.renderIndicator({
|
|
49
|
+
count,
|
|
50
|
+
activeIndex,
|
|
51
|
+
onSelect
|
|
52
|
+
})}
|
|
53
|
+
</>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<div
|
|
59
|
+
className={twMerge(
|
|
60
|
+
'absolute bottom-4 left-0 flex w-full justify-center',
|
|
61
|
+
customStyles?.indicatorContainer
|
|
62
|
+
)}
|
|
63
|
+
>
|
|
64
|
+
<ul
|
|
65
|
+
className={twMerge(
|
|
66
|
+
'flex gap-1.5 lg:gap-2',
|
|
67
|
+
customStyles?.indicatorList
|
|
68
|
+
)}
|
|
69
|
+
role="tablist"
|
|
70
|
+
aria-label="Product images"
|
|
71
|
+
>
|
|
72
|
+
{Array.from({ length: count }, (_, index) => {
|
|
73
|
+
if (customRenderers?.renderIndicatorDot) {
|
|
74
|
+
return (
|
|
75
|
+
<li key={index} role="tab" aria-selected={activeIndex === index}>
|
|
76
|
+
{customRenderers.renderIndicatorDot({
|
|
77
|
+
index,
|
|
78
|
+
isActive: activeIndex === index,
|
|
79
|
+
onClick: () => onSelect(index)
|
|
80
|
+
})}
|
|
81
|
+
</li>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<li
|
|
87
|
+
key={index}
|
|
88
|
+
role="tab"
|
|
89
|
+
aria-selected={activeIndex === index}
|
|
90
|
+
className={twMerge(
|
|
91
|
+
clsx(
|
|
92
|
+
'w-2.5 h-2.5 lg:w-3.5 lg:h-3.5 border rounded-full cursor-pointer transition-colors duration-200',
|
|
93
|
+
'border-white',
|
|
94
|
+
{
|
|
95
|
+
'bg-secondary border-transparent': activeIndex === index
|
|
96
|
+
}
|
|
97
|
+
),
|
|
98
|
+
activeIndex === index
|
|
99
|
+
? customStyles?.indicatorActive
|
|
100
|
+
: customStyles?.indicator
|
|
101
|
+
)}
|
|
102
|
+
onClick={() => onSelect(index)}
|
|
103
|
+
/>
|
|
104
|
+
);
|
|
105
|
+
})}
|
|
106
|
+
</ul>
|
|
107
|
+
</div>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function ListHoverImage({
|
|
112
|
+
images,
|
|
113
|
+
href,
|
|
114
|
+
alt,
|
|
115
|
+
width,
|
|
116
|
+
height,
|
|
117
|
+
fill,
|
|
118
|
+
sizes,
|
|
119
|
+
aspectRatio,
|
|
120
|
+
maxImages,
|
|
121
|
+
fallbackImage,
|
|
122
|
+
className,
|
|
123
|
+
settings: userSettings
|
|
124
|
+
}: ListHoverImageProps) {
|
|
125
|
+
const settings = mergeSettings(userSettings);
|
|
126
|
+
|
|
127
|
+
const resolvedMaxImages = maxImages ?? settings.maxImages;
|
|
128
|
+
const resolvedFallback = fallbackImage ?? settings.fallbackImage;
|
|
129
|
+
|
|
130
|
+
const visibleImages = useMemo(
|
|
131
|
+
() => images?.slice(0, resolvedMaxImages) ?? [],
|
|
132
|
+
[images, resolvedMaxImages]
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
const initialImageUrl = visibleImages[0]?.image || resolvedFallback;
|
|
136
|
+
|
|
137
|
+
const [activeIndex, setActiveIndex] = useState(0);
|
|
138
|
+
const [currentImageUrl, setCurrentImageUrl] = useState(initialImageUrl);
|
|
139
|
+
|
|
140
|
+
const handleImageChange = useCallback(
|
|
141
|
+
(index: number) => {
|
|
142
|
+
setActiveIndex(index);
|
|
143
|
+
setCurrentImageUrl(visibleImages[index]?.image || resolvedFallback);
|
|
144
|
+
},
|
|
145
|
+
[visibleImages, resolvedFallback]
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
const imageElement = settings.customRenderers.renderImage ? (
|
|
149
|
+
settings.customRenderers.renderImage({
|
|
150
|
+
src: currentImageUrl,
|
|
151
|
+
alt,
|
|
152
|
+
width,
|
|
153
|
+
height,
|
|
154
|
+
fill,
|
|
155
|
+
sizes,
|
|
156
|
+
aspectRatio
|
|
157
|
+
})
|
|
158
|
+
) : (
|
|
159
|
+
<Image
|
|
160
|
+
src={currentImageUrl}
|
|
161
|
+
alt={alt}
|
|
162
|
+
{...(fill
|
|
163
|
+
? { fill: true, sizes: sizes!, aspectRatio: aspectRatio! }
|
|
164
|
+
: { width: width!, height: height! })}
|
|
165
|
+
className={settings.customStyles.image}
|
|
166
|
+
/>
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
return (
|
|
170
|
+
<div
|
|
171
|
+
className={twMerge(
|
|
172
|
+
'relative',
|
|
173
|
+
settings.customStyles.container,
|
|
174
|
+
className
|
|
175
|
+
)}
|
|
176
|
+
>
|
|
177
|
+
<div className={twMerge('relative', settings.customStyles.imageWrapper)}>
|
|
178
|
+
{imageElement}
|
|
179
|
+
</div>
|
|
180
|
+
|
|
181
|
+
<HoverZones
|
|
182
|
+
count={visibleImages.length}
|
|
183
|
+
href={href}
|
|
184
|
+
onHover={handleImageChange}
|
|
185
|
+
customStyles={settings.customStyles}
|
|
186
|
+
/>
|
|
187
|
+
|
|
188
|
+
<Indicators
|
|
189
|
+
count={visibleImages.length}
|
|
190
|
+
activeIndex={activeIndex}
|
|
191
|
+
onSelect={handleImageChange}
|
|
192
|
+
customStyles={settings.customStyles}
|
|
193
|
+
customRenderers={settings.customRenderers}
|
|
194
|
+
/>
|
|
195
|
+
</div>
|
|
196
|
+
);
|
|
197
|
+
}
|