srcdev-nuxt-components 9.3.12 → 9.4.0
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/skills/components/cookie-consent-banner.md +88 -0
- package/.claude/skills/composable-cookie-consent.md +78 -0
- package/.claude/skills/composable-google-analytics.md +87 -0
- package/.claude/skills/index.md +5 -1
- package/.claude/skills/theming-component-token-pattern.md +126 -0
- package/app/app.config.ts +10 -1
- package/app/assets/styles/setup/04.elements/forms/06.button-geometry.css +10 -1
- package/app/components/01.atoms/cookie-consent-banner/CookieConsentBanner.vue +148 -0
- package/app/components/01.atoms/cookie-consent-banner/stories/CookieConsentBanner.stories.ts +93 -0
- package/app/components/01.atoms/cookie-consent-banner/tests/CookieConsentBanner.spec.ts +118 -0
- package/app/components/02.molecules/input-copy/CONSUMER-STYLING.md +3 -3
- package/app/components/02.molecules/input-copy/InputCopy.vue +9 -3
- package/app/components/02.molecules/pricing-card/CONSUMER-STYLING.md +3 -3
- package/app/components/02.molecules/pricing-card/PricingCard.vue +6 -3
- package/app/components/05.forms/input-button/CONSUMER-STYLING.md +117 -0
- package/app/components/05.forms/input-button/InputButtonCore.vue +83 -37
- package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/combo-icons-secondary-chromium-darwin.png +0 -0
- package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/combo-icons-tertiary-chromium-darwin.png +0 -0
- package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/combo-primary-pending-chromium-darwin.png +0 -0
- package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/combo-secondary-pill-chromium-darwin.png +0 -0
- package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/combo-tertiary-readonly-chromium-darwin.png +0 -0
- package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/default-chromium-darwin.png +0 -0
- package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/default-theme-default-chromium-darwin.png +0 -0
- package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/default-theme-error-chromium-darwin.png +0 -0
- package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/default-theme-success-chromium-darwin.png +0 -0
- package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/default-theme-warning-chromium-darwin.png +0 -0
- package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/default-variant-primary-chromium-darwin.png +0 -0
- package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/default-variant-secondary-chromium-darwin.png +0 -0
- package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/default-variant-tertiary-chromium-darwin.png +0 -0
- package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/nuxt-icon-only-component-chromium-darwin.png +0 -0
- package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/state-pending-chromium-darwin.png +0 -0
- package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/state-pill-chromium-darwin.png +0 -0
- package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/state-readonly-chromium-darwin.png +0 -0
- package/app/components/05.forms/input-button/playwright/input-button-core.playwright.ts-snapshots/with-both-nuxt-icon-components-chromium-darwin.png +0 -0
- package/app/components/05.forms/input-button/stories/InputButtonCore.stories.ts +35 -0
- package/app/components/05.forms/input-checkbox-radio/CONSUMER-STYLING.md +68 -0
- package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadioButton.vue +19 -9
- package/app/components/05.forms/input-checkbox-radio/InputCheckboxRadioCore.vue +8 -5
- package/app/components/05.forms/input-checkbox-radio/stories/InputCheckboxRadioButton.stories.ts +93 -0
- package/app/components/05.forms/input-checkbox-radio/stories/InputCheckboxRadioCore.stories.ts +75 -0
- package/app/components/05.forms/input-number/CONSUMER-STYLING.md +41 -0
- package/app/components/05.forms/input-number/InputNumberCore.vue +12 -6
- package/app/components/05.forms/input-select/CONSUMER-STYLING.md +35 -29
- package/app/components/05.forms/input-select/InputSelectCore.vue +32 -20
- package/app/components/05.forms/input-select/stories/InputSelectCore.stories.ts +69 -0
- package/app/components/05.forms/input-text/CONSUMER-STYLING.md +38 -48
- package/app/components/05.forms/input-text/InputTextCore.vue +35 -18
- package/app/components/05.forms/patterns/stories/ContactForm.stories.ts +184 -0
- package/app/components/05.forms/patterns/stories/LoginForm.stories.ts +125 -0
- package/app/components/05.forms/toggle-switch/CONSUMER-STYLING.md +49 -0
- package/app/components/05.forms/toggle-switch/ToggleSwitchCore.vue +23 -16
- package/app/components/05.forms/triple-toggle-switch/CONSUMER-STYLING.md +72 -0
- package/app/components/05.forms/triple-toggle-switch/TripleToggleSwitchCore.vue +42 -20
- package/app/components/05.forms/triple-toggle-switch/stories/TripleToggleSwitchCore.stories.ts +63 -0
- package/app/components/display-theme-switch/DisplayThemeSwitch.vue +13 -6
- package/app/composables/tests/useCookieConsent.spec.ts +87 -0
- package/app/composables/useCookieConsent.ts +54 -0
- package/app/composables/useGoogleAnalytics.ts +26 -0
- package/app/types/components/cookie-consent-banner.d.ts +8 -0
- package/app/types/components/index.ts +1 -0
- package/nuxt.config.ts +6 -0
- package/package.json +2 -1
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { Meta, StoryFn } from "@nuxtjs/storybook";
|
|
2
|
+
import StorybookComponent from "../CookieConsentBanner.vue";
|
|
3
|
+
|
|
4
|
+
export default {
|
|
5
|
+
title: "Atoms/CookieConsentBanner",
|
|
6
|
+
component: StorybookComponent,
|
|
7
|
+
argTypes: {
|
|
8
|
+
theme: {
|
|
9
|
+
control: { type: "inline-radio" },
|
|
10
|
+
options: ["info", "success", "warning", "error"],
|
|
11
|
+
description: "Semantic theme for the banner's accent border/accept button",
|
|
12
|
+
table: { category: "Appearance" },
|
|
13
|
+
},
|
|
14
|
+
styleClassPassthrough: {
|
|
15
|
+
control: { type: "object" },
|
|
16
|
+
description: "Extra classes applied to the banner root",
|
|
17
|
+
table: { category: "Styling" },
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
args: {
|
|
21
|
+
theme: "info",
|
|
22
|
+
styleClassPassthrough: [],
|
|
23
|
+
},
|
|
24
|
+
parameters: {
|
|
25
|
+
docs: {
|
|
26
|
+
description: {
|
|
27
|
+
// Drives itself from the real useCookieConsent()/useCookie() state, so
|
|
28
|
+
// once accepted/rejected in this browser it stays hidden across story
|
|
29
|
+
// reloads. Each story below has "Show banner again" (resets state
|
|
30
|
+
// in-place) and "Delete cookie" (clears document.cookie directly, for
|
|
31
|
+
// verifying the cookie itself is actually gone, e.g. in devtools).
|
|
32
|
+
component: "Reads/writes a real 'cookie-consent' cookie via useCookieConsent(). Use the story's reset controls to bring the banner back.",
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
} as Meta<typeof StorybookComponent>;
|
|
37
|
+
|
|
38
|
+
// Resets the same "cookie-consent" cookie useCookieConsent() reads, so the
|
|
39
|
+
// banner's status goes back to "unset" and reappears without a page reload
|
|
40
|
+
// (Nuxt dedupes useCookie() by key, so this shares the ref CookieConsentBanner
|
|
41
|
+
// itself reads).
|
|
42
|
+
function useResetConsent() {
|
|
43
|
+
const consentCookie = useCookie<"granted" | "denied" | null>("cookie-consent");
|
|
44
|
+
const showAgain = () => {
|
|
45
|
+
consentCookie.value = null;
|
|
46
|
+
};
|
|
47
|
+
// Belt-and-braces alongside showAgain(): clears the actual browser cookie
|
|
48
|
+
// rather than just the in-memory ref, so devtools/Application tab reflects
|
|
49
|
+
// it too, not only the story's live re-render.
|
|
50
|
+
const deleteCookie = () => {
|
|
51
|
+
document.cookie = "cookie-consent=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/";
|
|
52
|
+
consentCookie.value = null;
|
|
53
|
+
};
|
|
54
|
+
return { showAgain, deleteCookie };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const Template: StoryFn<typeof StorybookComponent> = (args) => ({
|
|
58
|
+
components: { StorybookComponent },
|
|
59
|
+
setup() {
|
|
60
|
+
const { showAgain, deleteCookie } = useResetConsent();
|
|
61
|
+
return { args, showAgain, deleteCookie };
|
|
62
|
+
},
|
|
63
|
+
template: `
|
|
64
|
+
<div style="padding: 2rem; min-height: 240px; position: relative;">
|
|
65
|
+
<button type="button" @click="showAgain">Show banner again</button>
|
|
66
|
+
<button type="button" @click="deleteCookie" style="margin-inline-start: 0.8rem;">Delete cookie</button>
|
|
67
|
+
<StorybookComponent :theme="args.theme" :style-class-passthrough="args.styleClassPassthrough">
|
|
68
|
+
<template #message>This site uses cookies for analytics. You can accept or reject them.</template>
|
|
69
|
+
</StorybookComponent>
|
|
70
|
+
</div>
|
|
71
|
+
`,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
export const Default = Template.bind({});
|
|
75
|
+
|
|
76
|
+
export const CustomCopy: StoryFn<typeof StorybookComponent> = (args) => ({
|
|
77
|
+
components: { StorybookComponent },
|
|
78
|
+
setup() {
|
|
79
|
+
const { showAgain, deleteCookie } = useResetConsent();
|
|
80
|
+
return { args, showAgain, deleteCookie };
|
|
81
|
+
},
|
|
82
|
+
template: `
|
|
83
|
+
<div style="padding: 2rem; min-height: 240px; position: relative;">
|
|
84
|
+
<button type="button" @click="showAgain">Show banner again</button>
|
|
85
|
+
<button type="button" @click="deleteCookie" style="margin-inline-start: 0.8rem;">Delete cookie</button>
|
|
86
|
+
<StorybookComponent :theme="args.theme">
|
|
87
|
+
<template #message>We use cookies to understand traffic to this site. No personal data is sold.</template>
|
|
88
|
+
<template #acceptLabel>Allow cookies</template>
|
|
89
|
+
<template #rejectLabel>No thanks</template>
|
|
90
|
+
</StorybookComponent>
|
|
91
|
+
</div>
|
|
92
|
+
`,
|
|
93
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { ref } from "vue";
|
|
3
|
+
import { mountSuspended, mockNuxtImport } from "@nuxt/test-utils/runtime";
|
|
4
|
+
import CookieConsentBanner from "../CookieConsentBanner.vue";
|
|
5
|
+
|
|
6
|
+
const { useAppConfigMock } = vi.hoisted(() => ({
|
|
7
|
+
useAppConfigMock: vi.fn(() => ({ srcdev: undefined as Record<string, unknown> | undefined, icon: {} as object })),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
const statusRef = ref<"unset" | "granted" | "denied">("unset");
|
|
11
|
+
const acceptAllSpy = vi.fn(() => {
|
|
12
|
+
statusRef.value = "granted";
|
|
13
|
+
});
|
|
14
|
+
const rejectAllSpy = vi.fn(() => {
|
|
15
|
+
statusRef.value = "denied";
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
mockNuxtImport("useAppConfig", () => useAppConfigMock);
|
|
19
|
+
mockNuxtImport("useCookieConsent", () => () => ({
|
|
20
|
+
status: statusRef,
|
|
21
|
+
acceptAll: acceptAllSpy,
|
|
22
|
+
rejectAll: rejectAllSpy,
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
function banner() {
|
|
26
|
+
return document.querySelector(".cookie-consent-banner");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function acceptButton() {
|
|
30
|
+
return document.querySelector("[data-test-id='cookie-consent-banner-accept']") as HTMLElement;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function rejectButton() {
|
|
34
|
+
return document.querySelector("[data-test-id='cookie-consent-banner-reject']") as HTMLElement;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe("CookieConsentBanner", () => {
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
statusRef.value = "unset";
|
|
40
|
+
acceptAllSpy.mockClear();
|
|
41
|
+
rejectAllSpy.mockClear();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
document.body.innerHTML = "";
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("mounts without error", async () => {
|
|
49
|
+
const w = await mountSuspended(CookieConsentBanner);
|
|
50
|
+
expect(w.vm).toBeTruthy();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("renders the banner, teleported to body", async () => {
|
|
54
|
+
await mountSuspended(CookieConsentBanner);
|
|
55
|
+
expect(banner()).not.toBeNull();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("is visible (no closed class) while status is 'unset'", async () => {
|
|
59
|
+
await mountSuspended(CookieConsentBanner);
|
|
60
|
+
expect(banner()!.classList).not.toContain("closed");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("adds the closed class once status is no longer 'unset'", async () => {
|
|
64
|
+
statusRef.value = "granted";
|
|
65
|
+
await mountSuspended(CookieConsentBanner);
|
|
66
|
+
expect(banner()!.classList).toContain("closed");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("defaults to data-theme='info'", async () => {
|
|
70
|
+
await mountSuspended(CookieConsentBanner);
|
|
71
|
+
expect(banner()!.getAttribute("data-theme")).toBe("info");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("uses app.config theme when no prop is supplied", async () => {
|
|
75
|
+
useAppConfigMock.mockReturnValue({ icon: {}, srcdev: { cookieConsentBanner: { theme: "success" } } });
|
|
76
|
+
await mountSuspended(CookieConsentBanner);
|
|
77
|
+
expect(banner()!.getAttribute("data-theme")).toBe("success");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("explicit theme prop takes precedence over app.config", async () => {
|
|
81
|
+
useAppConfigMock.mockReturnValue({ icon: {}, srcdev: { cookieConsentBanner: { theme: "warning" } } });
|
|
82
|
+
await mountSuspended(CookieConsentBanner, { props: { theme: "error" } });
|
|
83
|
+
expect(banner()!.getAttribute("data-theme")).toBe("error");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("renders default message copy", async () => {
|
|
87
|
+
await mountSuspended(CookieConsentBanner);
|
|
88
|
+
expect(banner()!.textContent).toContain("cookies");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("renders message slot content", async () => {
|
|
92
|
+
await mountSuspended(CookieConsentBanner, { slots: { message: "Custom cookie copy" } });
|
|
93
|
+
expect(banner()!.textContent).toContain("Custom cookie copy");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("renders acceptLabel/rejectLabel slot content", async () => {
|
|
97
|
+
await mountSuspended(CookieConsentBanner, { slots: { acceptLabel: "Yes please", rejectLabel: "No thanks" } });
|
|
98
|
+
expect(acceptButton().textContent).toContain("Yes please");
|
|
99
|
+
expect(rejectButton().textContent).toContain("No thanks");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("calls acceptAll() when the accept button is clicked", async () => {
|
|
103
|
+
await mountSuspended(CookieConsentBanner);
|
|
104
|
+
acceptButton().click();
|
|
105
|
+
expect(acceptAllSpy).toHaveBeenCalledOnce();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("calls rejectAll() when the reject button is clicked", async () => {
|
|
109
|
+
await mountSuspended(CookieConsentBanner);
|
|
110
|
+
rejectButton().click();
|
|
111
|
+
expect(rejectAllSpy).toHaveBeenCalledOnce();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("applies a styleClassPassthrough class to the root", async () => {
|
|
115
|
+
await mountSuspended(CookieConsentBanner, { props: { styleClassPassthrough: "outlined" } });
|
|
116
|
+
expect(banner()!.classList).toContain("outlined");
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -14,9 +14,9 @@ or instance) without touching the component itself.
|
|
|
14
14
|
| `--input-copy-input-text-color` | `#333` | Input text colour |
|
|
15
15
|
| `--input-copy-input-placeholder-color` | `#999` | Placeholder text colour |
|
|
16
16
|
| `--input-copy-input-font-size` | `0.95rem` | Input field font size |
|
|
17
|
-
| `--input-copy-button-bg` | `var(--
|
|
18
|
-
| `--input-copy-button-bg-hover` | `var(--
|
|
19
|
-
| `--input-copy-button-text-color` | `var(--
|
|
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
20
|
| `--input-copy-button-padding` | `0.8rem 1.2rem` | Copy button padding |
|
|
21
21
|
| `--input-copy-button-border-radius` | `0.4rem` | Copy button corner rounding |
|
|
22
22
|
| `--input-copy-button-gap` | `0.6rem` | Gap between icon and text in button |
|
|
@@ -102,9 +102,15 @@ onUnmounted(() => {
|
|
|
102
102
|
--_input-placeholder-color: var(--input-copy-input-placeholder-color, #999);
|
|
103
103
|
--_input-font-size: var(--input-copy-input-font-size, 0.95rem);
|
|
104
104
|
|
|
105
|
-
--_button-bg: var(--input-copy-button-bg, var(--
|
|
106
|
-
--_button-bg-hover: var(
|
|
107
|
-
|
|
105
|
+
--_button-bg: var(--input-copy-button-bg, var(--input-button-primary-surface, var(--theme-surface)));
|
|
106
|
+
--_button-bg-hover: var(
|
|
107
|
+
--input-copy-button-bg-hover,
|
|
108
|
+
var(--input-button-primary-surface-hover, var(--theme-surface-hover))
|
|
109
|
+
);
|
|
110
|
+
--_button-text-color: var(
|
|
111
|
+
--input-copy-button-text-color,
|
|
112
|
+
var(--input-button-primary-text, var(--theme-on-surface))
|
|
113
|
+
);
|
|
108
114
|
--_button-padding: var(--input-copy-button-padding, 0.8rem 1.2rem);
|
|
109
115
|
--_button-border-radius: var(--input-copy-button-border-radius, 0.4rem);
|
|
110
116
|
--_button-gap: var(--input-copy-button-gap, 0.6rem);
|
|
@@ -33,9 +33,9 @@ override it — there is no locale-aware formatting, just a literal prefix chara
|
|
|
33
33
|
| `--pricing-card-description-color` | `#555` | Plan description text colour |
|
|
34
34
|
| `--pricing-card-feature-color` | `#333` | Feature list text colour |
|
|
35
35
|
| `--pricing-card-cta-align` | `flex-start` | CTA wrapper `align-self` — set to `stretch`, `center`, or `flex-end` to change CTA alignment/width within the card |
|
|
36
|
-
| `--pricing-card-cta-bg` | `var(--
|
|
37
|
-
| `--pricing-card-cta-bg-hover` | `var(--
|
|
38
|
-
| `--pricing-card-cta-text` | `var(--
|
|
36
|
+
| `--pricing-card-cta-bg` | `var(--input-button-primary-surface)` | CTA button background |
|
|
37
|
+
| `--pricing-card-cta-bg-hover` | `var(--input-button-primary-surface-hover)` | CTA button background on hover |
|
|
38
|
+
| `--pricing-card-cta-text` | `var(--input-button-primary-text)` | CTA button text colour |
|
|
39
39
|
| `--pricing-card-cta-padding` | `1rem 1.6rem` | CTA button padding |
|
|
40
40
|
| `--pricing-card-cta-border-radius` | `0.4rem` | CTA button corner rounding |
|
|
41
41
|
|
|
@@ -111,9 +111,12 @@ watch(
|
|
|
111
111
|
--_feature-color: var(--pricing-card-feature-color, #333);
|
|
112
112
|
|
|
113
113
|
--_cta-align: var(--pricing-card-cta-align, flex-start);
|
|
114
|
-
--_cta-bg: var(--pricing-card-cta-bg, var(--
|
|
115
|
-
--_cta-bg-hover: var(
|
|
116
|
-
|
|
114
|
+
--_cta-bg: var(--pricing-card-cta-bg, var(--input-button-primary-surface, var(--theme-surface)));
|
|
115
|
+
--_cta-bg-hover: var(
|
|
116
|
+
--pricing-card-cta-bg-hover,
|
|
117
|
+
var(--input-button-primary-surface-hover, var(--theme-surface-hover))
|
|
118
|
+
);
|
|
119
|
+
--_cta-text: var(--pricing-card-cta-text, var(--input-button-primary-text, var(--theme-on-surface)));
|
|
117
120
|
--_cta-padding: var(--pricing-card-cta-padding, 1rem 1.6rem);
|
|
118
121
|
--_cta-border-radius: var(--pricing-card-cta-border-radius, 0.4rem);
|
|
119
122
|
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# InputButtonCore — Consumer Styling Guide
|
|
2
|
+
|
|
3
|
+
## Public token API
|
|
4
|
+
|
|
5
|
+
Every colour token below is `var(--input-button-{variant}-{property}, {default})` — set the
|
|
6
|
+
`--input-button-*` name to override just this component's variant; leave it unset and it inherits
|
|
7
|
+
the shared `--theme-*` slot every other themed component (inputs, alerts, ...) also falls back to.
|
|
8
|
+
See `theming-component-token-pattern.md` for why this two-tier shape exists.
|
|
9
|
+
|
|
10
|
+
**Resting state is flat by design**: `border` defaults to the variant's own surface colour, so
|
|
11
|
+
there's no visible edge until you hover, focus, or override it. **`outline` defaults to fully
|
|
12
|
+
transparent** in both resting and hover state — outline is reserved for the focus-visible ring,
|
|
13
|
+
not used to reinforce the flat look. **Hover always darkens the resting surface** via `color-mix()`
|
|
14
|
+
rather than jumping to a different `--theme-*` step — it's reliably darker in both light and dark
|
|
15
|
+
mode. **`:focus-visible` is a separate token set**, not grouped with hover, since it's the one
|
|
16
|
+
state that gets a real, independently-themeable indicator (its `outline-width`/`-offset` come from
|
|
17
|
+
`--button-focus-ring-width`/`-offset`, not from here — see `theming-form-geometry-tokens.md`).
|
|
18
|
+
|
|
19
|
+
Geometry and typography (`--button-*`) are separate, already-global tokens with real defaults —
|
|
20
|
+
see `theming-form-geometry-tokens.md`. Not duplicated here. One geometry token worth calling out:
|
|
21
|
+
`--button-min-height` defaults to `var(--input-min-height)`, so a button lines up height-wise with
|
|
22
|
+
a text input/select/number field placed next to it (e.g. an inline "email + submit" pattern — see
|
|
23
|
+
the `InlineWithTextInput` story). `box-sizing: content-box` means it's a floor, not a fixed height.
|
|
24
|
+
|
|
25
|
+
### Primary
|
|
26
|
+
|
|
27
|
+
| Token | Falls back to | Controls |
|
|
28
|
+
|---|---|---|
|
|
29
|
+
| `--input-button-primary-surface` | `var(--theme-surface)` | Background (resting) |
|
|
30
|
+
| `--input-button-primary-text` | `var(--theme-on-surface)` | Text colour (all states) |
|
|
31
|
+
| `--input-button-primary-border` | the resolved surface above | Border colour (resting) — flat by default |
|
|
32
|
+
| `--input-button-primary-ring` | `transparent` | Outline colour (resting) |
|
|
33
|
+
| `--input-button-primary-surface-hover` | `color-mix(in oklab, surface 85%, black)` | Background on hover |
|
|
34
|
+
| `--input-button-primary-border-hover` | the resolved hover surface above | Border colour on hover |
|
|
35
|
+
| `--input-button-primary-ring-hover` | `transparent` | Outline colour on hover |
|
|
36
|
+
| `--input-button-primary-border-focus` | `var(--theme-border-focus)` | Border colour on `:focus-visible` |
|
|
37
|
+
| `--input-button-primary-ring-focus` | `var(--theme-ring)` | Outline colour on `:focus-visible` |
|
|
38
|
+
|
|
39
|
+
### Secondary
|
|
40
|
+
|
|
41
|
+
Same shape, falls back to the inverted-surface slots instead:
|
|
42
|
+
|
|
43
|
+
| Token | Falls back to |
|
|
44
|
+
|---|---|
|
|
45
|
+
| `--input-button-secondary-surface` | `var(--theme-surface-inverted)` |
|
|
46
|
+
| `--input-button-secondary-text` | `var(--theme-text-inverted)` |
|
|
47
|
+
| `--input-button-secondary-border` | the resolved surface above (flat) |
|
|
48
|
+
| `--input-button-secondary-ring` / `-ring-hover` | `transparent` |
|
|
49
|
+
| `--input-button-secondary-surface-hover` | `color-mix(in oklab, surface 85%, black)` |
|
|
50
|
+
| `--input-button-secondary-border-hover` | the resolved hover surface above |
|
|
51
|
+
| `--input-button-secondary-border-focus` | `var(--theme-border-focus)` |
|
|
52
|
+
| `--input-button-secondary-ring-focus` | `var(--theme-ring)` |
|
|
53
|
+
|
|
54
|
+
### Tertiary
|
|
55
|
+
|
|
56
|
+
Same shape again; resting surface is transparent by default in dark mode (see note below):
|
|
57
|
+
|
|
58
|
+
| Token | Falls back to |
|
|
59
|
+
|---|---|
|
|
60
|
+
| `--input-button-tertiary-surface` | `light-dark(var(--slate-01), transparent)` |
|
|
61
|
+
| `--input-button-tertiary-text` | `var(--theme-text)` |
|
|
62
|
+
| `--input-button-tertiary-border` | the resolved surface above (flat) |
|
|
63
|
+
| `--input-button-tertiary-ring` / `-ring-hover` | `transparent` |
|
|
64
|
+
| `--input-button-tertiary-surface-hover` | `color-mix(in oklab, surface 85%, black)` |
|
|
65
|
+
| `--input-button-tertiary-border-hover` | the resolved hover surface above |
|
|
66
|
+
| `--input-button-tertiary-border-focus` | `var(--theme-border-focus)` |
|
|
67
|
+
| `--input-button-tertiary-ring-focus` | `var(--theme-ring)` |
|
|
68
|
+
|
|
69
|
+
> `--input-button-tertiary-surface`'s dark-mode default is literal `transparent`. `color-mix()`
|
|
70
|
+
> still produces a correct darker hover from that — mixing `transparent` with `black` lowers the
|
|
71
|
+
> alpha channel rather than the lightness, so hover reads as a faint dark tint over whatever's
|
|
72
|
+
> behind the button, not a flat fill swap.
|
|
73
|
+
|
|
74
|
+
`.is-pending` always dims the *primary* surface regardless of variant — a pre-existing quirk, not
|
|
75
|
+
something this token set changes.
|
|
76
|
+
|
|
77
|
+
`InputCopy` and `PricingCard` fall back through `--input-button-primary-*` for their own copy/CTA
|
|
78
|
+
buttons (see their own `CONSUMER-STYLING.md`) rather than duplicating this chain.
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## Global theming — app-level CSS file
|
|
83
|
+
|
|
84
|
+
Set tokens on `:where(html)` in the consuming app's own theming file (e.g.
|
|
85
|
+
`assets/styles/setup/03.theming/_button.css`). These apply to every `InputButtonCore` instance:
|
|
86
|
+
|
|
87
|
+
```css
|
|
88
|
+
:where(html) {
|
|
89
|
+
--input-button-primary-surface: var(--rose-09);
|
|
90
|
+
--input-button-primary-text: var(--rose-00);
|
|
91
|
+
/* Hover/border/ring all derive from --input-button-primary-surface automatically via
|
|
92
|
+
color-mix() — no need to also set -hover/-border/-ring unless you want a different
|
|
93
|
+
relationship than "same colour, darker". */
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Only declare the tokens you want to change — everything else keeps inheriting the shared
|
|
98
|
+
`--theme-*` slots (or, for hover, keeps darkening whatever surface you did set).
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## Per-instance overrides
|
|
103
|
+
|
|
104
|
+
```vue
|
|
105
|
+
<InputButtonCore variant="primary" button-text="Book now" style="--input-button-primary-surface: var(--gold-06);" />
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
To make hover lighten instead of darken for one instance, override the hover token directly rather
|
|
109
|
+
than fighting the `color-mix()` default:
|
|
110
|
+
|
|
111
|
+
```vue
|
|
112
|
+
<InputButtonCore
|
|
113
|
+
variant="primary"
|
|
114
|
+
button-text="Book now"
|
|
115
|
+
style="--input-button-primary-surface-hover: var(--gold-04);"
|
|
116
|
+
/>
|
|
117
|
+
```
|
|
@@ -106,6 +106,7 @@ const { elementClasses } = useStyleClassPassthrough(props.styleClassPassthrough)
|
|
|
106
106
|
justify-content: center;
|
|
107
107
|
align-items: center;
|
|
108
108
|
box-sizing: content-box;
|
|
109
|
+
min-height: var(--button-min-height);
|
|
109
110
|
border-radius: var(--button-border-radius);
|
|
110
111
|
font-family: var(--font-family);
|
|
111
112
|
padding-inline: var(--button-padding-inline);
|
|
@@ -121,60 +122,105 @@ const { elementClasses } = useStyleClassPassthrough(props.styleClassPassthrough)
|
|
|
121
122
|
|
|
122
123
|
/*
|
|
123
124
|
* Theming
|
|
125
|
+
* Every colour is a public --input-button-{variant}-{property} token, inline-fallback to
|
|
126
|
+
* the shared theme slot (see theming-component-token-pattern.md for why: overriding one
|
|
127
|
+
* variant here never touches the --theme-* slots other components read).
|
|
128
|
+
*
|
|
129
|
+
* Flat by default: border defaults to the variant's own surface colour (so the resting state
|
|
130
|
+
* shows no visible edge) and outline defaults to fully transparent — outline is reserved for
|
|
131
|
+
* the focus-visible ring, not used to reinforce the flat look. Hover darkens the surface via
|
|
132
|
+
* color-mix (always darker, regardless of which direction the underlying theme ramp runs in
|
|
133
|
+
* light vs dark mode — no more "hover looks lighter" surprises) and carries the border along
|
|
134
|
+
* with it, still flat; outline stays transparent through hover too.
|
|
135
|
+
* focus-visible is deliberately its own block, not grouped with :hover — it's the one state
|
|
136
|
+
* that gets a real, separately-themeable a11y indicator, and its outline-width/-offset (set
|
|
137
|
+
* below, in Shared States) give it a visible ring regardless of the border/outline being
|
|
138
|
+
* invisible everywhere else.
|
|
139
|
+
* --_surface is a private local (not a public token) — it exists purely so -hover/-border/-ring
|
|
140
|
+
* can color-mix() off the SAME resolved value a consumer's --input-button-{variant}-surface
|
|
141
|
+
* override produces, without every derived token repeating that fallback chain itself.
|
|
124
142
|
**/
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
outline: var(--button-outline-width) solid var(--theme-ring);
|
|
143
|
+
&.primary {
|
|
144
|
+
--_surface: var(--input-button-primary-surface, var(--theme-surface));
|
|
145
|
+
--_surface-hover: var(--input-button-primary-surface-hover, color-mix(in oklab, var(--_surface) 85%, black));
|
|
129
146
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
147
|
+
background-color: var(--_surface);
|
|
148
|
+
color: var(--input-button-primary-text, var(--theme-on-surface));
|
|
149
|
+
border: var(--button-border-width) solid var(--input-button-primary-border, var(--_surface));
|
|
150
|
+
outline: var(--button-outline-width) solid var(--input-button-primary-ring, transparent);
|
|
151
|
+
|
|
152
|
+
&:hover {
|
|
153
|
+
background-color: var(--_surface-hover);
|
|
154
|
+
border-color: var(--input-button-primary-border-hover, var(--_surface-hover));
|
|
155
|
+
outline-color: var(--input-button-primary-ring-hover, transparent);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
&:focus-visible {
|
|
159
|
+
border-color: var(--input-button-primary-border-focus, var(--theme-border-focus));
|
|
160
|
+
outline-color: var(--input-button-primary-ring-focus, var(--theme-ring));
|
|
161
|
+
}
|
|
142
162
|
}
|
|
143
163
|
|
|
144
164
|
&.is-pending {
|
|
145
|
-
|
|
165
|
+
/* Always the primary surface regardless of variant — pre-existing behaviour, not
|
|
166
|
+
something this migration changes. */
|
|
167
|
+
background-color: color-mix(
|
|
168
|
+
in oklab,
|
|
169
|
+
var(--input-button-primary-surface, var(--theme-surface)) 50%,
|
|
170
|
+
transparent
|
|
171
|
+
);
|
|
146
172
|
}
|
|
147
173
|
|
|
148
174
|
&.secondary {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
175
|
+
--_surface: var(--input-button-secondary-surface, var(--theme-surface-inverted));
|
|
176
|
+
--_surface-hover: var(
|
|
177
|
+
--input-button-secondary-surface-hover,
|
|
178
|
+
color-mix(in oklab, var(--_surface) 85%, black)
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
background-color: var(--_surface);
|
|
182
|
+
color: var(--input-button-secondary-text, var(--theme-text-inverted));
|
|
183
|
+
border: var(--button-border-width) solid var(--input-button-secondary-border, var(--_surface));
|
|
184
|
+
outline: var(--button-outline-width) solid var(--input-button-secondary-ring, transparent);
|
|
185
|
+
|
|
186
|
+
&:hover {
|
|
187
|
+
background-color: var(--_surface-hover);
|
|
188
|
+
border-color: var(--input-button-secondary-border-hover, var(--_surface-hover));
|
|
189
|
+
outline-color: var(--input-button-secondary-ring-hover, transparent);
|
|
190
|
+
}
|
|
153
191
|
|
|
154
|
-
&:hover,
|
|
155
192
|
&:focus-visible {
|
|
156
|
-
|
|
157
|
-
color: var(--theme-
|
|
158
|
-
border-color: var(--theme-border-focus);
|
|
159
|
-
outline-color: var(--theme-surface);
|
|
193
|
+
border-color: var(--input-button-secondary-border-focus, var(--theme-border-focus));
|
|
194
|
+
outline-color: var(--input-button-secondary-ring-focus, var(--theme-ring));
|
|
160
195
|
}
|
|
161
196
|
}
|
|
162
197
|
|
|
163
198
|
&.tertiary {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
199
|
+
/* --_surface can resolve to literal transparent (dark mode default) — color-mix() still
|
|
200
|
+
darkens correctly here: mixing transparent with black lowers the alpha channel rather
|
|
201
|
+
than the lightness, so it reads as a faint dark tint over whatever's behind the button
|
|
202
|
+
instead of a flat fill. Same formula, no special-casing needed. */
|
|
203
|
+
--_surface: var(--input-button-tertiary-surface, light-dark(var(--slate-01), transparent));
|
|
204
|
+
--_surface-hover: var(
|
|
205
|
+
--input-button-tertiary-surface-hover,
|
|
206
|
+
color-mix(in oklab, var(--_surface) 85%, black)
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
background-color: var(--_surface);
|
|
210
|
+
color: var(--input-button-tertiary-text, var(--theme-text));
|
|
211
|
+
border: var(--button-border-width) solid var(--input-button-tertiary-border, var(--_surface));
|
|
212
|
+
outline: var(--button-outline-width) solid var(--input-button-tertiary-ring, transparent);
|
|
167
213
|
text-decoration: underline;
|
|
168
|
-
outline: var(--button-outline-width) solid var(--theme-border-focus);
|
|
169
214
|
|
|
170
|
-
&:hover
|
|
215
|
+
&:hover {
|
|
216
|
+
background-color: var(--_surface-hover);
|
|
217
|
+
border-color: var(--input-button-tertiary-border-hover, var(--_surface-hover));
|
|
218
|
+
outline-color: var(--input-button-tertiary-ring-hover, transparent);
|
|
219
|
+
}
|
|
220
|
+
|
|
171
221
|
&:focus-visible {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
needs to switch off --theme-text (dark in light mode) to stay visible. */
|
|
175
|
-
color: var(--theme-on-surface);
|
|
176
|
-
border-color: var(--theme-border-focus);
|
|
177
|
-
outline-color: var(--theme-border-focus);
|
|
222
|
+
border-color: var(--input-button-tertiary-border-focus, var(--theme-border-focus));
|
|
223
|
+
outline-color: var(--input-button-tertiary-ring-focus, var(--theme-ring));
|
|
178
224
|
}
|
|
179
225
|
}
|
|
180
226
|
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Meta, StoryFn } from "@nuxtjs/storybook";
|
|
2
2
|
import { ref } from "vue";
|
|
3
3
|
import StorybookComponent from "../InputButtonCore.vue";
|
|
4
|
+
import InputTextCore from "../../input-text/InputTextCore.vue";
|
|
4
5
|
|
|
5
6
|
interface InputButtonCoreStoryArgs {
|
|
6
7
|
type: "submit" | "button" | "reset";
|
|
@@ -240,7 +241,17 @@ const Template: StoryFn<InputButtonCoreStoryArgs> = (args) => ({
|
|
|
240
241
|
},
|
|
241
242
|
template: `
|
|
242
243
|
<div style="display: flex; flex-direction: column; gap: 1rem; align-items: flex-start;margin: 36px;">
|
|
244
|
+
<!-- :key forces a remount when any slot-visibility arg changes. Verified the args
|
|
245
|
+
themselves ARE reactive here (a plain {{ args.useLeftSlot }} interpolation updates
|
|
246
|
+
live on toggle) — the bug is narrower: Vue's compiled-at-runtime string template
|
|
247
|
+
doesn't reliably re-evaluate a <template v-if="..."> conditionally attached to a named
|
|
248
|
+
slot when only the v-if's condition changes, on this component/Storybook combination.
|
|
249
|
+
Toggling a control changed the fallthrough attribute (confirmed via useleftslot="true"
|
|
250
|
+
in the rendered DOM) but never actually added/removed the slot content until a hard
|
|
251
|
+
refresh. Remounting sidesteps whatever the underlying slot-stability quirk is, rather
|
|
252
|
+
than chasing it further. -->
|
|
243
253
|
<StorybookComponent
|
|
254
|
+
:key="[args.useLeftSlot, args.useLeftIcon, args.useRightSlot, args.useRightIcon, args.useIconOnlySlot, args.useIconOnly].join(',')"
|
|
244
255
|
v-bind="args"
|
|
245
256
|
@click="handleClick"
|
|
246
257
|
>
|
|
@@ -302,3 +313,27 @@ NuxtIconOnlyComponent.args = {
|
|
|
302
313
|
useIconOnly: true,
|
|
303
314
|
iconOnlyName: "mdi:chevron-right-circle-outline",
|
|
304
315
|
};
|
|
316
|
+
|
|
317
|
+
// Composition check, not a props demo: confirms InputButtonCore's --button-min-height (defaults
|
|
318
|
+
// to var(--input-min-height)) actually keeps it height-aligned with an inline text input, the
|
|
319
|
+
// most common "usual suspect" pairing (e.g. newsletter signup, search bar). Textareas are the
|
|
320
|
+
// deliberate exception to this alignment and aren't part of this story.
|
|
321
|
+
const InlineTemplate: StoryFn = () => ({
|
|
322
|
+
components: { StorybookComponent, InputTextCore },
|
|
323
|
+
template: `
|
|
324
|
+
<div style="margin: 36px; max-width: 480px;">
|
|
325
|
+
<div style="display: flex; align-items: flex-start; gap: 0.8rem;">
|
|
326
|
+
<InputTextCore
|
|
327
|
+
id="inline-email"
|
|
328
|
+
name="email"
|
|
329
|
+
type="email"
|
|
330
|
+
placeholder="you@example.com"
|
|
331
|
+
style="flex: 1;"
|
|
332
|
+
/>
|
|
333
|
+
<StorybookComponent type="submit" variant="primary" button-text="Subscribe" />
|
|
334
|
+
</div>
|
|
335
|
+
</div>
|
|
336
|
+
`,
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
export const InlineWithTextInput = InlineTemplate.bind({});
|