srcdev-nuxt-components 9.4.9 → 9.4.11
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/commands/migrate-component.md +38 -6
- package/.claude/component-ledger/audit.json +1 -1
- package/.claude/component-ledger/build.mjs +26 -0
- package/.claude/component-ledger/output.html +8 -3
- package/.claude/component-ledger/template.html +7 -2
- package/.claude/skills/component-dynamic-slots.md +1 -1
- package/.claude/skills/components/alert-mask-core.md +84 -0
- package/.claude/skills/components/canvas-switcher.md +1 -1
- package/.claude/skills/components/carousel-flip.md +112 -168
- package/.claude/skills/components/container-glow.md +123 -0
- package/.claude/skills/components/dashboard-quad-grid.md +98 -0
- package/.claude/skills/components/dashboard-stats-grid.md +103 -0
- package/.claude/skills/components/display-theme-switch.md +60 -0
- package/.claude/skills/components/glowing-border.md +66 -0
- package/.claude/skills/components/input-number-core.md +214 -0
- package/.claude/skills/components/input-range-core.md +142 -0
- package/.claude/skills/components/input-select-core.md +151 -0
- package/.claude/skills/components/input-text-core.md +16 -0
- package/.claude/skills/components/input-textarea-core.md +154 -0
- package/.claude/skills/components/section-parallax.md +32 -10
- package/.claude/skills/components/select-menu.md +195 -0
- package/.claude/skills/components/toggle-switch-core.md +150 -0
- package/.claude/skills/components/ui-block-decorated.md +56 -0
- package/.claude/skills/components/wipe-away-vertical.md +98 -0
- package/.claude/skills/icon-sets.md +1 -1
- package/.claude/skills/index.md +15 -1
- package/.vscode/css-custom-data.json +38 -0
- package/.vscode/settings.json +3 -0
- package/.vscode/srcdev-component-alert-mask-core.code-snippets +31 -0
- package/.vscode/srcdev-component-carousel-flip.code-snippets +41 -0
- package/.vscode/srcdev-component-container-glow.code-snippets +33 -0
- package/.vscode/srcdev-component-dashboard-quad-grid.code-snippets +36 -0
- package/.vscode/srcdev-component-dashboard-stats-grid.code-snippets +35 -0
- package/.vscode/srcdev-component-display-theme-switch.code-snippets +27 -0
- package/.vscode/srcdev-component-glowing-border.code-snippets +23 -0
- package/.vscode/srcdev-component-input-number.code-snippets +65 -0
- package/.vscode/srcdev-component-input-range.code-snippets +85 -0
- package/.vscode/srcdev-component-input-select.code-snippets +42 -0
- package/.vscode/srcdev-component-input-textarea.code-snippets +40 -0
- package/.vscode/srcdev-component-section-parallax.code-snippets +27 -0
- package/.vscode/srcdev-component-select-menu.code-snippets +65 -0
- package/.vscode/srcdev-component-toggle-switch.code-snippets +49 -0
- package/.vscode/srcdev-component-ui-block-decorated.code-snippets +24 -0
- package/.vscode/srcdev-component-wipe-away-vertical.code-snippets +25 -0
- package/README.md +18 -37
- package/app/components/{alert-mask → 01.atoms/alert-mask}/AlertMaskCore.vue +16 -17
- package/app/components/01.atoms/alert-mask/CONSUMER-STYLING.md +46 -0
- package/app/components/01.atoms/alert-mask/stories/AlertMaskCore.stories.ts +79 -0
- package/app/components/{alert-mask → 01.atoms/alert-mask}/tests/AlertMaskCore.spec.ts +10 -10
- package/app/components/01.atoms/animations/container-glow/CONSUMER-STYLING.md +86 -0
- package/app/components/01.atoms/animations/container-glow/ContainerGlow.vue +299 -0
- package/app/components/01.atoms/animations/container-glow/stories/ContainerGlow.stories.ts +175 -0
- package/app/components/01.atoms/animations/container-glow/tests/ContainerGlow.spec.ts +131 -0
- package/app/components/01.atoms/animations/container-glow/tests/__snapshots__/ContainerGlow.spec.ts.snap +14 -0
- package/app/components/01.atoms/animations/glowing-border/CONSUMER-STYLING.md +61 -0
- package/app/components/01.atoms/animations/glowing-border/GlowingBorder.vue +132 -0
- package/app/components/01.atoms/animations/glowing-border/stories/GlowingBorder.stories.ts +165 -0
- package/app/components/01.atoms/animations/glowing-border/tests/GlowingBorder.spec.ts +94 -0
- package/app/components/01.atoms/animations/glowing-border/tests/__snapshots__/GlowingBorder.spec.ts.snap +3 -0
- package/app/components/01.atoms/animations/marquee-scroller/CONSUMER-STYLING.md +1 -1
- package/app/components/01.atoms/animations/marquee-scroller/MarqueeScroller.vue +1 -1
- package/app/components/01.atoms/animations/marquee-scroller/stories/MarqueeScroller.stories.ts +1 -1
- package/app/components/01.atoms/animations/section-parallax/CONSUMER-STYLING.md +44 -0
- package/app/components/{parallax → 01.atoms/animations/section-parallax}/SectionParallax.vue +11 -10
- package/app/components/01.atoms/animations/section-parallax/stories/SectionParallax.stories.ts +98 -0
- package/app/components/01.atoms/animations/section-parallax/tests/SectionParallax.spec.ts +102 -0
- package/app/components/01.atoms/animations/section-parallax/tests/__snapshots__/SectionParallax.spec.ts.snap +9 -0
- package/app/components/01.atoms/animations/view-timeline/CONSUMER-STYLING.md +70 -0
- package/app/components/01.atoms/animations/view-timeline/WipeAwayVertical.vue +181 -0
- package/app/components/01.atoms/animations/view-timeline/stories/WipeAwayVertical.stories.ts +269 -0
- package/app/components/01.atoms/animations/view-timeline/tests/WipeAwayVertical.spec.ts +127 -0
- package/app/components/01.atoms/canvas-switcher/CONSUMER-STYLING.md +3 -3
- package/app/components/01.atoms/canvas-switcher/CanvasSwitcher.vue +2 -2
- package/app/components/01.atoms/canvas-switcher/stories/CanvasSwitcher.stories.ts +1 -1
- package/app/components/01.atoms/card/CardCore.vue +4 -4
- package/app/components/01.atoms/clipped-panel/CONSUMER-STYLING.md +2 -2
- package/app/components/01.atoms/clipped-panel/ClippedPanel.vue +2 -2
- package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-quad-grid/CONSUMER-STYLING.md +42 -0
- package/app/components/{layout-grids/LayoutGridA.vue → 01.atoms/content-wrappers/layout-grid/dashboard-quad-grid/DashboardQuadGrid.vue} +23 -13
- package/app/components/{layout-grids/stories/LayoutGridA.stories.ts → 01.atoms/content-wrappers/layout-grid/dashboard-quad-grid/stories/DashboardQuadGrid.stories.ts} +151 -23
- package/app/components/{layout-grids/tests/LayoutGridA.spec.ts → 01.atoms/content-wrappers/layout-grid/dashboard-quad-grid/tests/DashboardQuadGrid.spec.ts} +25 -25
- package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-stats-grid/CONSUMER-STYLING.md +41 -0
- package/app/components/01.atoms/content-wrappers/layout-grid/dashboard-stats-grid/DashboardStatsGrid.vue +133 -0
- package/app/components/{layout-grids/stories/LayoutGridB.stories.ts → 01.atoms/content-wrappers/layout-grid/dashboard-stats-grid/stories/DashboardStatsGrid.stories.ts} +24 -24
- package/app/components/{layout-grids/tests/LayoutGridB.spec.ts → 01.atoms/content-wrappers/layout-grid/dashboard-stats-grid/tests/DashboardStatsGrid.spec.ts} +23 -23
- package/app/components/01.atoms/display-tooltip/CONSUMER-STYLING.md +8 -8
- package/app/components/01.atoms/display-tooltip/DisplayTooltip.vue +8 -8
- package/app/components/01.atoms/pop-over/CONSUMER-STYLING.md +6 -6
- package/app/components/01.atoms/pop-over/PopOver.vue +6 -6
- package/app/components/01.atoms/ui-block-decorated/CONSUMER-STYLING.md +39 -0
- package/app/components/01.atoms/ui-block-decorated/UiBlockDecorated.vue +141 -0
- package/app/components/01.atoms/ui-block-decorated/stories/UiBlockDecorated.stories.ts +148 -0
- package/app/components/01.atoms/ui-block-decorated/tests/UiBlockDecorated.spec.ts +80 -0
- package/app/components/01.atoms/ui-block-decorated/tests/__snapshots__/UiBlockDecorated.spec.ts.snap +3 -0
- package/app/components/02.molecules/alert-masked-content/tests/AlertMaskedContent.spec.ts +6 -6
- package/app/components/02.molecules/display-theme-switch/CONSUMER-STYLING.md +34 -0
- package/app/components/02.molecules/display-theme-switch/DisplayThemeSwitch.vue +102 -0
- package/app/components/02.molecules/display-theme-switch/stories/DisplayThemeSwitch.stories.ts +115 -0
- package/app/components/02.molecules/display-theme-switch/tests/DisplayThemeSwitch.spec.ts +86 -0
- package/app/components/02.molecules/navigation/deep-expanding-menu/CONSUMER-STYLING.md +1 -1
- package/app/components/02.molecules/navigation/deep-expanding-menu/DeepExpandingMenu.vue +1 -1
- package/app/components/02.molecules/navigation/deep-expanding-menu-classic/CONSUMER-STYLING.md +1 -1
- package/app/components/02.molecules/navigation/deep-expanding-menu-classic/DeepExpandingMenuClassic.vue +1 -1
- package/app/components/02.molecules/select-menu/CONSUMER-STYLING.md +168 -0
- package/app/components/02.molecules/select-menu/SelectMenu.vue +384 -0
- package/app/components/02.molecules/select-menu/stories/SelectMenu.stories.ts +359 -0
- package/app/components/02.molecules/select-menu/tests/SelectMenu.spec.ts +476 -0
- package/app/components/02.molecules/select-menu/tests/__snapshots__/SelectMenu.spec.ts.snap +42 -0
- package/app/components/03.organisms/image-galleries/carousel-flip/CONSUMER-STYLING.md +46 -0
- package/app/components/{carousels → 03.organisms/image-galleries/carousel-flip}/CarouselFlip.vue +63 -42
- package/app/components/{carousels → 03.organisms/image-galleries/carousel-flip}/stories/CarouselFlip.stories.ts +114 -29
- package/app/components/{carousels → 03.organisms/image-galleries/carousel-flip}/tests/CarouselFlip.spec.ts +80 -0
- package/app/components/05.forms/form-field/FormField.vue +1 -1
- package/app/components/05.forms/form-wrapper/FormWrapper.vue +1 -1
- package/app/components/05.forms/input-button/CONSUMER-STYLING.md +2 -7
- package/app/components/05.forms/input-button/InputButtonCore.vue +1 -5
- package/app/components/05.forms/input-number/CONSUMER-STYLING.md +26 -6
- package/app/components/05.forms/input-number/InputNumberCore.vue +125 -85
- package/app/components/05.forms/input-number/stories/InputNumberCore.stories.ts +92 -0
- package/app/components/05.forms/input-number/tests/InputNumberCore.spec.ts +119 -0
- package/app/components/05.forms/input-number/variants/InputNumberDefault.vue +30 -22
- package/app/components/05.forms/input-number/variants/stories/InputNumberDefault.stories.ts +123 -0
- package/app/components/05.forms/input-number/variants/tests/InputNumberDefault.spec.ts +143 -0
- package/app/components/05.forms/input-range/CONSUMER-STYLING.md +45 -0
- package/app/components/05.forms/input-range/InputRangeCore.vue +79 -143
- package/app/components/05.forms/input-range/stories/InputRangeCore.stories.ts +126 -0
- package/app/components/05.forms/input-range/tests/InputRangeCore.spec.ts +124 -0
- package/app/components/05.forms/input-range/variants/InputRangeDefault.vue +9 -32
- package/app/components/05.forms/input-range/variants/stories/InputRangeDefault.stories.ts +108 -0
- package/app/components/05.forms/input-range/variants/tests/InputRangeDefault.spec.ts +140 -0
- package/app/components/05.forms/input-select/InputSelectCore.vue +23 -30
- package/app/components/05.forms/input-select/stories/InputSelectCore.stories.ts +2 -2
- package/app/components/05.forms/input-select/stories/InputSelectWithLabel.stories.ts +115 -0
- package/app/components/05.forms/input-select/tests/InputSelectCore.spec.ts +129 -0
- package/app/components/05.forms/input-select/variants/InputSelectWithLabel.vue +1 -1
- package/app/components/05.forms/input-select/variants/tests/InputSelectWithLabel.spec.ts +87 -0
- package/app/components/05.forms/input-text/tests/InputTextAsNumberWithLabel.spec.ts +14 -0
- package/app/components/05.forms/input-text/variants/InputTextAsNumberWithLabel.vue +1 -1
- package/app/components/05.forms/input-textarea/CONSUMER-STYLING.md +55 -0
- package/app/components/05.forms/input-textarea/InputTextareaCore.vue +24 -16
- package/app/components/05.forms/input-textarea/stories/InputTextareaWithLabel.stories.ts +1 -1
- package/app/components/05.forms/input-textarea/tests/InputTextareaCore.spec.ts +119 -0
- package/app/components/05.forms/input-textarea/variants/InputTextareaWithLabel.vue +3 -13
- package/app/components/05.forms/input-textarea/variants/tests/InputTextareaWithLabel.spec.ts +93 -0
- package/app/components/05.forms/patterns/stories/MigratedFieldsForm.stories.ts +220 -0
- package/app/components/05.forms/toggle-switch/CONSUMER-STYLING.md +29 -0
- package/app/components/05.forms/toggle-switch/ToggleSwitchCore.vue +120 -119
- package/app/components/05.forms/toggle-switch/stories/ToggleSwitchCore.stories.ts +1 -1
- package/app/components/05.forms/toggle-switch/stories/ToggleSwitchWithLabel.stories.ts +1 -1
- package/app/components/05.forms/toggle-switch/stories/ToggleSwitchWithLabelInline.stories.ts +2 -2
- package/app/components/05.forms/toggle-switch/tests/ToggleSwitchCore.spec.ts +101 -0
- package/app/components/05.forms/toggle-switch/variants/ToggleSwitchWithLabel.vue +3 -5
- package/app/components/05.forms/toggle-switch/variants/ToggleSwitchWithLabelInline.vue +1 -2
- package/app/components/05.forms/toggle-switch/variants/tests/ToggleSwitchWithLabel.spec.ts +77 -0
- package/app/components/05.forms/toggle-switch/variants/tests/ToggleSwitchWithLabelInline.spec.ts +66 -0
- package/app/components/05.forms/triple-toggle-switch/CONSUMER-STYLING.md +20 -6
- package/app/components/05.forms/triple-toggle-switch/TripleToggleSwitchCore.vue +12 -10
- package/app/types/components/container-glow.d.ts +8 -0
- package/app/types/components/index.ts +2 -1
- package/app/types/components/select-menu.d.ts +5 -0
- package/package.json +3 -2
- package/.vscode/srcdev-component-toggle-switch-core.code-snippets +0 -13
- package/app/components/05.forms/input-range-fancy/InputRangeFancyCore.vue +0 -426
- package/app/components/05.forms/input-range-fancy/InputRangeFancyWithLabel.vue +0 -94
- package/app/components/carousels/CarouselBasic.vue +0 -317
- package/app/components/carousels/CarouselInfinite.vue +0 -358
- package/app/components/container-glow/ContainerGlowCore.vue +0 -296
- package/app/components/container-glow/stories/ContainerGlowCore.stories.ts +0 -337
- package/app/components/display-theme-switch/DisplayThemeSwitch.vue +0 -189
- package/app/components/display-theme-switch/stories/DisplayThemeSwitch.stories.ts +0 -154
- package/app/components/glowing-border/GlowingBorder.vue +0 -142
- package/app/components/glowing-border/stories/GlowingBorder.stories.ts +0 -141
- package/app/components/layout-grids/LayoutGridB.vue +0 -134
- package/app/components/test-storybook/TestStorybook.vue +0 -49
- package/app/components/test-storybook/stories/TestStorybook.stories.ts +0 -28
- package/app/components/ui-block-decorated/UiBlockDecorated.vue +0 -167
- package/app/components/view-timeline/WipeAwayVertical.vue +0 -180
- package/app/types/components/carousel-basic.d.ts +0 -19
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// https://nuxt.com/docs/getting-started/testing#unit-testing
|
|
2
|
+
import type { VueWrapper } from "@vue/test-utils";
|
|
3
|
+
import { mountSuspended } from "@nuxt/test-utils/runtime";
|
|
4
|
+
import ComponentUnderTest from "../InputSelectWithLabel.vue";
|
|
5
|
+
|
|
6
|
+
const initialFieldData = {
|
|
7
|
+
data: [
|
|
8
|
+
{ id: "1", name: "red", value: "red", label: "Red" },
|
|
9
|
+
{ id: "2", name: "blue", value: "blue", label: "Blue" },
|
|
10
|
+
],
|
|
11
|
+
total: 2,
|
|
12
|
+
skip: 0,
|
|
13
|
+
limit: 10,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const initialPropsData = {
|
|
17
|
+
name: "colour",
|
|
18
|
+
label: "Colour",
|
|
19
|
+
errorMessage: "",
|
|
20
|
+
modelValue: "red",
|
|
21
|
+
fieldData: initialFieldData,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
let wrapper: VueWrapper<InstanceType<typeof ComponentUnderTest>>;
|
|
25
|
+
const wrapperFactory = (propsData = {}, slots = {}) => {
|
|
26
|
+
const mockPropsData = { ...initialPropsData, ...propsData };
|
|
27
|
+
|
|
28
|
+
return mountSuspended(ComponentUnderTest, {
|
|
29
|
+
attachTo: document.body,
|
|
30
|
+
props: mockPropsData,
|
|
31
|
+
slots,
|
|
32
|
+
});
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
describe("InputSelectWithLabel", () => {
|
|
36
|
+
it("mounts without error", async () => {
|
|
37
|
+
wrapper = await wrapperFactory();
|
|
38
|
+
expect(wrapper.vm).toBeTruthy();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("renders the label text", async () => {
|
|
42
|
+
wrapper = await wrapperFactory({ label: "Favourite colour" });
|
|
43
|
+
expect(wrapper.text()).toContain("Favourite colour");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("renders a native select with an option per fieldData item", async () => {
|
|
47
|
+
wrapper = await wrapperFactory();
|
|
48
|
+
expect(wrapper.find("select").exists()).toBe(true);
|
|
49
|
+
expect(wrapper.findAll("option").map((o) => o.text())).toEqual(["Red", "Blue"]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("sets data-invalid and the error class when fieldHasError is true", async () => {
|
|
53
|
+
wrapper = await wrapperFactory({ fieldHasError: true });
|
|
54
|
+
expect(wrapper.find(".input-select-with-label").attributes("data-invalid")).toBe("");
|
|
55
|
+
expect(wrapper.find(".input-select-with-label").classes()).toContain("error");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("renders the error message when fieldHasError is true", async () => {
|
|
59
|
+
wrapper = await wrapperFactory({ fieldHasError: true, errorMessage: "Please choose a colour" });
|
|
60
|
+
expect(wrapper.text()).toContain("Please choose a colour");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("renders the descriptionText slot", async () => {
|
|
64
|
+
wrapper = await wrapperFactory({}, { descriptionText: () => "Pick your favourite" });
|
|
65
|
+
expect(wrapper.text()).toContain("Pick your favourite");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("applies inputVariant as a class on the wrapper", async () => {
|
|
69
|
+
wrapper = await wrapperFactory({ inputVariant: "underlined" });
|
|
70
|
+
expect(wrapper.find(".input-select-with-label").classes()).toContain("underlined");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("applies styleClassPassthrough", async () => {
|
|
74
|
+
wrapper = await wrapperFactory({ styleClassPassthrough: "custom-select" });
|
|
75
|
+
expect(wrapper.find("select").classes()).toContain("custom-select");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("forwards required to the native select", async () => {
|
|
79
|
+
wrapper = await wrapperFactory({ required: true });
|
|
80
|
+
expect(wrapper.find("select").attributes("required")).toBeDefined();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("applies the error theme to InputSelectCore when fieldHasError is true", async () => {
|
|
84
|
+
wrapper = await wrapperFactory({ fieldHasError: true, theme: "default" });
|
|
85
|
+
expect(wrapper.find(".input-select-wrapper").attributes("data-theme")).toBe("error");
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -58,4 +58,18 @@ describe("InputTextAsNumberWithLabel", () => {
|
|
|
58
58
|
|
|
59
59
|
expect(wrapper.emitted("update:modelValue")?.[0]).toEqual([6]);
|
|
60
60
|
});
|
|
61
|
+
|
|
62
|
+
it("forwards the input-text-as-number marker class down to InputTextCore's own input", async () => {
|
|
63
|
+
// Regression test: the marker classes toggled via updateElementClasses() must reach the
|
|
64
|
+
// inner <input> (not just the outer wrapper), or the scoped .input-text-core.input-text-as-number
|
|
65
|
+
// CSS (compact fit-content width, centered text) silently never matches.
|
|
66
|
+
wrapper = await wrapperFactory();
|
|
67
|
+
expect(wrapper.find("input").classes()).toContain("input-text-as-number");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("still forwards a consumer-supplied styleClassPassthrough down to the input", async () => {
|
|
71
|
+
wrapper = await wrapperFactory({ styleClassPassthrough: ["custom-number-class"] });
|
|
72
|
+
expect(wrapper.find("input").classes()).toContain("custom-number-class");
|
|
73
|
+
expect(wrapper.find("input").classes()).toContain("input-text-as-number");
|
|
74
|
+
});
|
|
61
75
|
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# InputTextareaCore — Consumer Styling Guide
|
|
2
|
+
|
|
3
|
+
## Public token API
|
|
4
|
+
|
|
5
|
+
Every colour token below is `var(--input-textarea-*, {default})` — set the `--input-textarea-*`
|
|
6
|
+
name to override just this component; leave it unset and it inherits the shared `--theme-input-*`/
|
|
7
|
+
`--theme-*` token every other themed component also falls back to. See
|
|
8
|
+
`theming-component-token-pattern.md` for why this two-tier shape exists.
|
|
9
|
+
|
|
10
|
+
This shape mirrors `InputTextCore`'s token set (see its own CONSUMER-STYLING.md).
|
|
11
|
+
|
|
12
|
+
| Token | Falls back to | Controls |
|
|
13
|
+
|---|---|---|
|
|
14
|
+
| `--input-textarea-surface` | `var(--theme-input-surface)` | Wrapper background |
|
|
15
|
+
| `--input-textarea-border` | `var(--theme-border)` | Border colour (`normal`/`underlined` variant border) |
|
|
16
|
+
| `--input-textarea-border-hover` | `var(--theme-border-focus)` | Wrapper outline colour on mouse hover (`normal` variant only) |
|
|
17
|
+
| `--input-textarea-border-focus` | `var(--theme-border-focus)` | Wrapper outline colour on `:focus-visible` (`normal` variant only) |
|
|
18
|
+
| `--input-textarea-text-color` | `var(--theme-input-text-color-normal)` | Textarea text colour |
|
|
19
|
+
| `--input-textarea-placeholder-color` | `var(--theme-input-placeholder)` | Placeholder text colour |
|
|
20
|
+
|
|
21
|
+
Geometry (`--form-textarea-padding-block`, `--form-textarea-padding-inline`, `--input-font-size`,
|
|
22
|
+
`--input-textarea-line-height`, `--input-padding-inline`, etc.) is shared across every `05.forms`
|
|
23
|
+
component and untouched by this — see `theming-form-geometry-tokens.md`.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Input variant
|
|
28
|
+
|
|
29
|
+
`:input-variant="'normal' | 'outlined' | 'underlined'"` (default `"normal"`) — same prop as
|
|
30
|
+
`InputTextCore`. Only `normal` (bordered box, with hover/focus outline) and `underlined`
|
|
31
|
+
(bottom-border only) have CSS today; `outlined` has no styling in `InputTextCore` either (no
|
|
32
|
+
`&.outlined` rule exists there), so passing it renders with no border/outline at all — a
|
|
33
|
+
pre-existing gap in the type, not specific to `InputTextareaCore`.
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## Global theming — app-level CSS file
|
|
38
|
+
|
|
39
|
+
```css
|
|
40
|
+
:where(html) {
|
|
41
|
+
--input-textarea-surface: var(--rose-09);
|
|
42
|
+
--input-textarea-border: var(--rose-05);
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Only declare the tokens you want to change — everything else keeps inheriting the shared
|
|
47
|
+
`--theme-input-*`/`--theme-*` tokens.
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## Per-instance overrides
|
|
52
|
+
|
|
53
|
+
```vue
|
|
54
|
+
<InputTextareaCore id="message" name="message" style="--input-textarea-border: var(--gold-04);" />
|
|
55
|
+
```
|
|
@@ -66,11 +66,9 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
66
66
|
|
|
67
67
|
const slots = useSlots();
|
|
68
68
|
|
|
69
|
-
const
|
|
70
|
-
|
|
69
|
+
const modelValue = defineModel<string | number | readonly string[] | null | undefined>({
|
|
70
|
+
required: true,
|
|
71
71
|
});
|
|
72
|
-
|
|
73
|
-
const modelValue = defineModel<string | number | readonly string[] | null | undefined>();
|
|
74
72
|
const isDirty = defineModel<boolean>("isDirty");
|
|
75
73
|
const isActive = defineModel<boolean>("isActive");
|
|
76
74
|
|
|
@@ -86,31 +84,41 @@ const { elementClasses } = useStyleClassPassthrough(props.styleClassPassthrough)
|
|
|
86
84
|
<style lang="css">
|
|
87
85
|
@layer components {
|
|
88
86
|
.input-textarea-wrapper {
|
|
89
|
-
|
|
90
|
-
|
|
87
|
+
/* Public --input-textarea-* tokens, inline-fallback to the shared --theme-* tokens (see
|
|
88
|
+
theming-component-token-pattern.md) — mirrors InputTextCore's token shape. --_border is
|
|
89
|
+
genuinely reused (both the .normal border and the .underlined border-bottom read it); the
|
|
90
|
+
hover/focus border colours are each used only once, so they're inlined directly rather than
|
|
91
|
+
wrapped in a private var (see the Public token pattern rule in CLAUDE.md's Styling
|
|
92
|
+
Methodology). */
|
|
93
|
+
--_border: var(--input-textarea-border, var(--theme-border));
|
|
94
|
+
|
|
95
|
+
background-color: var(--input-textarea-surface, var(--theme-input-surface));
|
|
96
|
+
padding-inline: var(--input-padding-inline);
|
|
91
97
|
transition: all var(--theme-form-transition-duration) ease-in-out;
|
|
92
98
|
|
|
93
99
|
z-index: 2;
|
|
94
100
|
position: relative;
|
|
95
101
|
|
|
96
102
|
&.normal {
|
|
97
|
-
border: var(--form-element-border-width) solid var(--
|
|
103
|
+
border: var(--form-element-border-width) solid var(--_border);
|
|
98
104
|
border-radius: var(--form-input-border-radius);
|
|
99
105
|
outline: var(--form-element-outline-width) solid transparent;
|
|
100
106
|
|
|
101
107
|
&:has(textarea:focus-visible) {
|
|
102
|
-
outline: var(--form-element-outline-width-focus) solid
|
|
108
|
+
outline: var(--form-element-outline-width-focus) solid
|
|
109
|
+
var(--input-textarea-border-focus, var(--theme-border-focus));
|
|
103
110
|
outline-offset: var(--form-element-outline-offset-focus);
|
|
104
111
|
}
|
|
105
112
|
|
|
106
113
|
&:has(textarea:is(:hover)) {
|
|
107
|
-
outline: var(--form-element-outline-width-focus) solid
|
|
114
|
+
outline: var(--form-element-outline-width-focus) solid
|
|
115
|
+
var(--input-textarea-border-hover, var(--theme-border-focus));
|
|
108
116
|
outline-offset: var(--form-element-outline-offset-focus);
|
|
109
117
|
}
|
|
110
118
|
}
|
|
111
119
|
|
|
112
120
|
&.underlined {
|
|
113
|
-
border-bottom: var(--form-element-border-bottom-width-underlined) solid var(--
|
|
121
|
+
border-bottom: var(--form-element-border-bottom-width-underlined) solid var(--_border);
|
|
114
122
|
}
|
|
115
123
|
|
|
116
124
|
display: flex;
|
|
@@ -129,7 +137,7 @@ const { elementClasses } = useStyleClassPassthrough(props.styleClassPassthrough)
|
|
|
129
137
|
flex-grow: 1;
|
|
130
138
|
field-sizing: content;
|
|
131
139
|
|
|
132
|
-
color: var(--theme-input-text-color-normal);
|
|
140
|
+
color: var(--input-textarea-text-color, var(--theme-input-text-color-normal));
|
|
133
141
|
font-family: var(--font-family);
|
|
134
142
|
font-size: var(--input-font-size);
|
|
135
143
|
line-height: var(--input-textarea-line-height);
|
|
@@ -139,11 +147,11 @@ const { elementClasses } = useStyleClassPassthrough(props.styleClassPassthrough)
|
|
|
139
147
|
min-height: 6lh;
|
|
140
148
|
|
|
141
149
|
&::placeholder {
|
|
142
|
-
color: var(--theme-input-placeholder);
|
|
143
|
-
font-size: var(--input-placeholder-font-size);
|
|
144
|
-
font-style:
|
|
145
|
-
line-height:
|
|
146
|
-
font-weight:
|
|
150
|
+
color: var(--input-textarea-placeholder-color, var(--theme-input-placeholder));
|
|
151
|
+
font-size: var(--theme-input-placeholder-font-size);
|
|
152
|
+
font-style: italic;
|
|
153
|
+
line-height: 1;
|
|
154
|
+
font-weight: normal;
|
|
147
155
|
}
|
|
148
156
|
}
|
|
149
157
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Meta, StoryFn } from "@nuxtjs/storybook";
|
|
2
|
-
import { computed } from "vue";
|
|
2
|
+
import { computed, reactive } from "vue";
|
|
3
3
|
import StorybookComponent from "../variants/InputTextareaWithLabel.vue";
|
|
4
4
|
import type { FormUiTheme, InputUiVariant } from "~/types/forms/types.forms.d";
|
|
5
5
|
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// https://nuxt.com/docs/getting-started/testing#unit-testing
|
|
2
|
+
import type { VueWrapper } from "@vue/test-utils";
|
|
3
|
+
import { mountSuspended } from "@nuxt/test-utils/runtime";
|
|
4
|
+
import ComponentUnderTest from "../InputTextareaCore.vue";
|
|
5
|
+
|
|
6
|
+
const initialPropsData = {
|
|
7
|
+
id: "test-textarea",
|
|
8
|
+
name: "testName",
|
|
9
|
+
modelValue: "",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
let wrapper: VueWrapper<InstanceType<typeof ComponentUnderTest>>;
|
|
13
|
+
const wrapperFactory = (propsData = {}, slots = {}) => {
|
|
14
|
+
const mockPropsData = { ...initialPropsData, ...propsData };
|
|
15
|
+
|
|
16
|
+
return mountSuspended(ComponentUnderTest, {
|
|
17
|
+
attachTo: document.body,
|
|
18
|
+
props: mockPropsData,
|
|
19
|
+
slots,
|
|
20
|
+
});
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
describe("InputTextareaCore", () => {
|
|
24
|
+
it("mounts without error", async () => {
|
|
25
|
+
wrapper = await wrapperFactory();
|
|
26
|
+
expect(wrapper.vm).toBeTruthy();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("renders a native textarea", async () => {
|
|
30
|
+
wrapper = await wrapperFactory();
|
|
31
|
+
expect(wrapper.find("textarea").exists()).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("binds v-model to the textarea value", async () => {
|
|
35
|
+
wrapper = await wrapperFactory({ modelValue: "hello world" });
|
|
36
|
+
expect(wrapper.find("textarea").element.value).toBe("hello world");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("forwards placeholder", async () => {
|
|
40
|
+
wrapper = await wrapperFactory({ placeholder: "Enter your message" });
|
|
41
|
+
expect(wrapper.find("textarea").attributes("placeholder")).toBe("Enter your message");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("forwards maxlength, defaulting to 255", async () => {
|
|
45
|
+
wrapper = await wrapperFactory();
|
|
46
|
+
expect(wrapper.find("textarea").attributes("maxlength")).toBe("255");
|
|
47
|
+
|
|
48
|
+
wrapper = await wrapperFactory({ maxlength: 100 });
|
|
49
|
+
expect(wrapper.find("textarea").attributes("maxlength")).toBe("100");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("forwards aria-describedby", async () => {
|
|
53
|
+
wrapper = await wrapperFactory({ ariaDescribedby: "some-description-id" });
|
|
54
|
+
expect(wrapper.find("textarea").attributes("aria-describedby")).toBe("some-description-id");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("sets data-theme from the theme prop", async () => {
|
|
58
|
+
wrapper = await wrapperFactory({ theme: "error" });
|
|
59
|
+
expect(wrapper.find(".input-textarea-wrapper").attributes("data-theme")).toBe("error");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("sets data-invalid and aria-invalid when fieldHasError is true", async () => {
|
|
63
|
+
wrapper = await wrapperFactory({ fieldHasError: true });
|
|
64
|
+
expect(wrapper.find(".input-textarea-wrapper").attributes("data-invalid")).toBe("");
|
|
65
|
+
expect(wrapper.find("textarea").attributes("aria-invalid")).toBe("true");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("defaults inputVariant to normal", async () => {
|
|
69
|
+
wrapper = await wrapperFactory();
|
|
70
|
+
expect(wrapper.find(".input-textarea-wrapper").classes()).toContain("normal");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("applies inputVariant as a class on the wrapper", async () => {
|
|
74
|
+
wrapper = await wrapperFactory({ inputVariant: "underlined" });
|
|
75
|
+
expect(wrapper.find(".input-textarea-wrapper").classes()).toContain("underlined");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("does not render the left/right slot wrappers when the slots are not used", async () => {
|
|
79
|
+
wrapper = await wrapperFactory();
|
|
80
|
+
expect(wrapper.find(".slot.left-slot").exists()).toBe(false);
|
|
81
|
+
expect(wrapper.find(".slot.right-slot").exists()).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("renders the left/right slots when provided", async () => {
|
|
85
|
+
wrapper = await wrapperFactory(
|
|
86
|
+
{},
|
|
87
|
+
{
|
|
88
|
+
left: '<span data-testid="left-slot">📝</span>',
|
|
89
|
+
right: '<span data-testid="right-slot">✨</span>',
|
|
90
|
+
}
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
expect(wrapper.find(".slot.left-slot [data-testid='left-slot']").exists()).toBe(true);
|
|
94
|
+
expect(wrapper.find(".slot.right-slot [data-testid='right-slot']").exists()).toBe(true);
|
|
95
|
+
expect(wrapper.find(".input-textarea-wrapper").classes()).toContain("has-left-slot");
|
|
96
|
+
expect(wrapper.find(".input-textarea-wrapper").classes()).toContain("has-right-slot");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("applies styleClassPassthrough to the textarea", async () => {
|
|
100
|
+
wrapper = await wrapperFactory({ styleClassPassthrough: ["custom-textarea-class"] });
|
|
101
|
+
expect(wrapper.find("textarea").classes()).toContain("custom-textarea-class");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("sets required attribute when required is true", async () => {
|
|
105
|
+
wrapper = await wrapperFactory({ required: true });
|
|
106
|
+
expect(wrapper.find("textarea").attributes("required")).toBeDefined();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("sets isActive true on focus and false on blur", async () => {
|
|
110
|
+
wrapper = await wrapperFactory();
|
|
111
|
+
const textarea = wrapper.find("textarea");
|
|
112
|
+
|
|
113
|
+
await textarea.trigger("focusin");
|
|
114
|
+
expect(wrapper.emitted("update:isActive")?.at(-1)).toEqual([true]);
|
|
115
|
+
|
|
116
|
+
await textarea.trigger("focusout");
|
|
117
|
+
expect(wrapper.emitted("update:isActive")?.at(-1)).toEqual([false]);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -42,7 +42,6 @@
|
|
|
42
42
|
:maxlength
|
|
43
43
|
:name
|
|
44
44
|
:placeholder
|
|
45
|
-
:label
|
|
46
45
|
:field-has-error
|
|
47
46
|
:required
|
|
48
47
|
:style-class-passthrough
|
|
@@ -106,24 +105,15 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
106
105
|
|
|
107
106
|
const slots = useSlots();
|
|
108
107
|
|
|
109
|
-
const FormUiTheme = computed(() => {
|
|
110
|
-
return props.fieldHasError ? "error" : props.theme;
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
// const id = `${props.name}-${useId()}`;
|
|
114
|
-
// const errorId = `${id}-error-message`;
|
|
115
|
-
// const ariaDescribedby = computed(() => {
|
|
116
|
-
// const ariaDescribedbyId = slots.descriptionText || slots.descriptionHtml ? `${id}-description` : undefined;
|
|
117
|
-
// return props.fieldHasError ? errorId : ariaDescribedbyId;
|
|
118
|
-
// });
|
|
119
|
-
|
|
120
108
|
const { id, errorId, descriptionId, ariaDescribedby } = useAriaDescribedById(
|
|
121
109
|
props.name,
|
|
122
110
|
toRef(props, "fieldHasError"),
|
|
123
111
|
slots
|
|
124
112
|
);
|
|
125
113
|
|
|
126
|
-
const modelValue = defineModel<string | number | readonly string[] | null | undefined>(
|
|
114
|
+
const modelValue = defineModel<string | number | readonly string[] | null | undefined>({
|
|
115
|
+
required: true,
|
|
116
|
+
});
|
|
127
117
|
const isActive = ref<boolean>(false);
|
|
128
118
|
const isDirty = ref<boolean>(false);
|
|
129
119
|
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// https://nuxt.com/docs/getting-started/testing#unit-testing
|
|
2
|
+
import type { VueWrapper } from "@vue/test-utils";
|
|
3
|
+
import { mountSuspended } from "@nuxt/test-utils/runtime";
|
|
4
|
+
import ComponentUnderTest from "../InputTextareaWithLabel.vue";
|
|
5
|
+
|
|
6
|
+
const initialPropsData = {
|
|
7
|
+
name: "message",
|
|
8
|
+
label: "Message",
|
|
9
|
+
errorMessage: "",
|
|
10
|
+
modelValue: "",
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
let wrapper: VueWrapper<InstanceType<typeof ComponentUnderTest>>;
|
|
14
|
+
const wrapperFactory = (propsData = {}, slots = {}) => {
|
|
15
|
+
const mockPropsData = { ...initialPropsData, ...propsData };
|
|
16
|
+
|
|
17
|
+
return mountSuspended(ComponentUnderTest, {
|
|
18
|
+
attachTo: document.body,
|
|
19
|
+
props: mockPropsData,
|
|
20
|
+
slots,
|
|
21
|
+
});
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
describe("InputTextareaWithLabel", () => {
|
|
25
|
+
it("mounts without error", async () => {
|
|
26
|
+
wrapper = await wrapperFactory();
|
|
27
|
+
expect(wrapper.vm).toBeTruthy();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("renders the label text", async () => {
|
|
31
|
+
wrapper = await wrapperFactory({ label: "Feedback" });
|
|
32
|
+
expect(wrapper.text()).toContain("Feedback");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("renders a native textarea", async () => {
|
|
36
|
+
wrapper = await wrapperFactory();
|
|
37
|
+
expect(wrapper.find("textarea").exists()).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("does not leak a label attribute onto InputTextareaCore's wrapper", async () => {
|
|
41
|
+
// Regression test: InputTextareaCore has no `label` prop (matches InputTextCore), so passing
|
|
42
|
+
// it down would fall through as a meaningless raw `label` HTML attribute on the wrapper div.
|
|
43
|
+
wrapper = await wrapperFactory({ label: "Feedback" });
|
|
44
|
+
expect(wrapper.find(".input-textarea-wrapper").attributes("label")).toBeUndefined();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("sets data-invalid and the error class when fieldHasError is true", async () => {
|
|
48
|
+
wrapper = await wrapperFactory({ fieldHasError: true });
|
|
49
|
+
expect(wrapper.find(".input-textarea-with-label").attributes("data-invalid")).toBe("");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("renders the error message when fieldHasError is true", async () => {
|
|
53
|
+
wrapper = await wrapperFactory({ fieldHasError: true, errorMessage: "This field is required" });
|
|
54
|
+
expect(wrapper.text()).toContain("This field is required");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("renders the descriptionText slot", async () => {
|
|
58
|
+
wrapper = await wrapperFactory(
|
|
59
|
+
{},
|
|
60
|
+
{ descriptionText: () => "Please provide more detail" }
|
|
61
|
+
);
|
|
62
|
+
expect(wrapper.text()).toContain("Please provide more detail");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("renders the left/right slots when provided", async () => {
|
|
66
|
+
wrapper = await wrapperFactory(
|
|
67
|
+
{},
|
|
68
|
+
{
|
|
69
|
+
left: '<span data-testid="left-slot">📝</span>',
|
|
70
|
+
right: '<span data-testid="right-slot">✨</span>',
|
|
71
|
+
}
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
expect(wrapper.find('[data-testid="left-slot"]').exists()).toBe(true);
|
|
75
|
+
expect(wrapper.find('[data-testid="right-slot"]').exists()).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("sets isDirty true once the value becomes non-empty", async () => {
|
|
79
|
+
wrapper = await wrapperFactory({ modelValue: "hello" });
|
|
80
|
+
const vm = wrapper.vm as unknown as { isDirty: boolean };
|
|
81
|
+
expect(vm.isDirty).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("applies inputVariant as a class on the wrapper", async () => {
|
|
85
|
+
wrapper = await wrapperFactory({ inputVariant: "underlined" });
|
|
86
|
+
expect(wrapper.find(".input-textarea-with-label").classes()).toContain("underlined");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("applies styleClassPassthrough", async () => {
|
|
90
|
+
wrapper = await wrapperFactory({ styleClassPassthrough: "custom-textarea" });
|
|
91
|
+
expect(wrapper.find(".input-textarea-with-label").classes()).toContain("custom-textarea");
|
|
92
|
+
});
|
|
93
|
+
});
|