srcdev-nuxt-components 9.6.3 → 9.6.5
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/rules/consumer/vue-no-self-closing.md +3 -2
- package/.claude/skills/component-compliance-checklist.md +17 -3
- package/.claude/skills/component-inline-action-button.md +33 -2
- package/.claude/skills/components/cookie-consent-banner.md +9 -6
- package/.claude/skills/components/input-copy.md +47 -100
- package/.claude/skills/components/progress-tracker.md +140 -0
- package/.claude/skills/components/stepper-list.md +10 -3
- package/.claude/skills/components/tabbed-content.md +4 -0
- package/.claude/skills/composable-cookie-consent.md +1 -1
- package/.claude/skills/index.md +4 -3
- package/.claude/skills/storybook-add-story.md +14 -0
- package/.claude/skills/testing-add-unit-test.md +19 -0
- package/.claude/skills/theming-component-token-pattern.md +1 -1
- package/.vscode/srcdev-component-cookie-consent-banner.code-snippets +7 -5
- package/.vscode/srcdev-component-input-copy.code-snippets +24 -42
- package/.vscode/srcdev-component-progress-tracker.code-snippets +45 -0
- package/.vscode/srcdev-component-stepper-list.code-snippets +3 -2
- package/.vscode/srcdev-component-tabbed-content.code-snippets +1 -0
- package/app/components/01.atoms/tabbed-content/CONSUMER-STYLING.md +5 -1
- package/app/components/01.atoms/tabbed-content/TabbedContent.vue +2 -2
- package/app/components/01.atoms/tabbed-content/stories/TabbedContent.stories.ts +26 -0
- package/app/components/02.molecules/contact-section/tests/__snapshots__/ContactSection.spec.ts.snap +28 -28
- package/app/components/02.molecules/cookie-consent-banner/CONSUMER-STYLING.md +13 -4
- package/app/components/02.molecules/cookie-consent-banner/CookieConsentBanner.vue +23 -14
- package/app/components/02.molecules/cookie-consent-banner/stories/CookieConsentBanner.stories.ts +106 -5
- package/app/components/02.molecules/cookie-consent-banner/tests/CookieConsentBanner.spec.ts +33 -11
- package/app/components/02.molecules/input-copy/CONSUMER-STYLING.md +82 -124
- package/app/components/02.molecules/input-copy/InputCopy.vue +110 -85
- package/app/components/02.molecules/input-copy/stories/InputCopy.stories.ts +204 -47
- package/app/components/02.molecules/input-copy/tests/InputCopy.spec.ts +147 -171
- package/app/components/02.molecules/progress-tracker/CONSUMER-STYLING.md +116 -0
- package/app/components/02.molecules/progress-tracker/ProgressTracker.vue +275 -0
- package/app/components/02.molecules/progress-tracker/stories/ProgressTracker.stories.ts +424 -0
- package/app/components/02.molecules/progress-tracker/tests/ProgressTracker.spec.ts +253 -0
- package/app/components/02.molecules/progress-tracker/tests/__snapshots__/ProgressTracker.spec.ts.snap +46 -0
- package/app/components/02.molecules/stepper-list/CONSUMER-STYLING.md +42 -7
- package/app/components/02.molecules/stepper-list/StepperList.vue +42 -19
- package/app/components/02.molecules/stepper-list/stories/StepperList.stories.ts +288 -2
- package/app/components/02.molecules/stepper-list/tests/StepperList.spec.ts +161 -34
- package/app/components/02.molecules/stepper-list/tests/__snapshots__/StepperList.spec.ts.snap +40 -40
- package/app/components/03.organisms/services/service-detail/tests/__snapshots__/ServiceDetail.spec.ts.snap +7 -7
- package/app/types/components/cookie-consent-banner.d.ts +2 -0
- package/app/types/components/index.ts +1 -0
- package/app/types/components/progress-tracker.d.ts +6 -0
- package/package.json +1 -1
package/app/components/02.molecules/cookie-consent-banner/stories/CookieConsentBanner.stories.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Meta, StoryFn } from "@nuxtjs/storybook";
|
|
2
|
+
import { computed } from "vue";
|
|
2
3
|
import StorybookComponent from "../CookieConsentBanner.vue";
|
|
3
4
|
|
|
4
5
|
export default {
|
|
@@ -21,6 +22,16 @@ export default {
|
|
|
21
22
|
description: "Render the icon column",
|
|
22
23
|
table: { category: "Appearance" },
|
|
23
24
|
},
|
|
25
|
+
acceptLabel: {
|
|
26
|
+
control: "text",
|
|
27
|
+
description: "Accept button text",
|
|
28
|
+
table: { category: "Content" },
|
|
29
|
+
},
|
|
30
|
+
rejectLabel: {
|
|
31
|
+
control: "text",
|
|
32
|
+
description: "Reject button text",
|
|
33
|
+
table: { category: "Content" },
|
|
34
|
+
},
|
|
24
35
|
ariaLabel: {
|
|
25
36
|
control: "text",
|
|
26
37
|
description: "Accessible name for the banner region",
|
|
@@ -35,6 +46,8 @@ export default {
|
|
|
35
46
|
args: {
|
|
36
47
|
theme: "info",
|
|
37
48
|
ariaLabel: "Cookie consent",
|
|
49
|
+
acceptLabel: "Accept",
|
|
50
|
+
rejectLabel: "Reject",
|
|
38
51
|
icon: "material-symbols:cookie-outline",
|
|
39
52
|
showIcon: true,
|
|
40
53
|
styleClassPassthrough: [],
|
|
@@ -49,7 +62,10 @@ export default {
|
|
|
49
62
|
// reloads. Each story below has "Show banner again" (resets state
|
|
50
63
|
// in-place) and "Delete cookie" (clears document.cookie directly, for
|
|
51
64
|
// verifying the cookie itself is actually gone, e.g. in devtools).
|
|
52
|
-
component:
|
|
65
|
+
component:
|
|
66
|
+
"Fixed, non-modal cookie consent banner built on AlertContent, with Accept/Reject InputButtons wired to useCookieConsent(). " +
|
|
67
|
+
"Copy comes from the title and message slots and the acceptLabel, rejectLabel and ariaLabel props; theme, icon and showIcon set the look. " +
|
|
68
|
+
"It reads/writes a real 'privacy-notice-consent' cookie, so use the story's reset controls to bring the banner back.",
|
|
53
69
|
},
|
|
54
70
|
},
|
|
55
71
|
},
|
|
@@ -84,7 +100,7 @@ const Template: StoryFn<typeof StorybookComponent> = (args) => ({
|
|
|
84
100
|
<div style="padding: 2rem; min-height: 240px; position: relative;">
|
|
85
101
|
<button type="button" @click="showAgain">Show banner again</button>
|
|
86
102
|
<button type="button" @click="deleteCookie" style="margin-inline-start: 0.8rem;">Delete cookie</button>
|
|
87
|
-
<StorybookComponent :theme="args.theme" :icon="args.icon" :show-icon="args.showIcon" :aria-label="args.ariaLabel" :style-class-passthrough="args.styleClassPassthrough">
|
|
103
|
+
<StorybookComponent :theme="args.theme" :icon="args.icon" :show-icon="args.showIcon" :aria-label="args.ariaLabel" :accept-label="args.acceptLabel" :reject-label="args.rejectLabel" :style-class-passthrough="args.styleClassPassthrough">
|
|
88
104
|
<template #message>This site uses cookies for analytics. You can accept or reject them.</template>
|
|
89
105
|
</StorybookComponent>
|
|
90
106
|
</div>
|
|
@@ -103,12 +119,97 @@ export const CustomCopy: StoryFn<typeof StorybookComponent> = (args) => ({
|
|
|
103
119
|
<div style="padding: 2rem; min-height: 240px; position: relative;">
|
|
104
120
|
<button type="button" @click="showAgain">Show banner again</button>
|
|
105
121
|
<button type="button" @click="deleteCookie" style="margin-inline-start: 0.8rem;">Delete cookie</button>
|
|
106
|
-
<StorybookComponent :theme="args.theme" :icon="args.icon" :show-icon="args.showIcon" :aria-label="args.ariaLabel">
|
|
122
|
+
<StorybookComponent :theme="args.theme" :icon="args.icon" :show-icon="args.showIcon" :aria-label="args.ariaLabel" :accept-label="args.acceptLabel" :reject-label="args.rejectLabel">
|
|
107
123
|
<template #title>Your privacy</template>
|
|
108
124
|
<template #message>We use cookies to understand traffic to this site. No personal data is sold.</template>
|
|
109
|
-
<template #acceptLabel>Allow cookies</template>
|
|
110
|
-
<template #rejectLabel>No thanks</template>
|
|
111
125
|
</StorybookComponent>
|
|
112
126
|
</div>
|
|
113
127
|
`,
|
|
114
128
|
});
|
|
129
|
+
CustomCopy.args = { acceptLabel: "Allow cookies", rejectLabel: "No thanks" };
|
|
130
|
+
|
|
131
|
+
const stressCases = {
|
|
132
|
+
german: {
|
|
133
|
+
title: "Datenschutzeinstellungen",
|
|
134
|
+
message:
|
|
135
|
+
"Diese Website verwendet Cookies, um zu verstehen, wie sie genutzt wird, und um Ihnen ein besseres Erlebnis zu bieten. Weitere Informationen finden Sie in unserer Datenschutzerklärung.",
|
|
136
|
+
acceptLabel: "Alle Cookies akzeptieren und fortfahren",
|
|
137
|
+
rejectLabel: "Alle nicht notwendigen Cookies ablehnen",
|
|
138
|
+
dir: "ltr",
|
|
139
|
+
},
|
|
140
|
+
unbroken: {
|
|
141
|
+
title: "Datenschutzgrundverordnungskonformitätsbestätigungseinstellungen",
|
|
142
|
+
message: "https://example.com/a-very-long-path-segment-without-any-spaces-at-all-in-it/privacy-and-cookie-policy",
|
|
143
|
+
acceptLabel: "Cookieeinwilligungsbestätigung",
|
|
144
|
+
rejectLabel: "Cookieeinwilligungsablehnung",
|
|
145
|
+
dir: "ltr",
|
|
146
|
+
},
|
|
147
|
+
emojiHtml: {
|
|
148
|
+
title: "🍪 <strong>Cookies</strong>",
|
|
149
|
+
message: "<script>alert('xss')</script> We use cookies 🍪🍪🍪 & <em>similar</em> technologies.",
|
|
150
|
+
acceptLabel: "✅ <b>Yes</b>",
|
|
151
|
+
rejectLabel: "❌ <i>No</i>",
|
|
152
|
+
dir: "ltr",
|
|
153
|
+
},
|
|
154
|
+
rtl: {
|
|
155
|
+
title: "ملفات تعريف الارتباط",
|
|
156
|
+
message: "يستخدم هذا الموقع ملفات تعريف الارتباط لفهم كيفية استخدامه. يمكنك قبولها أو رفضها.",
|
|
157
|
+
acceptLabel: "قبول",
|
|
158
|
+
rejectLabel: "رفض",
|
|
159
|
+
dir: "rtl",
|
|
160
|
+
},
|
|
161
|
+
minimal: {
|
|
162
|
+
title: "",
|
|
163
|
+
message: "C",
|
|
164
|
+
acceptLabel: "✓",
|
|
165
|
+
rejectLabel: "✗",
|
|
166
|
+
dir: "ltr",
|
|
167
|
+
},
|
|
168
|
+
} as const;
|
|
169
|
+
|
|
170
|
+
type StressCase = keyof typeof stressCases;
|
|
171
|
+
|
|
172
|
+
export const StressTest: StoryFn<typeof StorybookComponent> = (args) => ({
|
|
173
|
+
components: { StorybookComponent },
|
|
174
|
+
setup() {
|
|
175
|
+
const { showAgain, deleteCookie } = useResetConsent();
|
|
176
|
+
const copy = computed(() => stressCases[(args as { stressCase?: StressCase }).stressCase ?? "german"]);
|
|
177
|
+
return { args, copy, showAgain, deleteCookie };
|
|
178
|
+
},
|
|
179
|
+
template: `
|
|
180
|
+
<div style="padding: 2rem; min-height: 320px; position: relative;">
|
|
181
|
+
<button type="button" @click="showAgain">Show banner again</button>
|
|
182
|
+
<button type="button" @click="deleteCookie" style="margin-inline-start: 0.8rem;">Delete cookie</button>
|
|
183
|
+
<StorybookComponent :theme="args.theme" :icon="args.icon" :show-icon="args.showIcon" :aria-label="args.ariaLabel" :accept-label="copy.acceptLabel" :reject-label="copy.rejectLabel" :style-class-passthrough="args.styleClassPassthrough">
|
|
184
|
+
<template #title><span :dir="copy.dir">{{ copy.title }}</span></template>
|
|
185
|
+
<template #message><span :dir="copy.dir">{{ copy.message }}</span></template>
|
|
186
|
+
</StorybookComponent>
|
|
187
|
+
</div>
|
|
188
|
+
`,
|
|
189
|
+
});
|
|
190
|
+
StressTest.storyName = "Stress Test (Worst-Case Data)";
|
|
191
|
+
StressTest.argTypes = {
|
|
192
|
+
stressCase: {
|
|
193
|
+
name: "Stress case",
|
|
194
|
+
control: { type: "select" },
|
|
195
|
+
options: Object.keys(stressCases),
|
|
196
|
+
description:
|
|
197
|
+
"**Story control, not a prop.** Picks which worst-case copy fills the title and message slots and the acceptLabel/rejectLabel props (overriding their controls).",
|
|
198
|
+
table: { category: "Story only" },
|
|
199
|
+
},
|
|
200
|
+
} as Meta<typeof StorybookComponent>["argTypes"];
|
|
201
|
+
StressTest.args = { stressCase: "german" } as typeof StressTest.args;
|
|
202
|
+
StressTest.parameters = {
|
|
203
|
+
docs: {
|
|
204
|
+
story: { inline: false, height: "480px" },
|
|
205
|
+
description: {
|
|
206
|
+
story:
|
|
207
|
+
"Hostile slot content, switched with the Stress case control: long German copy and button labels, an unbroken compound title " +
|
|
208
|
+
"with a long URL as the message and unbroken button labels, emoji and HTML-like text (must render as text, never as markup), " +
|
|
209
|
+
"right-to-left Arabic, and a minimal case with an empty title and one-character copy. The banner is fixed to the viewport, so " +
|
|
210
|
+
"use Storybook's viewport toolbar (not a canvas width) to check narrow screens. Check that the banner stays inside the gutter " +
|
|
211
|
+
"and caps at 64rem, unbroken text wraps instead of being clipped (including inside the buttons), and the buttons wrap onto " +
|
|
212
|
+
"their own lines rather than overflowing.",
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
};
|
|
@@ -34,6 +34,12 @@ function rejectButton() {
|
|
|
34
34
|
return document.querySelector("[data-test-id='cookie-consent-banner-reject']") as HTMLElement;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
// Vue ignores events stamped at or before its listener's attach time; fake timers freeze both.
|
|
38
|
+
function click(button: HTMLElement) {
|
|
39
|
+
vi.advanceTimersByTime(1);
|
|
40
|
+
button.click();
|
|
41
|
+
}
|
|
42
|
+
|
|
37
43
|
describe("CookieConsentBanner", () => {
|
|
38
44
|
beforeEach(() => {
|
|
39
45
|
statusRef.value = "unset";
|
|
@@ -56,15 +62,15 @@ describe("CookieConsentBanner", () => {
|
|
|
56
62
|
expect(banner()).not.toBeNull();
|
|
57
63
|
});
|
|
58
64
|
|
|
59
|
-
it("is visible (no closed class) while status is 'unset'", async () => {
|
|
65
|
+
it("is visible (no closed state class) while status is 'unset'", async () => {
|
|
60
66
|
await mountSuspended(CookieConsentBanner);
|
|
61
|
-
expect(banner()!.classList).not.toContain("closed");
|
|
67
|
+
expect(banner()!.classList).not.toContain("cookie-consent-banner-closed");
|
|
62
68
|
});
|
|
63
69
|
|
|
64
|
-
it("adds the closed class once status is no longer 'unset'", async () => {
|
|
70
|
+
it("adds the cookie-consent-banner-closed class once status is no longer 'unset'", async () => {
|
|
65
71
|
statusRef.value = "granted";
|
|
66
72
|
await mountSuspended(CookieConsentBanner);
|
|
67
|
-
expect(banner()!.classList).toContain("closed");
|
|
73
|
+
expect(banner()!.classList).toContain("cookie-consent-banner-closed");
|
|
68
74
|
});
|
|
69
75
|
|
|
70
76
|
it("defaults to data-theme='info'", async () => {
|
|
@@ -94,21 +100,37 @@ describe("CookieConsentBanner", () => {
|
|
|
94
100
|
expect(banner()!.textContent).toContain("Custom cookie copy");
|
|
95
101
|
});
|
|
96
102
|
|
|
97
|
-
it("renders
|
|
98
|
-
await mountSuspended(CookieConsentBanner
|
|
103
|
+
it("renders default button labels", async () => {
|
|
104
|
+
await mountSuspended(CookieConsentBanner);
|
|
105
|
+
expect(acceptButton().textContent).toContain("Accept");
|
|
106
|
+
expect(rejectButton().textContent).toContain("Reject");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("renders acceptLabel/rejectLabel props", async () => {
|
|
110
|
+
await mountSuspended(CookieConsentBanner, { props: { acceptLabel: "Yes please", rejectLabel: "No thanks" } });
|
|
99
111
|
expect(acceptButton().textContent).toContain("Yes please");
|
|
100
112
|
expect(rejectButton().textContent).toContain("No thanks");
|
|
101
113
|
});
|
|
102
114
|
|
|
115
|
+
it("renders both buttons as inline InputButtons", async () => {
|
|
116
|
+
await mountSuspended(CookieConsentBanner);
|
|
117
|
+
for (const button of [acceptButton(), rejectButton()]) {
|
|
118
|
+
expect(button.tagName).toBe("BUTTON");
|
|
119
|
+
expect(button.getAttribute("type")).toBe("button");
|
|
120
|
+
expect(button.classList).toContain("input-button");
|
|
121
|
+
expect(button.classList).toContain("inline");
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
103
125
|
it("calls acceptAll() when the accept button is clicked", async () => {
|
|
104
126
|
await mountSuspended(CookieConsentBanner);
|
|
105
|
-
acceptButton()
|
|
127
|
+
click(acceptButton());
|
|
106
128
|
expect(acceptAllSpy).toHaveBeenCalledOnce();
|
|
107
129
|
});
|
|
108
130
|
|
|
109
131
|
it("calls rejectAll() when the reject button is clicked", async () => {
|
|
110
132
|
await mountSuspended(CookieConsentBanner);
|
|
111
|
-
rejectButton()
|
|
133
|
+
click(rejectButton());
|
|
112
134
|
expect(rejectAllSpy).toHaveBeenCalledOnce();
|
|
113
135
|
});
|
|
114
136
|
|
|
@@ -133,9 +155,9 @@ describe("CookieConsentBanner", () => {
|
|
|
133
155
|
|
|
134
156
|
it("closes after a choice is made", async () => {
|
|
135
157
|
await mountSuspended(CookieConsentBanner);
|
|
136
|
-
rejectButton()
|
|
158
|
+
click(rejectButton());
|
|
137
159
|
await nextTick();
|
|
138
|
-
expect(banner()!.classList).toContain("closed");
|
|
160
|
+
expect(banner()!.classList).toContain("cookie-consent-banner-closed");
|
|
139
161
|
});
|
|
140
162
|
|
|
141
163
|
it("reopens if consent is reset to 'unset'", async () => {
|
|
@@ -143,7 +165,7 @@ describe("CookieConsentBanner", () => {
|
|
|
143
165
|
await mountSuspended(CookieConsentBanner);
|
|
144
166
|
statusRef.value = "unset";
|
|
145
167
|
await nextTick();
|
|
146
|
-
expect(banner()!.classList).not.toContain("closed");
|
|
168
|
+
expect(banner()!.classList).not.toContain("cookie-consent-banner-closed");
|
|
147
169
|
});
|
|
148
170
|
|
|
149
171
|
it("updates classes when styleClassPassthrough changes", async () => {
|
|
@@ -1,150 +1,108 @@
|
|
|
1
1
|
# InputCopy — Consumer Styling Guide
|
|
2
2
|
|
|
3
|
+
A readonly input and a copy button (an `InputButton` with `variant="inline"`, so the tokens below
|
|
4
|
+
style it rather than `InputButton`'s variant colours), with optional description text below.
|
|
5
|
+
|
|
6
|
+
> **Changed 2026-10-15**: compliance migration.
|
|
7
|
+
>
|
|
8
|
+
> - Colour defaults now come from the theme: input background `--theme-input-surface` (was
|
|
9
|
+
> `--slate-00`), border `0.1rem solid var(--theme-border)` (was `1px solid var(--slate-03)`), input
|
|
10
|
+
> and description text `--theme-text` (were `#333` / `#666`). Set the tokens if you relied on the
|
|
11
|
+
> old greys.
|
|
12
|
+
> - Font sizes were `0.95rem` / `0.85rem` (9.5px / 8.5px on the 10px rem base); both now default to
|
|
13
|
+
> `var(--step-4)`.
|
|
14
|
+
> - `--input-copy-description-margin` defaults to `0` (was `0.6rem 0 0 0`, doubling up with the
|
|
15
|
+
> gap); spacing is the new `--input-copy-gap`.
|
|
16
|
+
> - `--input-copy-input-placeholder-color` is gone (the input always has a value). New:
|
|
17
|
+
> `--input-copy-gap`, `--input-copy-focus-ring`, `--input-copy-description-line-clamp`.
|
|
18
|
+
> - The input's focus outline used the border-radius token as its colour; it's now a real
|
|
19
|
+
> `:focus-visible` ring from `--input-copy-focus-ring`.
|
|
20
|
+
> - The button label is `InputButton`'s `.button-text` (was `.input-copy__button-text`), and it
|
|
21
|
+
> hides below 48rem of the **component's** width (a container query), not a 480px viewport.
|
|
22
|
+
|
|
3
23
|
## Public token API
|
|
4
24
|
|
|
5
|
-
|
|
6
|
-
or instance) without touching the component itself.
|
|
25
|
+
### Layout
|
|
7
26
|
|
|
8
27
|
| Token | Default | Controls |
|
|
9
28
|
|---|---|---|
|
|
10
|
-
| `--input-copy-
|
|
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(--input-button-primary-surface)` | Copy button background |
|
|
18
|
-
| `--input-copy-button-bg-hover` | `var(--input-button-primary-surface-hover)` | Copy button background on hover |
|
|
19
|
-
| `--input-copy-button-text-color` | `var(--input-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
|
-
---
|
|
29
|
+
| `--input-copy-gap` | `0.8rem` | Gap between input and button, and above the description |
|
|
28
30
|
|
|
29
|
-
|
|
31
|
+
### Input
|
|
32
|
+
|
|
33
|
+
| Token | Default | Controls |
|
|
34
|
+
|---|---|---|
|
|
35
|
+
| `--input-copy-input-bg` | `var(--theme-input-surface)` | Input background |
|
|
36
|
+
| `--input-copy-input-border` | `0.1rem solid var(--theme-border)` | Input border (shorthand) |
|
|
37
|
+
| `--input-copy-input-border-radius` | `0.4rem` | Input corner radius |
|
|
38
|
+
| `--input-copy-input-padding` | `0.8rem 1rem` | Input padding |
|
|
39
|
+
| `--input-copy-input-text-color` | `var(--theme-text)` | Input text colour |
|
|
40
|
+
| `--input-copy-input-font-size` | `var(--step-4)` | Input and button font size |
|
|
41
|
+
| `--input-copy-focus-ring` | `var(--theme-border-focus)` | `:focus-visible` outline colour on the input and the button |
|
|
42
|
+
|
|
43
|
+
### Button
|
|
30
44
|
|
|
31
|
-
|
|
32
|
-
|
|
45
|
+
| Token | Default | Controls |
|
|
46
|
+
|---|---|---|
|
|
47
|
+
| `--input-copy-button-bg` | `var(--input-button-primary-surface, var(--theme-surface))` | Button background |
|
|
48
|
+
| `--input-copy-button-bg-hover` | `var(--input-button-primary-surface-hover, var(--theme-surface-hover))` | Button background on hover |
|
|
49
|
+
| `--input-copy-button-text-color` | `var(--input-button-primary-text, var(--theme-on-surface))` | Button text and icon colour |
|
|
50
|
+
| `--input-copy-button-padding` | `0.8rem 1.2rem` | Button padding |
|
|
51
|
+
| `--input-copy-button-border-radius` | `0.4rem` | Button corner radius |
|
|
52
|
+
| `--input-copy-button-gap` | `0.6rem` | Gap between icon and label |
|
|
53
|
+
|
|
54
|
+
### Description
|
|
55
|
+
|
|
56
|
+
| Token | Default | Controls |
|
|
57
|
+
|---|---|---|
|
|
58
|
+
| `--input-copy-description-color` | `var(--theme-text)` | Description text colour |
|
|
59
|
+
| `--input-copy-description-font-size` | `var(--step-4)` | Description font size |
|
|
60
|
+
| `--input-copy-description-margin` | `0` | Description margin |
|
|
61
|
+
| `--input-copy-description-line-clamp` | `none` | Lines before the description is cut off with an ellipsis (`1` = single line) |
|
|
62
|
+
|
|
63
|
+
Private tokens (not public API): `--_input-font-size` on the root, the resolved input font size
|
|
64
|
+
shared by the input and the button.
|
|
65
|
+
|
|
66
|
+
## State hooks
|
|
67
|
+
|
|
68
|
+
- `.input-copy-copied` on the root for `copiedDuration` ms after a successful copy. The button then
|
|
69
|
+
has `aria-disabled="true"` (it stays focusable and ignores clicks) and shows `copiedText` and
|
|
70
|
+
`copiedIcon`.
|
|
71
|
+
- `disabled` on the button when `isDisabled` is set.
|
|
72
|
+
- Classes: `.input-copy` > `.input-copy__wrapper` > `.input-copy__input` and
|
|
73
|
+
`.input-copy__button` (also `.input-button.inline`, containing `.input-copy__icon` and
|
|
74
|
+
`.button-text`); then `.input-copy__description` and a visually hidden `role="status"` span.
|
|
75
|
+
- Container: the root is `container: input-copy / inline-size`. Below `48rem` of its own width the
|
|
76
|
+
button's `.button-text` is hidden and only the icon shows (the button keeps its aria-label).
|
|
77
|
+
|
|
78
|
+
## Global theming
|
|
33
79
|
|
|
34
80
|
```css
|
|
35
|
-
|
|
36
|
-
:root {
|
|
81
|
+
:where(html) {
|
|
37
82
|
--input-copy-input-border-radius: 0.6rem;
|
|
38
|
-
--input-copy-input-padding: 1rem 1.2rem;
|
|
39
83
|
--input-copy-button-border-radius: 0.6rem;
|
|
40
|
-
--input-copy-button-gap: 0.8rem;
|
|
41
|
-
--input-copy-description-color: var(--slate-06);
|
|
42
84
|
}
|
|
43
85
|
```
|
|
44
86
|
|
|
45
|
-
---
|
|
46
|
-
|
|
47
87
|
## Local overrides
|
|
48
88
|
|
|
49
89
|
Set the tokens above on an element you own (a page or section class, or a class added with
|
|
50
90
|
`:style-class-passthrough`): they inherit down into the component. Keep the block **unlayered** (no
|
|
51
91
|
`@layer` wrapper) so it beats the library's `@layer components`. If your own file uses
|
|
52
|
-
`<style scoped>`, tokens set on your element still work, but selectors that reach inside the
|
|
53
|
-
`:deep()`. Patterns and examples: `.claude/skills/component-local-style-override.md`.
|
|
54
|
-
|
|
55
|
-
### One instance
|
|
56
|
-
|
|
57
|
-
Override tokens directly on a single InputCopy instance:
|
|
58
|
-
|
|
59
|
-
```vue
|
|
60
|
-
<InputCopy
|
|
61
|
-
value="sk_live_abc123"
|
|
62
|
-
label="API key"
|
|
63
|
-
style="
|
|
64
|
-
--input-copy-input-border-radius: 0.8rem;
|
|
65
|
-
--input-copy-button-bg: var(--brand-success);
|
|
66
|
-
"
|
|
67
|
-
/>
|
|
68
|
-
```
|
|
92
|
+
`<style scoped>`, tokens set on your element still work, but selectors that reach inside the
|
|
93
|
+
component need `:deep()`. Patterns and examples: `.claude/skills/component-local-style-override.md`.
|
|
69
94
|
|
|
70
|
-
|
|
95
|
+
**Caveat:** the button sets `--input-button-text-white-space: normal` on itself so long labels wrap,
|
|
96
|
+
so a value set on an ancestor won't reach it.
|
|
71
97
|
|
|
72
|
-
|
|
73
|
-
under the page wrapper:
|
|
98
|
+
## Class passthrough
|
|
74
99
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
.success-page {
|
|
78
|
-
.input-copy {
|
|
79
|
-
--input-copy-input-bg: var(--brand-surface-light);
|
|
80
|
-
--input-copy-input-border: 2px solid var(--brand-primary);
|
|
81
|
-
--input-copy-button-bg: var(--brand-primary);
|
|
82
|
-
--input-copy-button-bg-hover: var(--brand-primary-hover);
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
```
|
|
86
|
-
|
|
87
|
-
---
|
|
88
|
-
|
|
89
|
-
## License key display pattern
|
|
100
|
+
`style-class-passthrough` adds classes to the root `.input-copy`, next to `.input-copy-copied`. It's
|
|
101
|
+
reachable in normal use and is a good place to set tokens for one instance.
|
|
90
102
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
```vue
|
|
94
|
-
<InputCopy
|
|
95
|
-
:value="licenseKey"
|
|
96
|
-
label="License key"
|
|
97
|
-
description="Your license key is ready to use. Copy it and add to your component."
|
|
98
|
-
buttonText="Copy"
|
|
99
|
-
copiedText="Copied!"
|
|
100
|
-
@copied="showFeedback"
|
|
101
|
-
/>
|
|
102
|
-
```
|
|
103
|
-
|
|
104
|
-
```css
|
|
105
|
-
.success-page {
|
|
106
|
-
.input-copy {
|
|
107
|
-
max-width: 500px;
|
|
108
|
-
|
|
109
|
-
--input-copy-input-bg: #f9f9f9;
|
|
110
|
-
--input-copy-input-border: 1px solid var(--slate-02);
|
|
111
|
-
--input-copy-input-text-color: var(--slate-09);
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
```
|
|
115
|
-
|
|
116
|
-
---
|
|
117
|
-
|
|
118
|
-
## Readonly input styling
|
|
119
|
-
|
|
120
|
-
The input field is always readonly to prevent accidental edits. Style it distinctly if needed:
|
|
121
|
-
|
|
122
|
-
```css
|
|
123
|
-
.input-copy {
|
|
124
|
-
.input-copy__input {
|
|
125
|
-
background-color: var(--slate-00);
|
|
126
|
-
color: var(--slate-09);
|
|
127
|
-
cursor: default;
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
```
|
|
131
|
-
|
|
132
|
-
---
|
|
133
|
-
|
|
134
|
-
## Button text visibility on small screens
|
|
135
|
-
|
|
136
|
-
By default, the button text is hidden on screens < 480px. Override for always-visible text:
|
|
137
|
-
|
|
138
|
-
```css
|
|
139
|
-
.input-copy__button-text {
|
|
140
|
-
display: inline !important;
|
|
141
|
-
}
|
|
142
|
-
```
|
|
143
|
-
|
|
144
|
-
Or show only the icon on mobile and text on desktop (default behavior):
|
|
145
|
-
|
|
146
|
-
```css
|
|
147
|
-
/* Default: button shows only icon on mobile, icon + text on desktop (480px+) */
|
|
148
|
-
/* No override needed; this is the built-in behavior */
|
|
149
|
-
```
|
|
103
|
+
## Notes
|
|
150
104
|
|
|
105
|
+
- The root is an inline-size container, so it can't size itself from its content: give it a width
|
|
106
|
+
from its parent (it fills a block or grid cell; inside a flex row, give it `flex: 1` or a width).
|
|
107
|
+
- The button is capped at half the row (`max-inline-size: 50%`) and its label wraps, so a long
|
|
108
|
+
translated label can't squeeze the input to nothing.
|