srcdev-nuxt-components 9.6.2 → 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.
- package/.claude/rules/consumer/consumer-overrides.md +17 -0
- package/.claude/rules/consumer/css-comment-slash.md +13 -0
- package/.claude/rules/consumer/css-native-nesting.md +13 -0
- package/.claude/rules/consumer/css-no-deep.md +13 -0
- package/.claude/rules/consumer/css-rem-base.md +11 -0
- package/.claude/rules/consumer/css-surface-text-pairing.md +13 -0
- package/.claude/rules/consumer/css-webkit-support.md +15 -0
- package/.claude/rules/consumer/explicit-vue-imports.md +11 -0
- package/.claude/rules/consumer/nuxt-img-dimensions.md +12 -0
- package/.claude/rules/consumer/srcdev-skills-location.md +9 -0
- package/.claude/rules/consumer/story-args.md +12 -0
- package/.claude/rules/consumer/story-register-components.md +12 -0
- package/.claude/rules/consumer/vue-auto-imports.md +14 -0
- package/.claude/rules/consumer/vue-no-self-closing.md +12 -0
- package/.claude/rules/consumer/vue-prop-hyphenation.md +11 -0
- package/.claude/rules/consumer/vue-props-pattern.md +13 -0
- package/.claude/rules/consumer/vue-slots-no-computed.md +14 -0
- package/.claude/rules/consumer/vue-video-source.md +12 -0
- package/.claude/skills/component-compliance-checklist.md +31 -3
- package/.claude/skills/component-inline-action-button.md +33 -2
- package/.claude/skills/component-naming.md +1 -1
- package/.claude/skills/components/cookie-consent-banner.md +9 -6
- package/.claude/skills/components/input-copy.md +47 -100
- package/.claude/skills/components/pricing-card.md +68 -84
- package/.claude/skills/composable-cookie-consent.md +1 -1
- package/.claude/skills/index.md +11 -5
- package/.claude/skills/new-app-scaffold.md +4 -2
- package/.claude/skills/setup-postinstall.md +5 -4
- package/.claude/skills/storybook-add-story.md +44 -0
- package/.claude/skills/testing-add-unit-test.md +19 -0
- package/.claude/skills/theming-component-token-pattern.md +47 -1
- package/.claude/skills/theming-partial-override.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-pricing-card.code-snippets +46 -84
- package/README.md +5 -5
- package/app/components/01.atoms/animations/scroll-reveal-frame/stories/ScrollRevealFrame.stories.ts +2 -0
- package/app/components/01.atoms/animations/scroll-reveal-image/stories/ScrollRevealImage.stories.ts +2 -0
- package/app/components/01.atoms/animations/section-parallax/stories/SectionParallax.stories.ts +4 -0
- package/app/components/01.atoms/animations/view-timeline/stories/WipeAwayVertical.stories.ts +2 -0
- 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 +108 -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 -48
- package/app/components/02.molecules/input-copy/tests/InputCopy.spec.ts +147 -171
- package/app/components/02.molecules/pricing-card/CONSUMER-STYLING.md +96 -149
- package/app/components/02.molecules/pricing-card/PricingCard.vue +84 -44
- package/app/components/02.molecules/pricing-card/stories/PricingCard.stories.ts +241 -104
- package/app/components/02.molecules/pricing-card/tests/PricingCard.spec.ts +149 -123
- package/app/components/02.molecules/toast/stories/DisplayToast.stories.ts +4 -0
- package/app/components/02.molecules/toast/stories/DisplayToastProvider.stories.ts +4 -0
- package/app/components/05.forms/toggle-switch/ToggleSwitchWithLabel.vue +1 -1
- package/app/composables/useAriaDescribedById.ts +1 -1
- package/app/types/components/cookie-consent-banner.d.ts +2 -0
- package/package.json +2 -1
|
@@ -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.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div class="input-copy" :class="elementClasses">
|
|
2
|
+
<div class="input-copy" :class="[{ 'input-copy-copied': copied }, elementClasses]">
|
|
3
3
|
<div class="input-copy__wrapper">
|
|
4
4
|
<input
|
|
5
5
|
:id="resolvedId"
|
|
@@ -8,20 +8,26 @@
|
|
|
8
8
|
:name="name || resolvedId"
|
|
9
9
|
:value="value"
|
|
10
10
|
:aria-label="ariaLabel || label"
|
|
11
|
+
:aria-describedby="description ? descriptionId : undefined"
|
|
11
12
|
readonly
|
|
12
13
|
/>
|
|
13
|
-
<
|
|
14
|
+
<InputButton
|
|
14
15
|
type="button"
|
|
16
|
+
variant="inline"
|
|
15
17
|
class="input-copy__button"
|
|
16
|
-
:
|
|
17
|
-
:
|
|
18
|
+
:button-text="copied ? copiedText : buttonText"
|
|
19
|
+
:aria-label="resolvedButtonAriaLabel"
|
|
20
|
+
:disabled="isDisabled"
|
|
21
|
+
:readonly="copied"
|
|
18
22
|
@click="handleCopy"
|
|
19
23
|
>
|
|
20
|
-
<
|
|
21
|
-
|
|
22
|
-
|
|
24
|
+
<template #left>
|
|
25
|
+
<Icon :name="copied ? copiedIcon : icon" class="input-copy__icon" />
|
|
26
|
+
</template>
|
|
27
|
+
</InputButton>
|
|
23
28
|
</div>
|
|
24
|
-
<p v-if="description" class="input-copy__description">{{ description }}</p>
|
|
29
|
+
<p v-if="description" :id="descriptionId" class="input-copy__description">{{ description }}</p>
|
|
30
|
+
<span class="sr-only" role="status">{{ copied ? copiedText : "" }}</span>
|
|
25
31
|
</div>
|
|
26
32
|
</template>
|
|
27
33
|
|
|
@@ -36,6 +42,9 @@ interface Props {
|
|
|
36
42
|
description?: string;
|
|
37
43
|
buttonText?: string;
|
|
38
44
|
copiedText?: string;
|
|
45
|
+
buttonAriaLabel?: string;
|
|
46
|
+
icon?: string;
|
|
47
|
+
copiedIcon?: string;
|
|
39
48
|
copiedDuration?: number;
|
|
40
49
|
isDisabled?: boolean;
|
|
41
50
|
styleClassPassthrough?: string | string[];
|
|
@@ -49,17 +58,23 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
49
58
|
description: undefined,
|
|
50
59
|
buttonText: "Copy",
|
|
51
60
|
copiedText: "Copied!",
|
|
61
|
+
buttonAriaLabel: undefined,
|
|
62
|
+
icon: "lucide:copy",
|
|
63
|
+
copiedIcon: "lucide:check",
|
|
52
64
|
copiedDuration: 2000,
|
|
53
65
|
isDisabled: false,
|
|
54
66
|
styleClassPassthrough: () => [],
|
|
55
67
|
});
|
|
56
68
|
|
|
57
69
|
// Readonly text inputs still need an id/name to satisfy "a form field element should have an
|
|
58
|
-
// id or name attribute"
|
|
59
|
-
// gets a valid, unique id for free. Named resolvedId, not id, so it can't be confused with (or
|
|
60
|
-
// silently shadow) the `id` prop it falls back to.
|
|
70
|
+
// id or name attribute", so one is auto-generated when none is passed.
|
|
61
71
|
const autoId = useId();
|
|
62
72
|
const resolvedId = computed(() => props.id ?? autoId);
|
|
73
|
+
const descriptionId = computed(() => `${resolvedId.value}-description`);
|
|
74
|
+
|
|
75
|
+
const resolvedButtonAriaLabel = computed(
|
|
76
|
+
() => props.buttonAriaLabel ?? `Copy ${props.ariaLabel || props.label || "value"} to clipboard`
|
|
77
|
+
);
|
|
63
78
|
|
|
64
79
|
const emit = defineEmits<{
|
|
65
80
|
copy: [value: string];
|
|
@@ -95,7 +110,7 @@ watch(
|
|
|
95
110
|
() => props.styleClassPassthrough,
|
|
96
111
|
() => {
|
|
97
112
|
resetElementClasses(props.styleClassPassthrough);
|
|
98
|
-
}
|
|
113
|
+
}
|
|
99
114
|
);
|
|
100
115
|
|
|
101
116
|
onUnmounted(() => {
|
|
@@ -108,95 +123,105 @@ onUnmounted(() => {
|
|
|
108
123
|
<style lang="css">
|
|
109
124
|
@layer components {
|
|
110
125
|
.input-copy {
|
|
111
|
-
--_input-
|
|
112
|
-
--_input-font-size: var(--input-copy-input-font-size, 0.95rem);
|
|
113
|
-
|
|
114
|
-
--_button-bg-hover: var(
|
|
115
|
-
--input-copy-button-bg-hover,
|
|
116
|
-
var(--input-button-primary-surface-hover, var(--theme-surface-hover))
|
|
117
|
-
);
|
|
118
|
-
--_button-text-color: var(
|
|
119
|
-
--input-copy-button-text-color,
|
|
120
|
-
var(--input-button-primary-text, var(--theme-on-surface))
|
|
121
|
-
);
|
|
126
|
+
--_input-font-size: var(--input-copy-input-font-size, var(--step-4));
|
|
122
127
|
|
|
128
|
+
container: input-copy / inline-size;
|
|
123
129
|
display: flex;
|
|
124
130
|
flex-direction: column;
|
|
125
|
-
gap: 0.8rem;
|
|
126
|
-
}
|
|
131
|
+
gap: var(--input-copy-gap, 0.8rem);
|
|
127
132
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
+
.input-copy__wrapper {
|
|
134
|
+
display: flex;
|
|
135
|
+
gap: var(--input-copy-gap, 0.8rem);
|
|
136
|
+
align-items: stretch;
|
|
137
|
+
}
|
|
133
138
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
139
|
+
.input-copy__input {
|
|
140
|
+
flex: 1;
|
|
141
|
+
min-width: 0;
|
|
142
|
+
padding: var(--input-copy-input-padding, 0.8rem 1rem);
|
|
143
|
+
background-color: var(--input-copy-input-bg, var(--theme-input-surface));
|
|
144
|
+
border: var(--input-copy-input-border, 0.1rem solid var(--theme-border));
|
|
145
|
+
border-radius: var(--input-copy-input-border-radius, 0.4rem);
|
|
146
|
+
font-size: var(--_input-font-size);
|
|
147
|
+
color: var(--input-copy-input-text-color, var(--theme-text));
|
|
148
|
+
font-family: inherit;
|
|
149
|
+
|
|
150
|
+
&:focus-visible {
|
|
151
|
+
outline: var(--form-element-outline-width-focus, 0.2rem) solid
|
|
152
|
+
var(--input-copy-focus-ring, var(--theme-border-focus));
|
|
153
|
+
outline-offset: var(--form-element-outline-offset-focus, 0.2rem);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
137
156
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
157
|
+
.input-copy__button.input-button {
|
|
158
|
+
flex-shrink: 0;
|
|
159
|
+
max-inline-size: 50%;
|
|
160
|
+
box-sizing: border-box;
|
|
161
|
+
min-height: auto;
|
|
162
|
+
gap: var(--input-copy-button-gap, 0.6rem);
|
|
163
|
+
padding: var(--input-copy-button-padding, 0.8rem 1.2rem);
|
|
164
|
+
background-color: var(--input-copy-button-bg, var(--input-button-primary-surface, var(--theme-surface)));
|
|
165
|
+
color: var(--input-copy-button-text-color, var(--input-button-primary-text, var(--theme-on-surface)));
|
|
166
|
+
border: none;
|
|
167
|
+
border-radius: var(--input-copy-button-border-radius, 0.4rem);
|
|
168
|
+
font-size: var(--_input-font-size);
|
|
169
|
+
font-weight: 500;
|
|
170
|
+
cursor: pointer;
|
|
171
|
+
transition: background-color 0.2s ease;
|
|
172
|
+
--input-button-text-white-space: normal;
|
|
173
|
+
|
|
174
|
+
&:hover:not(:disabled, [aria-disabled="true"]) {
|
|
175
|
+
background-color: var(
|
|
176
|
+
--input-copy-button-bg-hover,
|
|
177
|
+
var(--input-button-primary-surface-hover, var(--theme-surface-hover))
|
|
178
|
+
);
|
|
179
|
+
}
|
|
145
180
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
181
|
+
&:focus-visible {
|
|
182
|
+
outline: var(--form-element-outline-width-focus, 0.2rem) solid
|
|
183
|
+
var(--input-copy-focus-ring, var(--theme-border-focus));
|
|
184
|
+
outline-offset: var(--form-element-outline-offset-focus, 0.2rem);
|
|
185
|
+
}
|
|
149
186
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
}
|
|
187
|
+
&:disabled {
|
|
188
|
+
opacity: 0.6;
|
|
189
|
+
cursor: not-allowed;
|
|
190
|
+
}
|
|
155
191
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
gap: var(--input-copy-button-gap, 0.6rem);
|
|
160
|
-
padding: var(--input-copy-button-padding, 0.8rem 1.2rem);
|
|
161
|
-
|
|
162
|
-
background-color: var(--input-copy-button-bg, var(--input-button-primary-surface, var(--theme-surface)));
|
|
163
|
-
color: var(--_button-text-color);
|
|
164
|
-
border: none;
|
|
165
|
-
border-radius: var(--input-copy-button-border-radius, 0.4rem);
|
|
166
|
-
font-size: var(--_input-font-size);
|
|
167
|
-
font-weight: 500;
|
|
168
|
-
cursor: pointer;
|
|
169
|
-
white-space: nowrap;
|
|
170
|
-
transition: background-color 0.2s ease;
|
|
171
|
-
|
|
172
|
-
&:hover:not(:disabled) {
|
|
173
|
-
background-color: var(--_button-bg-hover);
|
|
174
|
-
}
|
|
192
|
+
&[aria-disabled="true"] {
|
|
193
|
+
cursor: default;
|
|
194
|
+
}
|
|
175
195
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
196
|
+
.input-copy__icon {
|
|
197
|
+
width: 1.2em;
|
|
198
|
+
height: 1.2em;
|
|
199
|
+
flex-shrink: 0;
|
|
200
|
+
}
|
|
180
201
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
202
|
+
.button-text {
|
|
203
|
+
font-size: inherit;
|
|
204
|
+
text-transform: none;
|
|
205
|
+
}
|
|
185
206
|
}
|
|
186
207
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
@media (min-width: 480px) {
|
|
191
|
-
display: inline;
|
|
208
|
+
@container input-copy (width < 48rem) {
|
|
209
|
+
.input-copy__button.input-button .button-text {
|
|
210
|
+
display: none;
|
|
192
211
|
}
|
|
193
212
|
}
|
|
194
|
-
}
|
|
195
213
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
214
|
+
.input-copy__description {
|
|
215
|
+
margin: var(--input-copy-description-margin, 0);
|
|
216
|
+
font-size: var(--input-copy-description-font-size, var(--step-4));
|
|
217
|
+
color: var(--input-copy-description-color, var(--theme-text));
|
|
218
|
+
display: -webkit-box;
|
|
219
|
+
-webkit-box-orient: vertical;
|
|
220
|
+
overflow: hidden;
|
|
221
|
+
overflow-wrap: anywhere;
|
|
222
|
+
-webkit-line-clamp: var(--input-copy-description-line-clamp, none);
|
|
223
|
+
line-clamp: var(--input-copy-description-line-clamp, none);
|
|
224
|
+
}
|
|
200
225
|
}
|
|
201
226
|
}
|
|
202
227
|
</style>
|