srcdev-nuxt-components 9.4.6 → 9.4.8
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 +34 -3
- package/.claude/component-ledger/audit.json +1 -1
- package/.claude/component-ledger/build.mjs +27 -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/column-flow-grid.md +93 -0
- package/.claude/skills/components/entry-animation.md +88 -0
- package/.claude/skills/components/header-block.md +92 -0
- package/.claude/skills/components/input-copy.md +2 -0
- package/.claude/skills/components/input-text-core.md +186 -0
- package/.claude/skills/components/masonry-grid.md +153 -0
- package/.claude/skills/components/pop-over.md +100 -0
- package/.claude/skills/components/rotating-carousel-image.md +101 -0
- package/.claude/skills/components/skip-links.md +82 -0
- package/.claude/skills/components/tabs-core.md +187 -0
- package/.claude/skills/index.md +10 -1
- package/.vscode/srcdev-component-column-flow-grid.code-snippets +39 -0
- package/.vscode/srcdev-component-entry-animation.code-snippets +29 -0
- package/.vscode/srcdev-component-header-block.code-snippets +41 -0
- package/.vscode/srcdev-component-input-text.code-snippets +107 -0
- package/.vscode/srcdev-component-masonry-grid.code-snippets +51 -0
- package/.vscode/srcdev-component-pop-over.code-snippets +44 -0
- package/.vscode/srcdev-component-rotating-carousel-image.code-snippets +74 -0
- package/.vscode/srcdev-component-skip-links.code-snippets +55 -0
- package/.vscode/srcdev-component-tabs-core.code-snippets +78 -0
- package/app/assets/styles/setup/05.typography/02.utility-classes/_font-classes-page-heading.css +2 -1
- package/app/components/01.atoms/animations/entry/EntryAnimation.vue +7 -1
- package/app/components/01.atoms/animations/entry/stories/EntryAnimation.stories.ts +47 -0
- package/app/components/01.atoms/animations/entry/tests/EntryAnimation.spec.ts +57 -0
- package/app/components/01.atoms/animations/rotating-carousel-image/CONSUMER-STYLING.md +54 -0
- package/app/components/01.atoms/animations/rotating-carousel-image/RotatingCarouselImage.vue +315 -0
- package/app/components/01.atoms/animations/rotating-carousel-image/stories/RotatingCarouselImage.stories.ts +156 -0
- package/app/components/01.atoms/animations/rotating-carousel-image/tests/RotatingCarouselImage.spec.ts +230 -0
- package/app/components/01.atoms/animations/rotating-carousel-image/tests/__snapshots__/RotatingCarouselImage.spec.ts.snap +19 -0
- package/app/components/01.atoms/canvas-switcher/stories/CanvasSwitcher.stories.ts +9 -10
- package/app/components/01.atoms/content-wrappers/docs-pages/stories/ContentDocs.stories.ts +12 -13
- package/app/components/01.atoms/grids/column-flow-grid/CONSUMER-STYLING.md +33 -0
- package/app/components/01.atoms/grids/column-flow-grid/ColumnFlowGrid.vue +55 -0
- package/app/components/01.atoms/grids/column-flow-grid/stories/ColumnFlowGrid.stories.ts +178 -0
- package/app/components/01.atoms/grids/column-flow-grid/tests/ColumnFlowGrid.spec.ts +75 -0
- package/app/components/01.atoms/grids/masonry-grid/CONSUMER-STYLING.md +35 -0
- package/app/components/01.atoms/grids/masonry-grid/MasonryGrid.vue +178 -0
- package/app/components/01.atoms/grids/masonry-grid/stories/MasonryGrid.stories.ts +199 -0
- package/app/components/01.atoms/grids/masonry-grid/tests/MasonryGrid.spec.ts +158 -0
- package/app/components/01.atoms/navigation/skip-links/CONSUMER-STYLING.md +31 -0
- package/app/components/01.atoms/navigation/skip-links/SkipLinks.vue +92 -0
- package/app/components/01.atoms/navigation/skip-links/stories/SkipLinks.stories.ts +113 -0
- package/app/components/01.atoms/navigation/skip-links/tests/SkipLinks.spec.ts +71 -0
- package/app/components/01.atoms/navigation/skip-links/tests/__snapshots__/SkipLinks.spec.ts.snap +15 -0
- package/app/components/01.atoms/navigation/tabs/CONSUMER-STYLING.md +99 -0
- package/app/components/01.atoms/navigation/tabs/TabsCore.vue +272 -0
- package/app/components/01.atoms/navigation/tabs/stories/TabsCore.stories.ts +199 -0
- package/app/components/01.atoms/navigation/tabs/tests/TabsCore.spec.ts +274 -0
- package/app/components/01.atoms/pop-over/CONSUMER-STYLING.md +54 -0
- package/app/components/01.atoms/pop-over/PopOver.vue +201 -0
- package/app/components/01.atoms/pop-over/stories/PopOver.stories.ts +141 -0
- package/app/components/01.atoms/pop-over/tests/PopOver.spec.ts +195 -0
- package/app/components/01.atoms/pop-over/tests/__snapshots__/PopOver.spec.ts.snap +11 -0
- package/app/components/01.atoms/text-blocks/header-block/CONSUMER-STYLING.md +43 -0
- package/app/components/01.atoms/text-blocks/header-block/HeaderBlock.vue +50 -0
- package/app/components/01.atoms/text-blocks/header-block/stories/HeaderBlock.stories.ts +111 -0
- package/app/components/01.atoms/text-blocks/header-block/tests/HeaderBlock.spec.ts +119 -0
- package/app/components/01.atoms/text-blocks/header-block/tests/__snapshots__/HeaderBlock.spec.ts.snap +5 -0
- package/app/components/02.molecules/action-menu/stories/ActionMenu.stories.ts +8 -2
- package/app/components/02.molecules/input-copy/InputCopy.vue +14 -0
- package/app/components/02.molecules/input-copy/stories/InputCopy.stories.ts +15 -0
- package/app/components/02.molecules/input-copy/tests/InputCopy.spec.ts +41 -0
- package/app/components/03.organisms/site-header/SiteHeader.vue +1 -1
- package/app/components/03.organisms/site-header/tests/SiteHeader.spec.ts +1 -1
- package/app/components/03.organisms/site-header/tests/__snapshots__/SiteHeader.spec.ts.snap +3 -3
- package/app/components/05.forms/input-select/InputSelectCore.vue +1 -1
- package/app/components/05.forms/input-text/InputTextCore.vue +6 -0
- package/app/components/05.forms/input-text/stories/InputPasswordWithLabel.stories.ts +49 -20
- package/app/components/05.forms/input-text/stories/InputTextAsNumberWithLabel.stories.ts +41 -22
- package/app/components/05.forms/input-text/stories/InputTextCore.stories.ts +48 -22
- package/app/components/05.forms/input-text/stories/InputTextWithLabel.stories.ts +43 -17
- package/app/components/05.forms/input-text/tests/InputPasswordWithLabel.spec.ts +56 -0
- package/app/components/05.forms/input-text/tests/InputTextAsNumberWithLabel.spec.ts +61 -0
- package/app/components/05.forms/input-text/tests/InputTextCore.spec.ts +60 -0
- package/app/components/05.forms/input-text/tests/InputTextWithLabel.spec.ts +46 -0
- package/app/components/05.forms/input-text/variants/InputPasswordWithLabel.vue +7 -1
- package/app/components/05.forms/input-text/variants/InputTextAsNumberWithLabel.vue +8 -2
- package/app/components/05.forms/input-text/variants/InputTextWithLabel.vue +6 -0
- package/app/components/05.forms/input-textarea/stories/InputTextareaCore.stories.ts +20 -20
- package/app/components/05.forms/input-textarea/stories/InputTextareaWithLabel.stories.ts +32 -26
- package/app/composables/useTabs.ts +225 -207
- package/app/types/components/index.ts +2 -0
- package/app/types/components/rotating-carousel-image.d.ts +4 -0
- package/app/types/components/skip-links.d.ts +4 -0
- package/app/types/forms/types.forms.d.ts +1 -1
- package/package.json +1 -1
- package/app/components/01.atoms/grids/data-grid/tests/__snapshots__/DataGrid.spec.ts.snap +0 -11
- package/app/components/masonry-grid/MasonryGrid.vue +0 -68
- package/app/components/masonry-grid-ordered/MasonryGridOrdered.vue +0 -163
- package/app/components/masonry-grid-ordered/MasonryGridOrderedGridExperiment.vue +0 -259
- package/app/components/masonry-grid-ordered/stories/MasonryGridOrdered.stories.ts +0 -354
- package/app/components/masonry-grid-sorted/MasonryGridSorted.vue +0 -120
- package/app/components/pop-over/PopOver.vue +0 -90
- package/app/components/rotating-carousel/RotatingCarouselImage.vue +0 -216
- package/app/components/skip-links/SkipLinks.vue +0 -60
- package/app/components/tabs/TabsCore.vue +0 -306
- package/app/components/typography/HeaderBlock.vue +0 -35
- package/app/layouts/default.vue +0 -308
- package/app/layouts/site-navigation-demo.vue +0 -188
- /package/app/components/01.atoms/grids/{data-grid → auto-grid}/AutoGrid.vue +0 -0
- /package/app/components/01.atoms/grids/{data-grid → auto-grid}/stories/AutoGrid.stories.ts +0 -0
- /package/app/components/01.atoms/grids/{data-grid → auto-grid}/tests/AutoGrid.spec.ts +0 -0
- /package/app/components/01.atoms/grids/{data-grid → auto-grid}/tests/__snapshots__/AutoGrid.spec.ts.snap +0 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
+
import { mountSuspended } from "@nuxt/test-utils/runtime";
|
|
3
|
+
import PopOver from "../PopOver.vue";
|
|
4
|
+
|
|
5
|
+
interface PopOverInstance {
|
|
6
|
+
popoverId: string;
|
|
7
|
+
anchorName: string;
|
|
8
|
+
handleToggle: (event: Event) => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const createWrapper = async (props: Record<string, unknown> = {}, slots: Record<string, string> = {}) => {
|
|
12
|
+
return mountSuspended(PopOver, { props, slots });
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
describe("PopOver", () => {
|
|
16
|
+
let wrapper: Awaited<ReturnType<typeof createWrapper>>;
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
// Popover API is not implemented in jsdom — define stubs so we can test
|
|
20
|
+
Object.defineProperty(HTMLElement.prototype, "hidePopover", {
|
|
21
|
+
value: vi.fn(),
|
|
22
|
+
writable: true,
|
|
23
|
+
configurable: true,
|
|
24
|
+
});
|
|
25
|
+
Object.defineProperty(HTMLElement.prototype, "showPopover", {
|
|
26
|
+
value: vi.fn(),
|
|
27
|
+
writable: true,
|
|
28
|
+
configurable: true,
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
wrapper?.unmount();
|
|
34
|
+
vi.restoreAllMocks();
|
|
35
|
+
delete (HTMLElement.prototype as unknown as Record<string, unknown>)["hidePopover"];
|
|
36
|
+
delete (HTMLElement.prototype as unknown as Record<string, unknown>)["showPopover"];
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("mounts without error", async () => {
|
|
40
|
+
wrapper = await createWrapper();
|
|
41
|
+
expect(wrapper.vm).toBeTruthy();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("renders correct HTML structure", async () => {
|
|
45
|
+
wrapper = await createWrapper(
|
|
46
|
+
{},
|
|
47
|
+
{ trigger: "<span>Open</span>", content: "<p>Details</p>" }
|
|
48
|
+
);
|
|
49
|
+
expect(wrapper.html()).toMatchSnapshot();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("Trigger", () => {
|
|
53
|
+
it("renders the trigger slot", async () => {
|
|
54
|
+
wrapper = await createWrapper({}, { trigger: '<span class="slot-trigger">Open</span>' });
|
|
55
|
+
expect(wrapper.find(".slot-trigger").text()).toBe("Open");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("trigger button has type='button'", async () => {
|
|
59
|
+
wrapper = await createWrapper();
|
|
60
|
+
expect(wrapper.find(".pop-over-trigger").attributes("type")).toBe("button");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("does not set aria-label on the trigger by default", async () => {
|
|
64
|
+
wrapper = await createWrapper();
|
|
65
|
+
expect(wrapper.find(".pop-over-trigger").attributes("aria-label")).toBeUndefined();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("sets aria-label on the trigger when triggerAriaLabel is given", async () => {
|
|
69
|
+
wrapper = await createWrapper({ triggerAriaLabel: "Show filters" });
|
|
70
|
+
expect(wrapper.find(".pop-over-trigger").attributes("aria-label")).toBe("Show filters");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("trigger button popovertarget matches the popover id", async () => {
|
|
74
|
+
wrapper = await createWrapper();
|
|
75
|
+
const vm = wrapper.vm as unknown as PopOverInstance;
|
|
76
|
+
expect(wrapper.find(".pop-over-trigger").attributes("popovertarget")).toBe(vm.popoverId);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("trigger button has popovertargetaction='toggle'", async () => {
|
|
80
|
+
wrapper = await createWrapper();
|
|
81
|
+
expect(wrapper.find(".pop-over-trigger").attributes("popovertargetaction")).toBe("toggle");
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("Popover panel", () => {
|
|
86
|
+
it("renders the content slot", async () => {
|
|
87
|
+
wrapper = await createWrapper({}, { content: '<p class="slot-content">Details</p>' });
|
|
88
|
+
expect(wrapper.find(".pop-over-popover .slot-content").text()).toBe("Details");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("has the popover attribute", async () => {
|
|
92
|
+
wrapper = await createWrapper();
|
|
93
|
+
expect(wrapper.find(".pop-over-popover").attributes("popover")).toBeDefined();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("does not set aria-label by default", async () => {
|
|
97
|
+
wrapper = await createWrapper();
|
|
98
|
+
expect(wrapper.find(".pop-over-popover").attributes("aria-label")).toBeUndefined();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("sets aria-label when popoverAriaLabel is given", async () => {
|
|
102
|
+
wrapper = await createWrapper({ popoverAriaLabel: "Filter options" });
|
|
103
|
+
expect(wrapper.find(".pop-over-popover").attributes("aria-label")).toBe("Filter options");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("sets data-placement on the root element", async () => {
|
|
107
|
+
wrapper = await createWrapper({ placement: "top" });
|
|
108
|
+
expect(wrapper.find(".pop-over").attributes("data-placement")).toBe("top");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("defaults placement to right", async () => {
|
|
112
|
+
wrapper = await createWrapper();
|
|
113
|
+
expect(wrapper.find(".pop-over").attributes("data-placement")).toBe("right");
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("Close button", () => {
|
|
118
|
+
it("has a default aria-label", async () => {
|
|
119
|
+
wrapper = await createWrapper();
|
|
120
|
+
expect(wrapper.find(".pop-over-close-button").attributes("aria-label")).toBe("Close");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("uses a custom closeButtonAriaLabel", async () => {
|
|
124
|
+
wrapper = await createWrapper({ closeButtonAriaLabel: "Dismiss" });
|
|
125
|
+
expect(wrapper.find(".pop-over-close-button").attributes("aria-label")).toBe("Dismiss");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("has popovertargetaction='hide' pointing at the popover", async () => {
|
|
129
|
+
wrapper = await createWrapper();
|
|
130
|
+
const vm = wrapper.vm as unknown as PopOverInstance;
|
|
131
|
+
const closeButton = wrapper.find(".pop-over-close-button");
|
|
132
|
+
expect(closeButton.attributes("popovertargetaction")).toBe("hide");
|
|
133
|
+
expect(closeButton.attributes("popovertarget")).toBe(vm.popoverId);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("handleToggle focus management", () => {
|
|
138
|
+
it("focuses the close button when the popover opens", async () => {
|
|
139
|
+
wrapper = await createWrapper();
|
|
140
|
+
const closeButtonEl = wrapper.find(".pop-over-close-button").element as HTMLElement;
|
|
141
|
+
const focusSpy = vi.spyOn(closeButtonEl, "focus");
|
|
142
|
+
|
|
143
|
+
const vm = wrapper.vm as unknown as PopOverInstance;
|
|
144
|
+
vm.handleToggle(Object.assign(new Event("toggle"), { newState: "open" }));
|
|
145
|
+
|
|
146
|
+
expect(focusSpy).toHaveBeenCalledOnce();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("does not move focus when the popover closes", async () => {
|
|
150
|
+
wrapper = await createWrapper();
|
|
151
|
+
const closeButtonEl = wrapper.find(".pop-over-close-button").element as HTMLElement;
|
|
152
|
+
const focusSpy = vi.spyOn(closeButtonEl, "focus");
|
|
153
|
+
|
|
154
|
+
const vm = wrapper.vm as unknown as PopOverInstance;
|
|
155
|
+
vm.handleToggle(Object.assign(new Event("toggle"), { newState: "closed" }));
|
|
156
|
+
|
|
157
|
+
expect(focusSpy).not.toHaveBeenCalled();
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe("Anchor name", () => {
|
|
162
|
+
it("sets a unique --_anchor-name on the root element style", async () => {
|
|
163
|
+
wrapper = await createWrapper();
|
|
164
|
+
const style = wrapper.find(".pop-over").attributes("style") ?? "";
|
|
165
|
+
expect(style).toContain("--_anchor-name: --pop-over-anchor-");
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe("styleClassPassthrough", () => {
|
|
170
|
+
it("applies a string class to the root element", async () => {
|
|
171
|
+
wrapper = await createWrapper({ styleClassPassthrough: "custom-pop-over" });
|
|
172
|
+
expect(wrapper.find(".pop-over").classes()).toContain("custom-pop-over");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("applies an array of classes to the root element", async () => {
|
|
176
|
+
wrapper = await createWrapper({ styleClassPassthrough: ["pop-over-a", "pop-over-b"] });
|
|
177
|
+
expect(wrapper.find(".pop-over").classes()).toContain("pop-over-a");
|
|
178
|
+
expect(wrapper.find(".pop-over").classes()).toContain("pop-over-b");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("root element always has the 'pop-over' class", async () => {
|
|
182
|
+
wrapper = await createWrapper({ styleClassPassthrough: "extra-class" });
|
|
183
|
+
expect(wrapper.find(".pop-over").classes()).toContain("pop-over");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("resets classes when styleClassPassthrough prop changes", async () => {
|
|
187
|
+
wrapper = await createWrapper({ styleClassPassthrough: "initial-class" });
|
|
188
|
+
expect(wrapper.find(".pop-over").classes()).toContain("initial-class");
|
|
189
|
+
|
|
190
|
+
await wrapper.setProps({ styleClassPassthrough: "updated-class" });
|
|
191
|
+
expect(wrapper.find(".pop-over").classes()).not.toContain("initial-class");
|
|
192
|
+
expect(wrapper.find(".pop-over").classes()).toContain("updated-class");
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
|
2
|
+
|
|
3
|
+
exports[`PopOver > renders correct HTML structure 1`] = `
|
|
4
|
+
"<div class="pop-over" style="--_anchor-name: --pop-over-anchor-v-0-0;" data-placement="right"><button popovertarget="pop-over-v-0-0" popovertargetaction="toggle" type="button" class="pop-over-trigger"><span>Open</span></button>
|
|
5
|
+
<div id="pop-over-v-0-0" popover="" class="pop-over-popover"><button popovertarget="pop-over-v-0-0" popovertargetaction="hide" type="button" class="pop-over-close-button" aria-label="Close"><span class="iconify i-lucide:x pop-over-close-button-icon" aria-hidden="true"></span></button>
|
|
6
|
+
<div class="pop-over-content">
|
|
7
|
+
<p>Details</p>
|
|
8
|
+
</div>
|
|
9
|
+
</div>
|
|
10
|
+
</div>"
|
|
11
|
+
`;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# HeaderBlock — Consumer Styling Guide
|
|
2
|
+
|
|
3
|
+
## No component-owned CSS tokens
|
|
4
|
+
|
|
5
|
+
`HeaderBlock` has no `<style>` block of its own. Visual size comes from applying one of six global
|
|
6
|
+
`.page-heading-1`–`.page-heading-6` utility classes (`classLevel` prop), defined in
|
|
7
|
+
`app/assets/styles/setup/05.typography/02.utility-classes/_font-classes-page-heading.css`:
|
|
8
|
+
|
|
9
|
+
```css
|
|
10
|
+
.page-heading-1 { font-size: var(--step-8); }
|
|
11
|
+
.page-heading-2 { font-size: var(--step-7); }
|
|
12
|
+
/* … down to .page-heading-6 { font-size: var(--step-3); } */
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Each size is a fluid type-scale token (`--step-3` through `--step-8`, defined globally, not by
|
|
16
|
+
this component) — override those tokens at a page/section scope to change every `page-heading-*`
|
|
17
|
+
element's size at once, not just this component's.
|
|
18
|
+
|
|
19
|
+
All six levels also reset `margin-block: 0rem` and `padding-block: 0rem` (instead of relying on the
|
|
20
|
+
browser's own per-level heading margins, which vary by `<h1>`–`<h6>` and would otherwise fight
|
|
21
|
+
`classLevel`/`tagLevel` being independent). This is a deliberate blank slate — add spacing in your
|
|
22
|
+
own CSS rather than expecting a level to come with baked-in margin:
|
|
23
|
+
|
|
24
|
+
```css
|
|
25
|
+
.my-page .page-heading-1 {
|
|
26
|
+
margin-block: 2.4rem 1.6rem;
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Overriding one instance
|
|
31
|
+
|
|
32
|
+
Use `styleClassPassthrough` to add your own class and override `font-size`/`font-weight`/etc. for
|
|
33
|
+
a single `HeaderBlock` instance without touching the shared `.page-heading-*` utility classes:
|
|
34
|
+
|
|
35
|
+
```vue
|
|
36
|
+
<HeaderBlock :tag-level="2" :class-level="1" style-class-passthrough="hero-title">Big idea</HeaderBlock>
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
```css
|
|
40
|
+
.hero-title {
|
|
41
|
+
font-size: 6rem;
|
|
42
|
+
}
|
|
43
|
+
```
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<component :is="tag" :id :class="[classLevelClass, elementClasses]">
|
|
3
|
+
<slot></slot>
|
|
4
|
+
</component>
|
|
5
|
+
</template>
|
|
6
|
+
|
|
7
|
+
<script setup lang="ts">
|
|
8
|
+
type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
/** Semantic heading level — controls which <h1>-<h6> tag is rendered. */
|
|
12
|
+
tagLevel?: HeadingLevel | `${HeadingLevel}`;
|
|
13
|
+
/** Visual size level — controls which `.page-heading-N` utility class is applied. Independent of tagLevel, so a component can be, e.g., a semantic h2 styled at the page-heading-1 size. */
|
|
14
|
+
classLevel?: HeadingLevel | `${HeadingLevel}`;
|
|
15
|
+
/** Bind to a wrapping section's aria-labelledby target (e.g. the heading-id slot prop from PageRow/useAriaLabelledById). */
|
|
16
|
+
id?: string | null;
|
|
17
|
+
styleClassPassthrough?: string | string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
21
|
+
tagLevel: 1,
|
|
22
|
+
classLevel: 1,
|
|
23
|
+
id: null,
|
|
24
|
+
styleClassPassthrough: () => [],
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const validLevels = [1, 2, 3, 4, 5, 6];
|
|
28
|
+
|
|
29
|
+
const tagLevel = computed(() => {
|
|
30
|
+
const n = Number(props.tagLevel);
|
|
31
|
+
return validLevels.includes(n) ? n : 1;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const classLevel = computed(() => {
|
|
35
|
+
const n = Number(props.classLevel);
|
|
36
|
+
return validLevels.includes(n) ? n : 1;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const tag = computed(() => `h${tagLevel.value}`);
|
|
40
|
+
const classLevelClass = computed(() => `page-heading-${classLevel.value}`);
|
|
41
|
+
|
|
42
|
+
const { elementClasses, resetElementClasses } = useStyleClassPassthrough(props.styleClassPassthrough);
|
|
43
|
+
|
|
44
|
+
watch(
|
|
45
|
+
() => props.styleClassPassthrough,
|
|
46
|
+
(newVal) => {
|
|
47
|
+
resetElementClasses(newVal ?? []);
|
|
48
|
+
}
|
|
49
|
+
);
|
|
50
|
+
</script>
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import HeaderBlock from "../HeaderBlock.vue";
|
|
2
|
+
import type { Meta, StoryObj } from "@nuxtjs/storybook";
|
|
3
|
+
|
|
4
|
+
const meta: Meta<typeof HeaderBlock> = {
|
|
5
|
+
title: "Atoms/Text Blocks/Header Block",
|
|
6
|
+
component: HeaderBlock,
|
|
7
|
+
argTypes: {
|
|
8
|
+
tagLevel: {
|
|
9
|
+
control: { type: "select" },
|
|
10
|
+
options: [1, 2, 3, 4, 5, 6],
|
|
11
|
+
description: "Semantic heading level — which <h1>-<h6> tag is rendered",
|
|
12
|
+
},
|
|
13
|
+
classLevel: {
|
|
14
|
+
control: { type: "select" },
|
|
15
|
+
options: [1, 2, 3, 4, 5, 6],
|
|
16
|
+
description: "Visual size level — which .page-heading-N utility class is applied, independent of tagLevel",
|
|
17
|
+
},
|
|
18
|
+
id: {
|
|
19
|
+
control: "text",
|
|
20
|
+
description: "Bind to a wrapping section's aria-labelledby target (e.g. PageRow's heading-id slot prop)",
|
|
21
|
+
},
|
|
22
|
+
styleClassPassthrough: {
|
|
23
|
+
control: "object",
|
|
24
|
+
description: "Additional classes applied to the root element",
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
args: {
|
|
28
|
+
tagLevel: 1,
|
|
29
|
+
classLevel: 1,
|
|
30
|
+
styleClassPassthrough: [],
|
|
31
|
+
},
|
|
32
|
+
parameters: {
|
|
33
|
+
docs: {
|
|
34
|
+
description: {
|
|
35
|
+
component:
|
|
36
|
+
"Renders an <h1>-<h6> tag chosen by tagLevel (document structure/accessibility), styled by the global .page-heading-{classLevel} utility class (visual size) — the two are independent, so a component can be a semantic h2 styled at the page-heading-1 size.",
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export default meta;
|
|
43
|
+
type Story = StoryObj<typeof HeaderBlock>;
|
|
44
|
+
|
|
45
|
+
export const Default: Story = {
|
|
46
|
+
render: (args) => ({
|
|
47
|
+
components: { HeaderBlock },
|
|
48
|
+
setup() {
|
|
49
|
+
return { args };
|
|
50
|
+
},
|
|
51
|
+
template: `<HeaderBlock v-bind="args">Page title</HeaderBlock>`,
|
|
52
|
+
}),
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const DecoupledLevels: Story = {
|
|
56
|
+
name: "Semantic vs Visual Level Decoupled",
|
|
57
|
+
args: { tagLevel: 2, classLevel: 1 },
|
|
58
|
+
parameters: {
|
|
59
|
+
docs: {
|
|
60
|
+
description: {
|
|
61
|
+
story:
|
|
62
|
+
"tagLevel=2 (renders an <h2>, correct for document structure below the page's real <h1>) but classLevel=1 (styled at the largest page-heading size) — inspect the DOM to confirm the tag is <h2> while it visually reads as the biggest heading on the page.",
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
render: (args) => ({
|
|
67
|
+
components: { HeaderBlock },
|
|
68
|
+
setup() {
|
|
69
|
+
return { args };
|
|
70
|
+
},
|
|
71
|
+
template: `<HeaderBlock v-bind="args">Section heading, styled big</HeaderBlock>`,
|
|
72
|
+
}),
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export const SectionAriaLabelledby: Story = {
|
|
76
|
+
name: "Paired with a Section's aria-labelledby",
|
|
77
|
+
parameters: {
|
|
78
|
+
docs: {
|
|
79
|
+
description: {
|
|
80
|
+
story:
|
|
81
|
+
"The id prop lets HeaderBlock be the target of a wrapping landmark's aria-labelledby — pass the same id both places (or, inside this library, the heading-id slot prop that PageRow/other section wrappers expose via useAriaLabelledById).",
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
render: () => ({
|
|
86
|
+
components: { HeaderBlock },
|
|
87
|
+
template: `
|
|
88
|
+
<section aria-labelledby="story-section-heading">
|
|
89
|
+
<HeaderBlock id="story-section-heading" :tag-level="2" :class-level="2">Section title</HeaderBlock>
|
|
90
|
+
<p>Section content…</p>
|
|
91
|
+
</section>
|
|
92
|
+
`,
|
|
93
|
+
}),
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export const AllSizes: Story = {
|
|
97
|
+
name: "All classLevel Sizes",
|
|
98
|
+
render: () => ({
|
|
99
|
+
components: { HeaderBlock },
|
|
100
|
+
template: `
|
|
101
|
+
<div>
|
|
102
|
+
<HeaderBlock :tag-level="1" :class-level="1">page-heading-1</HeaderBlock>
|
|
103
|
+
<HeaderBlock :tag-level="2" :class-level="2">page-heading-2</HeaderBlock>
|
|
104
|
+
<HeaderBlock :tag-level="3" :class-level="3">page-heading-3</HeaderBlock>
|
|
105
|
+
<HeaderBlock :tag-level="4" :class-level="4">page-heading-4</HeaderBlock>
|
|
106
|
+
<HeaderBlock :tag-level="5" :class-level="5">page-heading-5</HeaderBlock>
|
|
107
|
+
<HeaderBlock :tag-level="6" :class-level="6">page-heading-6</HeaderBlock>
|
|
108
|
+
</div>
|
|
109
|
+
`,
|
|
110
|
+
}),
|
|
111
|
+
};
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { mountSuspended } from "@nuxt/test-utils/runtime";
|
|
3
|
+
import HeaderBlock from "../HeaderBlock.vue";
|
|
4
|
+
|
|
5
|
+
describe("HeaderBlock", () => {
|
|
6
|
+
// ─── Mount ───────────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
it("mounts without error", async () => {
|
|
9
|
+
const wrapper = await mountSuspended(HeaderBlock, {
|
|
10
|
+
slots: { default: "Hello World" },
|
|
11
|
+
});
|
|
12
|
+
expect(wrapper.vm).toBeTruthy();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
// ─── Snapshots ───────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
it("renders correct HTML structure with default props", async () => {
|
|
18
|
+
const wrapper = await mountSuspended(HeaderBlock, {
|
|
19
|
+
slots: { default: "Default heading" },
|
|
20
|
+
});
|
|
21
|
+
expect(wrapper.html()).toMatchSnapshot();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("renders correct HTML structure with all props set", async () => {
|
|
25
|
+
const wrapper = await mountSuspended(HeaderBlock, {
|
|
26
|
+
props: { tagLevel: 3, classLevel: 5, id: "section-heading", styleClassPassthrough: ["custom-class"] },
|
|
27
|
+
slots: { default: "Custom heading" },
|
|
28
|
+
});
|
|
29
|
+
expect(wrapper.html()).toMatchSnapshot();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// ─── tagLevel / classLevel ───────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
it("defaults to an h1 with the page-heading-1 class", async () => {
|
|
35
|
+
const wrapper = await mountSuspended(HeaderBlock, { slots: { default: "Title" } });
|
|
36
|
+
expect(wrapper.element.tagName).toBe("H1");
|
|
37
|
+
expect(wrapper.classes()).toContain("page-heading-1");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("renders the tag matching tagLevel", async () => {
|
|
41
|
+
const wrapper = await mountSuspended(HeaderBlock, {
|
|
42
|
+
props: { tagLevel: 4 },
|
|
43
|
+
slots: { default: "Title" },
|
|
44
|
+
});
|
|
45
|
+
expect(wrapper.element.tagName).toBe("H4");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("applies the page-heading class matching classLevel, independent of tagLevel", async () => {
|
|
49
|
+
const wrapper = await mountSuspended(HeaderBlock, {
|
|
50
|
+
props: { tagLevel: 2, classLevel: 6 },
|
|
51
|
+
slots: { default: "Title" },
|
|
52
|
+
});
|
|
53
|
+
expect(wrapper.element.tagName).toBe("H2");
|
|
54
|
+
expect(wrapper.classes()).toContain("page-heading-6");
|
|
55
|
+
expect(wrapper.classes()).not.toContain("page-heading-2");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("accepts tagLevel/classLevel as numeric strings", async () => {
|
|
59
|
+
const wrapper = await mountSuspended(HeaderBlock, {
|
|
60
|
+
props: { tagLevel: "5", classLevel: "2" },
|
|
61
|
+
slots: { default: "Title" },
|
|
62
|
+
});
|
|
63
|
+
expect(wrapper.element.tagName).toBe("H5");
|
|
64
|
+
expect(wrapper.classes()).toContain("page-heading-2");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("clamps an out-of-range tagLevel to 1", async () => {
|
|
68
|
+
const wrapper = await mountSuspended(HeaderBlock, {
|
|
69
|
+
// @ts-expect-error deliberately invalid at the type level to test the runtime clamp
|
|
70
|
+
props: { tagLevel: 9 },
|
|
71
|
+
slots: { default: "Title" },
|
|
72
|
+
});
|
|
73
|
+
expect(wrapper.element.tagName).toBe("H1");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("clamps an out-of-range classLevel to 1", async () => {
|
|
77
|
+
const wrapper = await mountSuspended(HeaderBlock, {
|
|
78
|
+
// @ts-expect-error deliberately invalid at the type level to test the runtime clamp
|
|
79
|
+
props: { classLevel: 0 },
|
|
80
|
+
slots: { default: "Title" },
|
|
81
|
+
});
|
|
82
|
+
expect(wrapper.classes()).toContain("page-heading-1");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// ─── Slot content ────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
it("renders slot content", async () => {
|
|
88
|
+
const wrapper = await mountSuspended(HeaderBlock, {
|
|
89
|
+
slots: { default: "<span class='highlight'>Big</span> idea" },
|
|
90
|
+
});
|
|
91
|
+
expect(wrapper.find(".highlight").exists()).toBe(true);
|
|
92
|
+
expect(wrapper.text()).toContain("idea");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// ─── id ──────────────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
it("does not render an id attribute by default", async () => {
|
|
98
|
+
const wrapper = await mountSuspended(HeaderBlock, { slots: { default: "Title" } });
|
|
99
|
+
expect(wrapper.attributes("id")).toBeUndefined();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("renders a given id, for pairing with a wrapping section's aria-labelledby", async () => {
|
|
103
|
+
const wrapper = await mountSuspended(HeaderBlock, {
|
|
104
|
+
props: { id: "section-heading" },
|
|
105
|
+
slots: { default: "Title" },
|
|
106
|
+
});
|
|
107
|
+
expect(wrapper.attributes("id")).toBe("section-heading");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// ─── styleClassPassthrough ───────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
it("applies styleClassPassthrough classes", async () => {
|
|
113
|
+
const wrapper = await mountSuspended(HeaderBlock, {
|
|
114
|
+
props: { styleClassPassthrough: "hero-title" },
|
|
115
|
+
slots: { default: "Title" },
|
|
116
|
+
});
|
|
117
|
+
expect(wrapper.classes()).toContain("hero-title");
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
|
2
|
+
|
|
3
|
+
exports[`HeaderBlock > renders correct HTML structure with all props set 1`] = `"<h3 id="section-heading" class="page-heading-5 custom-class">Custom heading</h3>"`;
|
|
4
|
+
|
|
5
|
+
exports[`HeaderBlock > renders correct HTML structure with default props 1`] = `"<h1 class="page-heading-1">Default heading</h1>"`;
|
|
@@ -74,10 +74,16 @@ export const Default: Story = {
|
|
|
74
74
|
render: (args) => ({
|
|
75
75
|
components: { ActionMenu, ActionMenuItemCore },
|
|
76
76
|
setup() {
|
|
77
|
-
|
|
77
|
+
// `args` is Storybook's own reactive object — read from it directly (`args.x`) inside
|
|
78
|
+
// these computeds rather than destructuring it into local variables at setup-time, which
|
|
79
|
+
// would snapshot the values once and stop reflecting later Controls-panel changes.
|
|
80
|
+
const componentArgs = computed(() => {
|
|
81
|
+
const { itemCount, ...rest } = args;
|
|
82
|
+
return rest;
|
|
83
|
+
});
|
|
78
84
|
const lastAction = ref<string | null>(null);
|
|
79
85
|
const items = computed(() =>
|
|
80
|
-
actionItems.slice(0, itemCount ?? 5).map((item, i) => ({
|
|
86
|
+
actionItems.slice(0, args.itemCount ?? 5).map((item, i) => ({
|
|
81
87
|
...item,
|
|
82
88
|
slotName: `item-${i}`,
|
|
83
89
|
}))
|
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
<div class="input-copy" :class="elementClasses">
|
|
3
3
|
<div class="input-copy__wrapper">
|
|
4
4
|
<input
|
|
5
|
+
:id="resolvedId"
|
|
5
6
|
type="text"
|
|
6
7
|
class="input-copy__input"
|
|
8
|
+
:name="name || resolvedId"
|
|
7
9
|
:value="value"
|
|
8
10
|
:aria-label="ariaLabel || label"
|
|
9
11
|
readonly
|
|
@@ -26,6 +28,9 @@
|
|
|
26
28
|
<script setup lang="ts">
|
|
27
29
|
interface Props {
|
|
28
30
|
value: string;
|
|
31
|
+
/** Defaults to an auto-generated id — pass one explicitly only if something needs to target this input. */
|
|
32
|
+
id?: string;
|
|
33
|
+
name?: string;
|
|
29
34
|
label?: string;
|
|
30
35
|
ariaLabel?: string;
|
|
31
36
|
description?: string;
|
|
@@ -37,6 +42,8 @@ interface Props {
|
|
|
37
42
|
}
|
|
38
43
|
|
|
39
44
|
const props = withDefaults(defineProps<Props>(), {
|
|
45
|
+
id: undefined,
|
|
46
|
+
name: undefined,
|
|
40
47
|
label: undefined,
|
|
41
48
|
ariaLabel: undefined,
|
|
42
49
|
description: undefined,
|
|
@@ -47,6 +54,13 @@ const props = withDefaults(defineProps<Props>(), {
|
|
|
47
54
|
styleClassPassthrough: () => [],
|
|
48
55
|
});
|
|
49
56
|
|
|
57
|
+
// Readonly text inputs still need an id/name to satisfy "a form field element should have an
|
|
58
|
+
// id or name attribute" — auto-generated so every existing call site (none of which pass one)
|
|
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.
|
|
61
|
+
const autoId = useId();
|
|
62
|
+
const resolvedId = computed(() => props.id ?? autoId);
|
|
63
|
+
|
|
50
64
|
const emit = defineEmits<{
|
|
51
65
|
copy: [value: string];
|
|
52
66
|
copied: [value: string];
|
|
@@ -7,6 +7,11 @@ const meta: Meta<typeof InputCopy> = {
|
|
|
7
7
|
tags: ["autodocs"],
|
|
8
8
|
argTypes: {
|
|
9
9
|
value: { control: "text" },
|
|
10
|
+
id: {
|
|
11
|
+
control: "text",
|
|
12
|
+
description: "Defaults to an auto-generated id — pass one only if something needs to target this input.",
|
|
13
|
+
},
|
|
14
|
+
name: { control: "text", description: "Defaults to the resolved id if not provided." },
|
|
10
15
|
label: { control: "text" },
|
|
11
16
|
description: { control: "text" },
|
|
12
17
|
buttonText: { control: "text" },
|
|
@@ -101,6 +106,16 @@ export const NoDescription: Story = {
|
|
|
101
106
|
},
|
|
102
107
|
};
|
|
103
108
|
|
|
109
|
+
export const WithExplicitIdAndName: Story = {
|
|
110
|
+
args: {
|
|
111
|
+
value: "sk_live_abc123defgh456xyz789uvw",
|
|
112
|
+
id: "checkout-license-key",
|
|
113
|
+
name: "licenseKey",
|
|
114
|
+
label: "License key",
|
|
115
|
+
description: "Demonstrates passing explicit id/name instead of relying on the auto-generated one.",
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
|
|
104
119
|
export const LongValue: Story = {
|
|
105
120
|
args: {
|
|
106
121
|
value: "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.POdHyP_DQkT3WTZhu9qLWSwVe8ZlHfHEPBOzzVI3vFA",
|
|
@@ -178,4 +178,45 @@ describe("InputCopy", () => {
|
|
|
178
178
|
expect(wrapper.classes()).toContain("custom-class");
|
|
179
179
|
expect(wrapper.classes()).toContain("another-class");
|
|
180
180
|
});
|
|
181
|
+
|
|
182
|
+
it("auto-generates an id and mirrors it as the name when neither is provided", () => {
|
|
183
|
+
const wrapper = mount(InputCopy, {
|
|
184
|
+
props: {
|
|
185
|
+
value: "test-key",
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const input = wrapper.find("input");
|
|
190
|
+
const id = input.attributes("id");
|
|
191
|
+
|
|
192
|
+
expect(id).toBeTruthy();
|
|
193
|
+
expect(input.attributes("name")).toBe(id);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("uses the provided id prop instead of auto-generating one", () => {
|
|
197
|
+
const wrapper = mount(InputCopy, {
|
|
198
|
+
props: {
|
|
199
|
+
value: "test-key",
|
|
200
|
+
id: "license-key-input",
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const input = wrapper.find("input");
|
|
205
|
+
expect(input.attributes("id")).toBe("license-key-input");
|
|
206
|
+
expect(input.attributes("name")).toBe("license-key-input");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("uses the provided name prop instead of falling back to the id", () => {
|
|
210
|
+
const wrapper = mount(InputCopy, {
|
|
211
|
+
props: {
|
|
212
|
+
value: "test-key",
|
|
213
|
+
id: "license-key-input",
|
|
214
|
+
name: "licenseKey",
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const input = wrapper.find("input");
|
|
219
|
+
expect(input.attributes("id")).toBe("license-key-input");
|
|
220
|
+
expect(input.attributes("name")).toBe("licenseKey");
|
|
221
|
+
});
|
|
181
222
|
});
|