srcdev-nuxt-components 9.2.0 → 9.2.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/.claude/settings.json +5 -2
- package/.claude/skills/components/banner-video.md +88 -23
- package/.claude/skills/index.md +1 -1
- package/app/components/01.atoms/banner-video/BannerVideo.vue +62 -29
- package/app/components/01.atoms/banner-video/CONSUMER-STYLING.md +120 -0
- package/app/components/01.atoms/banner-video/stories/BannerVideo.stories.ts +53 -2
- package/app/components/01.atoms/banner-video/tests/BannerVideo.spec.ts +95 -0
- package/app/components/01.atoms/banner-video/tests/__snapshots__/BannerVideo.spec.ts.snap +2 -2
- package/app/components/02.molecules/input-copy/CONSUMER-STYLING.md +143 -0
- package/app/components/02.molecules/input-copy/InputCopy.vue +195 -0
- package/app/components/02.molecules/input-copy/stories/InputCopy.stories.ts +110 -0
- package/app/components/02.molecules/input-copy/tests/InputCopy.spec.ts +181 -0
- package/app/components/02.molecules/pricing-card/CONSUMER-STYLING.md +149 -0
- package/app/components/02.molecules/pricing-card/PricingCard.vue +222 -0
- package/app/components/02.molecules/pricing-card/stories/PricingCard.stories.ts +160 -0
- package/app/components/02.molecules/pricing-card/tests/PricingCard.spec.ts +139 -0
- package/package.json +1 -1
- package/.claude/skills/components/input-copy-core.md +0 -66
- package/app/components/05.forms/input-copy/InputCopyCore.vue +0 -132
- package/app/components/05.forms/input-copy/stories/InputCopyCore.stories.ts +0 -89
- package/app/components/05.forms/input-copy/tests/InputCopyCore.spec.ts +0 -212
- package/app/components/05.forms/input-copy/tests/__snapshots__/InputCopyCore.spec.ts.snap +0 -28
|
@@ -127,6 +127,51 @@ describe("BannerVideo", () => {
|
|
|
127
127
|
expect(play).toHaveBeenCalled();
|
|
128
128
|
});
|
|
129
129
|
|
|
130
|
+
// ─── Pause/play toggle (WCAG 2.2.2) ─────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
it("renders a pause/play toggle button", async () => {
|
|
133
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
134
|
+
expect(wrapper.find(".banner-video__toggle").exists()).toBe(true);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("toggle defaults to a 'Pause background video' label once playback starts", async () => {
|
|
138
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
139
|
+
await wrapper.find("video").trigger("play");
|
|
140
|
+
expect(wrapper.find(".banner-video__toggle").attributes("aria-label")).toBe("Pause background video");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("toggle reads 'Play background video' before playback has started", async () => {
|
|
144
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
145
|
+
expect(wrapper.find(".banner-video__toggle").attributes("aria-label")).toBe("Play background video");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("clicking the toggle calls pause() while playing", async () => {
|
|
149
|
+
const pause = vi.fn();
|
|
150
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
151
|
+
const videoElement = wrapper.find("video").element as HTMLVideoElement;
|
|
152
|
+
videoElement.pause = pause;
|
|
153
|
+
Object.defineProperty(videoElement, "paused", { value: false, configurable: true });
|
|
154
|
+
await wrapper.find(".banner-video__toggle").trigger("click");
|
|
155
|
+
expect(pause).toHaveBeenCalled();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("clicking the toggle calls play() while paused", async () => {
|
|
159
|
+
const play = vi.fn().mockResolvedValue(undefined);
|
|
160
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
161
|
+
const videoElement = wrapper.find("video").element as HTMLVideoElement;
|
|
162
|
+
videoElement.play = play;
|
|
163
|
+
Object.defineProperty(videoElement, "paused", { value: true, configurable: true });
|
|
164
|
+
await wrapper.find(".banner-video__toggle").trigger("click");
|
|
165
|
+
expect(play).toHaveBeenCalled();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("updates aria-label to 'Play background video' after the video pauses", async () => {
|
|
169
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
170
|
+
await wrapper.find("video").trigger("play");
|
|
171
|
+
await wrapper.find("video").trigger("pause");
|
|
172
|
+
expect(wrapper.find(".banner-video__toggle").attributes("aria-label")).toBe("Play background video");
|
|
173
|
+
});
|
|
174
|
+
|
|
130
175
|
// ─── Fallback image ──────────────────────────────────────────────────────
|
|
131
176
|
|
|
132
177
|
it("renders a NuxtImg fallback element", async () => {
|
|
@@ -349,4 +394,54 @@ describe("BannerVideo", () => {
|
|
|
349
394
|
expect(wrapper.classes()).not.toContain("original");
|
|
350
395
|
expect(wrapper.classes()).toContain("updated");
|
|
351
396
|
});
|
|
397
|
+
|
|
398
|
+
// ─── Toggle icon ─────────────────────────────────────────────────────────
|
|
399
|
+
|
|
400
|
+
it("renders the default play icon before playback starts", async () => {
|
|
401
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
402
|
+
expect(wrapper.find(".banner-video__toggle").html()).toContain("mdi:play");
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it("renders the default pause icon once playback starts", async () => {
|
|
406
|
+
const wrapper = await mountSuspended(BannerVideo, { props: defaultProps });
|
|
407
|
+
await wrapper.find("video").trigger("play");
|
|
408
|
+
expect(wrapper.find(".banner-video__toggle").html()).toContain("mdi:pause");
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it("renders a custom playIcon", async () => {
|
|
412
|
+
const wrapper = await mountSuspended(BannerVideo, {
|
|
413
|
+
props: { ...defaultProps, playIcon: "mdi:play-circle" },
|
|
414
|
+
});
|
|
415
|
+
expect(wrapper.find(".banner-video__toggle").html()).toContain("mdi:play-circle");
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it("renders a custom pauseIcon", async () => {
|
|
419
|
+
const wrapper = await mountSuspended(BannerVideo, {
|
|
420
|
+
props: { ...defaultProps, pauseIcon: "mdi:pause-circle" },
|
|
421
|
+
});
|
|
422
|
+
await wrapper.find("video").trigger("play");
|
|
423
|
+
expect(wrapper.find(".banner-video__toggle").html()).toContain("mdi:pause-circle");
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it("replaces the toggle icon via the toggle-icon slot", async () => {
|
|
427
|
+
const wrapper = await mountSuspended(BannerVideo, {
|
|
428
|
+
props: defaultProps,
|
|
429
|
+
slots: {
|
|
430
|
+
"toggle-icon": '<span class="test-custom-icon">custom</span>',
|
|
431
|
+
},
|
|
432
|
+
});
|
|
433
|
+
expect(wrapper.find(".test-custom-icon").exists()).toBe(true);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it("passes isPlaying to the toggle-icon slot", async () => {
|
|
437
|
+
const wrapper = await mountSuspended(BannerVideo, {
|
|
438
|
+
props: defaultProps,
|
|
439
|
+
slots: {
|
|
440
|
+
"toggle-icon": `<span class="test-is-playing">{{ params.isPlaying }}</span>`,
|
|
441
|
+
},
|
|
442
|
+
});
|
|
443
|
+
expect(wrapper.find(".test-is-playing").text()).toBe("false");
|
|
444
|
+
await wrapper.find("video").trigger("play");
|
|
445
|
+
expect(wrapper.find(".test-is-playing").text()).toBe("true");
|
|
446
|
+
});
|
|
352
447
|
});
|
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
exports[`BannerVideo > renders correct HTML structure (all props set) 1`] = `
|
|
4
4
|
"<header class="banner-video full-bleed" data-depth="xl" style="--_aspect-ratio: 16/9; --_align-self: center; --_justify-self: center;"><video class="video" autoplay="" muted="" loop="" playsinline="" preload="auto" fetchpriority="high" poster="/images/banners/video/lake-banner.jpg" style="object-fit: cover;">
|
|
5
5
|
<source src="/images/banners/video/lake-banner.mp4" type="video/mp4">
|
|
6
|
-
</video><img width="1280" height="720" data-nuxt-img="" srcset="/_ipx/s_1280x720/images/banners/video/lake-banner.jpg 1x, /_ipx/s_2560x1440/images/banners/video/lake-banner.jpg 2x" class="fallback" alt="Lake banner" loading="eager" fetchpriority="high" decoding="async" style="object-fit: cover; object-position: center center;" src="/_ipx/s_1280x720/images/banners/video/lake-banner.jpg"></header>"
|
|
6
|
+
</video><img width="1280" height="720" data-nuxt-img="" srcset="/_ipx/s_1280x720/images/banners/video/lake-banner.jpg 1x, /_ipx/s_2560x1440/images/banners/video/lake-banner.jpg 2x" class="fallback" alt="Lake banner" loading="eager" fetchpriority="high" decoding="async" style="object-fit: cover; object-position: center center;" src="/_ipx/s_1280x720/images/banners/video/lake-banner.jpg"><button type="button" class="banner-video__toggle" aria-label="Play background video"><span class="iconify i-mdi:play" aria-hidden="true"></span></button></header>"
|
|
7
7
|
`;
|
|
8
8
|
|
|
9
9
|
exports[`BannerVideo > renders correct HTML structure (default props) 1`] = `
|
|
10
10
|
"<div class="banner-video" data-depth="md" style="--_aspect-ratio: 21/9; --_align-self: center; --_justify-self: center;"><video class="video" autoplay="" muted="" loop="" playsinline="" preload="auto" fetchpriority="high" poster="/images/banners/video/lake-banner.jpg" style="object-fit: cover;">
|
|
11
11
|
<source src="/images/banners/video/lake-banner.mp4" type="video/mp4">
|
|
12
|
-
</video><img width="1920" height="1080" data-nuxt-img="" srcset="/_ipx/s_1920x1080/images/banners/video/lake-banner.jpg 1x, /_ipx/s_3840x2160/images/banners/video/lake-banner.jpg 2x" class="fallback" alt="" loading="eager" fetchpriority="high" decoding="async" style="object-fit: cover; object-position: center center;" src="/_ipx/s_1920x1080/images/banners/video/lake-banner.jpg"></div>"
|
|
12
|
+
</video><img width="1920" height="1080" data-nuxt-img="" srcset="/_ipx/s_1920x1080/images/banners/video/lake-banner.jpg 1x, /_ipx/s_3840x2160/images/banners/video/lake-banner.jpg 2x" class="fallback" alt="" loading="eager" fetchpriority="high" decoding="async" style="object-fit: cover; object-position: center center;" src="/_ipx/s_1920x1080/images/banners/video/lake-banner.jpg"><button type="button" class="banner-video__toggle" aria-label="Play background video"><span class="iconify i-mdi:play" aria-hidden="true"></span></button></div>"
|
|
13
13
|
`;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# InputCopy — Consumer Styling Guide
|
|
2
|
+
|
|
3
|
+
## Public token API
|
|
4
|
+
|
|
5
|
+
All `--input-copy-*` tokens are the stable override surface. Set them at any scope (global, page,
|
|
6
|
+
or instance) without touching the component itself.
|
|
7
|
+
|
|
8
|
+
| Token | Default | Controls |
|
|
9
|
+
|---|---|---|
|
|
10
|
+
| `--input-copy-input-bg` | `var(--slate-00)` | Input field background colour |
|
|
11
|
+
| `--input-copy-input-border` | `1px solid var(--slate-03)` | Input field border |
|
|
12
|
+
| `--input-copy-input-border-radius` | `0.4rem` | Input field corner rounding |
|
|
13
|
+
| `--input-copy-input-padding` | `0.8rem 1rem` | Input field padding |
|
|
14
|
+
| `--input-copy-input-text-color` | `#333` | Input text colour |
|
|
15
|
+
| `--input-copy-input-placeholder-color` | `#999` | Placeholder text colour |
|
|
16
|
+
| `--input-copy-input-font-size` | `0.95rem` | Input field font size |
|
|
17
|
+
| `--input-copy-button-bg` | `var(--theme-button-primary-surface)` | Copy button background |
|
|
18
|
+
| `--input-copy-button-bg-hover` | `var(--theme-button-primary-surface-hover)` | Copy button background on hover |
|
|
19
|
+
| `--input-copy-button-text-color` | `var(--theme-button-primary-text)` | Copy button text colour |
|
|
20
|
+
| `--input-copy-button-padding` | `0.8rem 1.2rem` | Copy button padding |
|
|
21
|
+
| `--input-copy-button-border-radius` | `0.4rem` | Copy button corner rounding |
|
|
22
|
+
| `--input-copy-button-gap` | `0.6rem` | Gap between icon and text in button |
|
|
23
|
+
| `--input-copy-description-color` | `#666` | Description text colour |
|
|
24
|
+
| `--input-copy-description-font-size` | `0.85rem` | Description font size |
|
|
25
|
+
| `--input-copy-description-margin` | `0.6rem 0 0 0` | Description margin |
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Global theming — app-level CSS file
|
|
30
|
+
|
|
31
|
+
Create `assets/styles/setup/07.components/input-copy.css` in the consuming app and set tokens on
|
|
32
|
+
`:root`. These values apply to every `InputCopy` instance across the site.
|
|
33
|
+
|
|
34
|
+
```css
|
|
35
|
+
/* assets/styles/setup/07.components/input-copy.css */
|
|
36
|
+
:root {
|
|
37
|
+
--input-copy-input-border-radius: 0.6rem;
|
|
38
|
+
--input-copy-input-padding: 1rem 1.2rem;
|
|
39
|
+
--input-copy-button-border-radius: 0.6rem;
|
|
40
|
+
--input-copy-button-gap: 0.8rem;
|
|
41
|
+
--input-copy-description-color: var(--slate-06);
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## Per-instance overrides via inline styles
|
|
48
|
+
|
|
49
|
+
Override tokens directly on a single InputCopy instance:
|
|
50
|
+
|
|
51
|
+
```vue
|
|
52
|
+
<InputCopy
|
|
53
|
+
value="sk_live_abc123"
|
|
54
|
+
label="API key"
|
|
55
|
+
style="
|
|
56
|
+
--input-copy-input-border-radius: 0.8rem;
|
|
57
|
+
--input-copy-button-bg: var(--brand-success);
|
|
58
|
+
"
|
|
59
|
+
/>
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## Page-scoped overrides
|
|
65
|
+
|
|
66
|
+
When input-copy appears in a specific page context (e.g., checkout success page), scope overrides
|
|
67
|
+
under the page wrapper:
|
|
68
|
+
|
|
69
|
+
```css
|
|
70
|
+
/* In the consuming page's unscoped <style> block */
|
|
71
|
+
.success-page {
|
|
72
|
+
.input-copy {
|
|
73
|
+
--input-copy-input-bg: var(--brand-surface-light);
|
|
74
|
+
--input-copy-input-border: 2px solid var(--brand-primary);
|
|
75
|
+
--input-copy-button-bg: var(--brand-primary);
|
|
76
|
+
--input-copy-button-bg-hover: var(--brand-primary-hover);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## License key display pattern
|
|
84
|
+
|
|
85
|
+
Typical usage on a success page or dashboard to display a license key:
|
|
86
|
+
|
|
87
|
+
```vue
|
|
88
|
+
<InputCopy
|
|
89
|
+
:value="licenseKey"
|
|
90
|
+
label="License key"
|
|
91
|
+
description="Your license key is ready to use. Copy it and add to your component."
|
|
92
|
+
buttonText="Copy"
|
|
93
|
+
copiedText="Copied!"
|
|
94
|
+
@copied="showFeedback"
|
|
95
|
+
/>
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
```css
|
|
99
|
+
.success-page {
|
|
100
|
+
.input-copy {
|
|
101
|
+
max-width: 500px;
|
|
102
|
+
|
|
103
|
+
--input-copy-input-bg: #f9f9f9;
|
|
104
|
+
--input-copy-input-border: 1px solid var(--slate-02);
|
|
105
|
+
--input-copy-input-text-color: var(--slate-09);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## Readonly input styling
|
|
113
|
+
|
|
114
|
+
The input field is always readonly to prevent accidental edits. Style it distinctly if needed:
|
|
115
|
+
|
|
116
|
+
```css
|
|
117
|
+
.input-copy {
|
|
118
|
+
.input-copy__input {
|
|
119
|
+
background-color: var(--slate-00);
|
|
120
|
+
color: var(--slate-09);
|
|
121
|
+
cursor: default;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## Button text visibility on small screens
|
|
129
|
+
|
|
130
|
+
By default, the button text is hidden on screens < 480px. Override for always-visible text:
|
|
131
|
+
|
|
132
|
+
```css
|
|
133
|
+
.input-copy__button-text {
|
|
134
|
+
display: inline !important;
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Or show only the icon on mobile and text on desktop (default behavior):
|
|
139
|
+
|
|
140
|
+
```css
|
|
141
|
+
/* Default: button shows only icon on mobile, icon + text on desktop (480px+) */
|
|
142
|
+
/* No override needed; this is the built-in behavior */
|
|
143
|
+
```
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="input-copy" :class="elementClasses">
|
|
3
|
+
<div class="input-copy__wrapper">
|
|
4
|
+
<input
|
|
5
|
+
type="text"
|
|
6
|
+
class="input-copy__input"
|
|
7
|
+
:value="value"
|
|
8
|
+
:aria-label="ariaLabel || label"
|
|
9
|
+
readonly
|
|
10
|
+
/>
|
|
11
|
+
<button
|
|
12
|
+
type="button"
|
|
13
|
+
class="input-copy__button"
|
|
14
|
+
:aria-label="`Copy ${ariaLabel || label || 'value'} to clipboard`"
|
|
15
|
+
:disabled="copied || isDisabled"
|
|
16
|
+
@click="handleCopy"
|
|
17
|
+
>
|
|
18
|
+
<Icon name="lucide:copy" class="input-copy__icon" />
|
|
19
|
+
<span class="input-copy__button-text">{{ copied ? copiedText : buttonText }}</span>
|
|
20
|
+
</button>
|
|
21
|
+
</div>
|
|
22
|
+
<p v-if="description" class="input-copy__description">{{ description }}</p>
|
|
23
|
+
</div>
|
|
24
|
+
</template>
|
|
25
|
+
|
|
26
|
+
<script setup lang="ts">
|
|
27
|
+
interface Props {
|
|
28
|
+
value: string;
|
|
29
|
+
label?: string;
|
|
30
|
+
ariaLabel?: string;
|
|
31
|
+
description?: string;
|
|
32
|
+
buttonText?: string;
|
|
33
|
+
copiedText?: string;
|
|
34
|
+
copiedDuration?: number;
|
|
35
|
+
isDisabled?: boolean;
|
|
36
|
+
styleClassPassthrough?: string | string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
40
|
+
label: undefined,
|
|
41
|
+
ariaLabel: undefined,
|
|
42
|
+
description: undefined,
|
|
43
|
+
buttonText: "Copy",
|
|
44
|
+
copiedText: "Copied!",
|
|
45
|
+
copiedDuration: 2000,
|
|
46
|
+
isDisabled: false,
|
|
47
|
+
styleClassPassthrough: () => [],
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const emit = defineEmits<{
|
|
51
|
+
copy: [value: string];
|
|
52
|
+
copied: [value: string];
|
|
53
|
+
}>();
|
|
54
|
+
|
|
55
|
+
const copied = ref(false);
|
|
56
|
+
let copyTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
57
|
+
|
|
58
|
+
const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
|
|
59
|
+
|
|
60
|
+
const handleCopy = async () => {
|
|
61
|
+
try {
|
|
62
|
+
await navigator.clipboard.writeText(props.value);
|
|
63
|
+
copied.value = true;
|
|
64
|
+
emit("copy", props.value);
|
|
65
|
+
emit("copied", props.value);
|
|
66
|
+
|
|
67
|
+
if (copyTimeoutId) {
|
|
68
|
+
clearTimeout(copyTimeoutId);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
copyTimeoutId = setTimeout(() => {
|
|
72
|
+
copied.value = false;
|
|
73
|
+
copyTimeoutId = null;
|
|
74
|
+
}, props.copiedDuration);
|
|
75
|
+
} catch (err) {
|
|
76
|
+
console.error("Failed to copy to clipboard:", err);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
watch(
|
|
81
|
+
() => props.styleClassPassthrough,
|
|
82
|
+
() => {
|
|
83
|
+
resetElementClasses(props.styleClassPassthrough);
|
|
84
|
+
},
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
onUnmounted(() => {
|
|
88
|
+
if (copyTimeoutId) {
|
|
89
|
+
clearTimeout(copyTimeoutId);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
</script>
|
|
93
|
+
|
|
94
|
+
<style lang="css">
|
|
95
|
+
@layer components {
|
|
96
|
+
.input-copy {
|
|
97
|
+
--_input-bg: var(--input-copy-input-bg, var(--slate-00));
|
|
98
|
+
--_input-border: var(--input-copy-input-border, 1px solid var(--slate-03));
|
|
99
|
+
--_input-border-radius: var(--input-copy-input-border-radius, 0.4rem);
|
|
100
|
+
--_input-padding: var(--input-copy-input-padding, 0.8rem 1rem);
|
|
101
|
+
--_input-text-color: var(--input-copy-input-text-color, #333);
|
|
102
|
+
--_input-placeholder-color: var(--input-copy-input-placeholder-color, #999);
|
|
103
|
+
--_input-font-size: var(--input-copy-input-font-size, 0.95rem);
|
|
104
|
+
|
|
105
|
+
--_button-bg: var(--input-copy-button-bg, var(--theme-button-primary-surface));
|
|
106
|
+
--_button-bg-hover: var(--input-copy-button-bg-hover, var(--theme-button-primary-surface-hover));
|
|
107
|
+
--_button-text-color: var(--input-copy-button-text-color, var(--theme-button-primary-text));
|
|
108
|
+
--_button-padding: var(--input-copy-button-padding, 0.8rem 1.2rem);
|
|
109
|
+
--_button-border-radius: var(--input-copy-button-border-radius, 0.4rem);
|
|
110
|
+
--_button-gap: var(--input-copy-button-gap, 0.6rem);
|
|
111
|
+
|
|
112
|
+
--_description-color: var(--input-copy-description-color, #666);
|
|
113
|
+
--_description-font-size: var(--input-copy-description-font-size, 0.85rem);
|
|
114
|
+
--_description-margin: var(--input-copy-description-margin, 0.6rem 0 0 0);
|
|
115
|
+
|
|
116
|
+
display: flex;
|
|
117
|
+
flex-direction: column;
|
|
118
|
+
gap: 0.8rem;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.input-copy__wrapper {
|
|
122
|
+
display: flex;
|
|
123
|
+
gap: 0.8rem;
|
|
124
|
+
align-items: stretch;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
.input-copy__input {
|
|
128
|
+
flex: 1;
|
|
129
|
+
min-width: 0;
|
|
130
|
+
|
|
131
|
+
padding: var(--_input-padding);
|
|
132
|
+
background-color: var(--_input-bg);
|
|
133
|
+
border: var(--_input-border);
|
|
134
|
+
border-radius: var(--_input-border-radius);
|
|
135
|
+
font-size: var(--_input-font-size);
|
|
136
|
+
color: var(--_input-text-color);
|
|
137
|
+
font-family: inherit;
|
|
138
|
+
|
|
139
|
+
&::placeholder {
|
|
140
|
+
color: var(--_input-placeholder-color);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
&:focus {
|
|
144
|
+
outline: 2px solid var(--_input-border-radius);
|
|
145
|
+
outline-offset: 2px;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
.input-copy__button {
|
|
150
|
+
display: flex;
|
|
151
|
+
align-items: center;
|
|
152
|
+
gap: var(--_button-gap);
|
|
153
|
+
padding: var(--_button-padding);
|
|
154
|
+
|
|
155
|
+
background-color: var(--_button-bg);
|
|
156
|
+
color: var(--_button-text-color);
|
|
157
|
+
border: none;
|
|
158
|
+
border-radius: var(--_button-border-radius);
|
|
159
|
+
font-size: var(--_input-font-size);
|
|
160
|
+
font-weight: 500;
|
|
161
|
+
cursor: pointer;
|
|
162
|
+
white-space: nowrap;
|
|
163
|
+
transition: background-color 0.2s ease;
|
|
164
|
+
|
|
165
|
+
&:hover:not(:disabled) {
|
|
166
|
+
background-color: var(--_button-bg-hover);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
&:disabled {
|
|
170
|
+
opacity: 0.6;
|
|
171
|
+
cursor: not-allowed;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
.input-copy__icon {
|
|
175
|
+
width: 1.2em;
|
|
176
|
+
height: 1.2em;
|
|
177
|
+
flex-shrink: 0;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
.input-copy__button-text {
|
|
181
|
+
display: none;
|
|
182
|
+
|
|
183
|
+
@media (min-width: 480px) {
|
|
184
|
+
display: inline;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
.input-copy__description {
|
|
190
|
+
margin: var(--_description-margin);
|
|
191
|
+
font-size: var(--_description-font-size);
|
|
192
|
+
color: var(--_description-color);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
</style>
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@nuxtjs/storybook";
|
|
2
|
+
import InputCopy from "../InputCopy.vue";
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof InputCopy> = {
|
|
5
|
+
title: "Molecules/Input Copy",
|
|
6
|
+
component: InputCopy,
|
|
7
|
+
tags: ["autodocs"],
|
|
8
|
+
argTypes: {
|
|
9
|
+
value: { control: "text" },
|
|
10
|
+
label: { control: "text" },
|
|
11
|
+
description: { control: "text" },
|
|
12
|
+
buttonText: { control: "text" },
|
|
13
|
+
copiedText: { control: "text" },
|
|
14
|
+
copiedDuration: { control: "number" },
|
|
15
|
+
isDisabled: { control: "boolean" },
|
|
16
|
+
},
|
|
17
|
+
args: {
|
|
18
|
+
value: "sk_live_abc123defgh456xyz789uvw",
|
|
19
|
+
label: "License key",
|
|
20
|
+
description: "Your license key is ready to use. Copy it to embed in your application.",
|
|
21
|
+
buttonText: "Copy",
|
|
22
|
+
copiedText: "Copied!",
|
|
23
|
+
copiedDuration: 2000,
|
|
24
|
+
isDisabled: false,
|
|
25
|
+
},
|
|
26
|
+
parameters: {
|
|
27
|
+
docs: {
|
|
28
|
+
description: {
|
|
29
|
+
component:
|
|
30
|
+
"A readonly input field with a copy-to-clipboard button. Displays a value that users can quickly copy to their clipboard. " +
|
|
31
|
+
"Shows feedback ('Copied!') for a brief duration after successful copy. " +
|
|
32
|
+
"Emits `copy` and `copied` events. Useful for license keys, API tokens, or any copyable identifier.",
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export default meta;
|
|
39
|
+
type Story = StoryObj<typeof meta>;
|
|
40
|
+
|
|
41
|
+
const storyWrapper = (args: Record<string, unknown>) => ({
|
|
42
|
+
components: { InputCopy },
|
|
43
|
+
setup() {
|
|
44
|
+
const handleCopy = (value: string) => {
|
|
45
|
+
console.log(`Copied: ${value}`);
|
|
46
|
+
};
|
|
47
|
+
const handleCopied = (value: string) => {
|
|
48
|
+
console.log(`Copied event fired: ${value}`);
|
|
49
|
+
};
|
|
50
|
+
return { args, handleCopy, handleCopied };
|
|
51
|
+
},
|
|
52
|
+
template: `
|
|
53
|
+
<div style="padding: 2rem; background: #f5f5f5; border-radius: 0.5rem; max-width: 600px;">
|
|
54
|
+
<InputCopy v-bind="args" @copy="handleCopy" @copied="handleCopied" />
|
|
55
|
+
</div>
|
|
56
|
+
`,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
export const Default: Story = {
|
|
60
|
+
render: (args) => storyWrapper(args),
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const LicenseKey: Story = {
|
|
64
|
+
args: {
|
|
65
|
+
value: "gmh_lic_single_site_abc123def456ghi789",
|
|
66
|
+
label: "License key",
|
|
67
|
+
description: "Your GuideMyHair license key. Keep this safe and use it to embed the widget.",
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export const APIToken: Story = {
|
|
72
|
+
args: {
|
|
73
|
+
value: "ghp_16C7e42F292c6912E7710c838347Ae178B4a",
|
|
74
|
+
label: "API token",
|
|
75
|
+
description: "Your API token for authentication. Do not share this token.",
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export const ShortValue: Story = {
|
|
80
|
+
args: {
|
|
81
|
+
value: "ABC-123-XYZ",
|
|
82
|
+
label: "Invite code",
|
|
83
|
+
description: "Share this invite code with your team members.",
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export const Disabled: Story = {
|
|
88
|
+
args: {
|
|
89
|
+
value: "Disabled input",
|
|
90
|
+
label: "License key",
|
|
91
|
+
isDisabled: true,
|
|
92
|
+
description: "This input is disabled and cannot be copied.",
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export const NoDescription: Story = {
|
|
97
|
+
args: {
|
|
98
|
+
value: "sk_test_51234567890abcdef",
|
|
99
|
+
label: "Secret key",
|
|
100
|
+
description: undefined,
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export const LongValue: Story = {
|
|
105
|
+
args: {
|
|
106
|
+
value: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.POdHyP_DQkT3WTZhu9qLWSwVe8ZlHfHEPBOzzVI3vFA",
|
|
107
|
+
label: "JWT token",
|
|
108
|
+
description: "Your JWT authentication token. This is a long-lived token used for API access.",
|
|
109
|
+
},
|
|
110
|
+
};
|