srcdev-nuxt-components 9.6.3 → 9.6.4

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.
Files changed (22) hide show
  1. package/.claude/rules/consumer/vue-no-self-closing.md +3 -2
  2. package/.claude/skills/component-compliance-checklist.md +8 -2
  3. package/.claude/skills/component-inline-action-button.md +33 -2
  4. package/.claude/skills/components/cookie-consent-banner.md +9 -6
  5. package/.claude/skills/components/input-copy.md +47 -100
  6. package/.claude/skills/composable-cookie-consent.md +1 -1
  7. package/.claude/skills/index.md +2 -2
  8. package/.claude/skills/storybook-add-story.md +8 -0
  9. package/.claude/skills/testing-add-unit-test.md +19 -0
  10. package/.claude/skills/theming-component-token-pattern.md +1 -1
  11. package/.vscode/srcdev-component-cookie-consent-banner.code-snippets +7 -5
  12. package/.vscode/srcdev-component-input-copy.code-snippets +24 -42
  13. package/app/components/02.molecules/cookie-consent-banner/CONSUMER-STYLING.md +13 -4
  14. package/app/components/02.molecules/cookie-consent-banner/CookieConsentBanner.vue +23 -14
  15. package/app/components/02.molecules/cookie-consent-banner/stories/CookieConsentBanner.stories.ts +106 -5
  16. package/app/components/02.molecules/cookie-consent-banner/tests/CookieConsentBanner.spec.ts +33 -11
  17. package/app/components/02.molecules/input-copy/CONSUMER-STYLING.md +82 -124
  18. package/app/components/02.molecules/input-copy/InputCopy.vue +110 -85
  19. package/app/components/02.molecules/input-copy/stories/InputCopy.stories.ts +204 -47
  20. package/app/components/02.molecules/input-copy/tests/InputCopy.spec.ts +147 -171
  21. package/app/types/components/cookie-consent-banner.d.ts +2 -0
  22. package/package.json +1 -1
@@ -2,7 +2,7 @@
2
2
  <Teleport to="body">
3
3
  <div
4
4
  class="cookie-consent-banner"
5
- :class="[{ closed: isClosed }, elementClasses]"
5
+ :class="[{ 'cookie-consent-banner-closed': isClosed }, elementClasses]"
6
6
  :data-theme="resolved.theme"
7
7
  data-test-id="cookie-consent-banner"
8
8
  >
@@ -15,22 +15,22 @@
15
15
  <slot name="message">This site uses cookies to understand how it's used. You can accept or reject them.</slot>
16
16
  </template>
17
17
  <template #actions>
18
- <button
18
+ <InputButton
19
19
  type="button"
20
+ variant="inline"
20
21
  class="cookie-consent-banner-reject"
21
22
  data-test-id="cookie-consent-banner-reject"
23
+ :button-text="rejectLabel"
22
24
  @click="rejectAll()"
23
- >
24
- <slot name="rejectLabel">Reject</slot>
25
- </button>
26
- <button
25
+ />
26
+ <InputButton
27
27
  type="button"
28
+ variant="inline"
28
29
  class="cookie-consent-banner-accept"
29
30
  data-test-id="cookie-consent-banner-accept"
31
+ :button-text="acceptLabel"
30
32
  @click="acceptAll()"
31
- >
32
- <slot name="acceptLabel">Accept</slot>
33
- </button>
33
+ />
34
34
  </template>
35
35
  </AlertContent>
36
36
  </div>
@@ -44,6 +44,8 @@ import type { CookieConsentBannerProps } from "../../../types/components";
44
44
  const props = withDefaults(defineProps<CookieConsentBannerProps>(), {
45
45
  theme: undefined,
46
46
  ariaLabel: "Cookie consent",
47
+ acceptLabel: "Accept",
48
+ rejectLabel: "Reject",
47
49
  icon: "material-symbols:cookie-outline",
48
50
  showIcon: true,
49
51
  styleClassPassthrough: () => [],
@@ -91,7 +93,7 @@ const isClosed = computed(() => status.value !== "unset");
91
93
  opacity var(--_transition-duration) ease-in-out,
92
94
  visibility var(--_transition-duration);
93
95
 
94
- &.closed {
96
+ &.cookie-consent-banner-closed {
95
97
  grid-template-rows: 0fr;
96
98
  opacity: 0;
97
99
  visibility: hidden;
@@ -107,8 +109,10 @@ const isClosed = computed(() => status.value !== "unset");
107
109
  overflow: hidden;
108
110
  }
109
111
 
110
- .cookie-consent-banner-reject,
111
- .cookie-consent-banner-accept {
112
+ .cookie-consent-banner-reject.input-button,
113
+ .cookie-consent-banner-accept.input-button {
114
+ box-sizing: border-box;
115
+ min-height: auto;
112
116
  padding: var(--cookie-consent-banner-button-padding, 0.8rem 1.6rem);
113
117
  border-radius: var(--cookie-consent-banner-button-border-radius, 0.4rem);
114
118
  border: 0.1rem solid transparent;
@@ -122,9 +126,14 @@ const isClosed = computed(() => status.value !== "unset");
122
126
  outline: 0.2rem solid var(--cookie-consent-banner-focus-ring, var(--theme-border-focus));
123
127
  outline-offset: 0.2rem;
124
128
  }
129
+
130
+ .button-text {
131
+ font-size: inherit;
132
+ text-transform: none;
133
+ }
125
134
  }
126
135
 
127
- .cookie-consent-banner-reject {
136
+ .cookie-consent-banner-reject.input-button {
128
137
  background-color: transparent;
129
138
  color: inherit;
130
139
  border-color: var(--cookie-consent-banner-reject-border-colour, var(--theme-border));
@@ -135,7 +144,7 @@ const isClosed = computed(() => status.value !== "unset");
135
144
  }
136
145
  }
137
146
 
138
- .cookie-consent-banner-accept {
147
+ .cookie-consent-banner-accept.input-button {
139
148
  background-color: var(--cookie-consent-banner-accept-background, var(--_accent));
140
149
  color: var(--cookie-consent-banner-accept-text-colour, var(--theme-on-surface));
141
150
 
@@ -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: "Reads/writes a real 'privacy-notice-consent' cookie via useCookieConsent(). Use the story's reset controls to bring the banner back.",
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 acceptLabel/rejectLabel slot content", async () => {
98
- await mountSuspended(CookieConsentBanner, { slots: { acceptLabel: "Yes please", rejectLabel: "No thanks" } });
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().click();
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().click();
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().click();
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
- All `--input-copy-*` tokens are the stable override surface. Set them at any scope (global, page,
6
- or instance) without touching the component itself.
25
+ ### Layout
7
26
 
8
27
  | Token | Default | Controls |
9
28
  |---|---|---|
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(--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
- ## Global theming
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
- 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.
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
- /* assets/styles/setup/07.components/input-copy.css */
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 component need
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
- ### Page or section
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
- When input-copy appears in a specific page context (e.g., checkout success page), scope overrides
73
- under the page wrapper:
98
+ ## Class passthrough
74
99
 
75
- ```css
76
- /* In the consuming page's unscoped <style> block */
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
- Typical usage on a success page or dashboard to display a license key:
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.