@dillingerstaffing/strand-ui 0.1.1 → 0.2.1
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/HTML_REFERENCE.md +753 -0
- package/dist/components/DataReadout/DataReadout.d.ts +2 -0
- package/dist/components/DataReadout/DataReadout.d.ts.map +1 -1
- package/dist/css/strand-ui.css +248 -76
- package/dist/index.d.ts +1 -1
- package/dist/index.js +13 -9
- package/package.json +13 -10
- package/src/__tests__/build-output.test.ts +123 -0
- package/src/__tests__/design-language.test.ts +137 -0
- package/src/__tests__/static.test.tsx +60 -0
- package/src/components/Alert/Alert.css +11 -3
- package/src/components/Badge/Badge.css +1 -1
- package/src/components/Breadcrumb/Breadcrumb.css +6 -1
- package/src/components/Button/Button.css +15 -8
- package/src/components/Card/Card.css +12 -3
- package/src/components/Checkbox/Checkbox.css +4 -4
- package/src/components/DataReadout/DataReadout.css +9 -0
- package/src/components/DataReadout/DataReadout.test.tsx +36 -0
- package/src/components/DataReadout/DataReadout.tsx +8 -2
- package/src/components/Dialog/Dialog.css +7 -6
- package/src/components/FormField/FormField.css +1 -1
- package/src/components/Grid/Grid.css +21 -0
- package/src/components/Input/Input.css +11 -4
- package/src/components/Link/Link.css +6 -0
- package/src/components/Nav/Nav.css +13 -3
- package/src/components/Progress/Progress.css +3 -3
- package/src/components/Radio/Radio.css +4 -4
- package/src/components/Select/Select.css +11 -4
- package/src/components/Skeleton/Skeleton.css +2 -2
- package/src/components/Slider/Slider.css +5 -5
- package/src/components/Stack/Stack.css +15 -0
- package/src/components/Switch/Switch.css +4 -4
- package/src/components/Table/Table.css +6 -1
- package/src/components/Tabs/Tabs.css +7 -2
- package/src/components/Tag/Tag.css +7 -7
- package/src/components/Textarea/Textarea.css +11 -4
- package/src/components/Toast/Toast.css +3 -3
- package/src/components/Tooltip/Tooltip.css +2 -2
- package/src/index.ts +1 -1
- package/src/static.css +47 -0
- package/LICENSE +0 -21
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/*! Strand UI v0.
|
|
1
|
+
/*! Strand UI v0.2.0 | MIT License | dillingerstaffing.com */
|
|
2
2
|
export { Button } from "./components/Button/index.js";
|
|
3
3
|
export type { ButtonProps } from "./components/Button/index.js";
|
|
4
4
|
export { Input } from "./components/Input/index.js";
|
package/dist/index.js
CHANGED
|
@@ -499,7 +499,7 @@ const V = p(
|
|
|
499
499
|
}
|
|
500
500
|
);
|
|
501
501
|
V.displayName = "Avatar";
|
|
502
|
-
const
|
|
502
|
+
const O = p(
|
|
503
503
|
({
|
|
504
504
|
variant: a = "solid",
|
|
505
505
|
status: t = "default",
|
|
@@ -548,8 +548,8 @@ const z = p(
|
|
|
548
548
|
] });
|
|
549
549
|
}
|
|
550
550
|
);
|
|
551
|
-
|
|
552
|
-
const
|
|
551
|
+
O.displayName = "Tag";
|
|
552
|
+
const z = p(
|
|
553
553
|
({ columns: a, data: t, onSort: e, className: n = "", ...i }, o) => {
|
|
554
554
|
const [r, l] = B(null), [c, u] = B("asc"), d = b(
|
|
555
555
|
(m) => {
|
|
@@ -591,11 +591,15 @@ const O = p(
|
|
|
591
591
|
] }) });
|
|
592
592
|
}
|
|
593
593
|
);
|
|
594
|
-
|
|
594
|
+
z.displayName = "Table";
|
|
595
595
|
const W = p(
|
|
596
|
-
({ label: a, value: t,
|
|
597
|
-
const
|
|
598
|
-
|
|
596
|
+
({ label: a, value: t, size: e, className: n = "", ...i }, o) => {
|
|
597
|
+
const r = [
|
|
598
|
+
"strand-data-readout",
|
|
599
|
+
e && e !== "md" ? `strand-data-readout--${e}` : "",
|
|
600
|
+
n
|
|
601
|
+
].filter(Boolean).join(" ");
|
|
602
|
+
return /* @__PURE__ */ _("div", { ref: o, className: r, ...i, children: [
|
|
599
603
|
/* @__PURE__ */ s("span", { className: "strand-data-readout__label", children: a }),
|
|
600
604
|
/* @__PURE__ */ s("span", { className: "strand-data-readout__value", children: t })
|
|
601
605
|
] });
|
|
@@ -1353,9 +1357,9 @@ export {
|
|
|
1353
1357
|
pa as Spinner,
|
|
1354
1358
|
Z as Stack,
|
|
1355
1359
|
F as Switch,
|
|
1356
|
-
|
|
1360
|
+
z as Table,
|
|
1357
1361
|
ea as Tabs,
|
|
1358
|
-
|
|
1362
|
+
O as Tag,
|
|
1359
1363
|
A as Textarea,
|
|
1360
1364
|
ia as Toast,
|
|
1361
1365
|
ra as ToastProvider,
|
package/package.json
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dillingerstaffing/strand-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Strand UI - Preact/React component library built on the Strand Design Language",
|
|
5
5
|
"author": "Dillinger Staffing <engineering@dillingerstaffing.com> (https://dillingerstaffing.com)",
|
|
6
6
|
"license": "MIT",
|
|
7
|
+
"keywords": ["design-system", "ui-components", "preact", "react", "css-custom-properties", "design-tokens", "accessibility", "wcag", "aria", "component-library"],
|
|
7
8
|
"homepage": "https://dillingerstaffing.com/labs/strand",
|
|
8
9
|
"repository": {
|
|
9
10
|
"type": "git",
|
|
@@ -24,13 +25,21 @@
|
|
|
24
25
|
},
|
|
25
26
|
"./css/strand-ui.css": "./dist/css/strand-ui.css"
|
|
26
27
|
},
|
|
28
|
+
"style": "./dist/css/strand-ui.css",
|
|
27
29
|
"files": [
|
|
28
30
|
"dist/",
|
|
29
|
-
"src/"
|
|
31
|
+
"src/",
|
|
32
|
+
"HTML_REFERENCE.md"
|
|
30
33
|
],
|
|
31
34
|
"sideEffects": [
|
|
32
35
|
"dist/css/*.css"
|
|
33
36
|
],
|
|
37
|
+
"scripts": {
|
|
38
|
+
"build": "vite build && tsc --emitDeclarationOnly && cp ../../HTML_REFERENCE.md ./HTML_REFERENCE.md",
|
|
39
|
+
"test": "vitest run",
|
|
40
|
+
"test:watch": "vitest",
|
|
41
|
+
"test:coverage": "vitest run --coverage"
|
|
42
|
+
},
|
|
34
43
|
"peerDependencies": {
|
|
35
44
|
"preact": "^10.0.0"
|
|
36
45
|
},
|
|
@@ -40,7 +49,7 @@
|
|
|
40
49
|
}
|
|
41
50
|
},
|
|
42
51
|
"dependencies": {
|
|
43
|
-
"@dillingerstaffing/strand": "
|
|
52
|
+
"@dillingerstaffing/strand": "workspace:*"
|
|
44
53
|
},
|
|
45
54
|
"devDependencies": {
|
|
46
55
|
"@testing-library/preact": "^3.2.0",
|
|
@@ -49,11 +58,5 @@
|
|
|
49
58
|
"preact": "^10.25.0",
|
|
50
59
|
"vite": "^6.0.0",
|
|
51
60
|
"vitest": "^3.0.0"
|
|
52
|
-
},
|
|
53
|
-
"scripts": {
|
|
54
|
-
"build": "vite build && tsc --emitDeclarationOnly",
|
|
55
|
-
"test": "vitest run",
|
|
56
|
-
"test:watch": "vitest",
|
|
57
|
-
"test:coverage": "vitest run --coverage"
|
|
58
61
|
}
|
|
59
|
-
}
|
|
62
|
+
}
|
|
@@ -74,4 +74,127 @@ describe("Build output", () => {
|
|
|
74
74
|
expect(existsSync(resolve(__dirname, "../components/Button/Button.css"))).toBe(true);
|
|
75
75
|
expect(existsSync(resolve(__dirname, "../components/Dialog/Dialog.tsx"))).toBe(true);
|
|
76
76
|
});
|
|
77
|
+
|
|
78
|
+
it("CSS has no hardcoded duration values", () => {
|
|
79
|
+
const css = readFileSync(resolve(distDir, "css/strand-ui.css"), "utf-8");
|
|
80
|
+
const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
81
|
+
// Strip @keyframes blocks entirely (durations inside keyframes are acceptable)
|
|
82
|
+
const withoutKeyframes = withoutComments.replace(/@keyframes\s+[\w-]+\s*\{[^}]*(?:\{[^}]*\}[^}]*)*\}/g, "");
|
|
83
|
+
// Strip animation shorthand lines (duration is part of the shorthand spec, not standalone)
|
|
84
|
+
const withoutAnimations = withoutKeyframes.replace(/animation:.*$/gm, "");
|
|
85
|
+
// Strip var() references (tokenized durations are fine)
|
|
86
|
+
const withoutVars = withoutAnimations.replace(/var\([^)]+\)/g, "VAR_REF");
|
|
87
|
+
|
|
88
|
+
const durationPattern = /(?<!\w)(150ms|250ms|400ms|700ms|1\.8s|1\.5s|1\.2s|0\.8s)\b/g;
|
|
89
|
+
const matches: string[] = [];
|
|
90
|
+
let match: RegExpExecArray | null;
|
|
91
|
+
while ((match = durationPattern.exec(withoutVars)) !== null) {
|
|
92
|
+
matches.push(match[0]);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Exception: 75ms in transition-duration for active/pressed states (Part XII spec)
|
|
96
|
+
const nonExemptPattern = /(?<!\w)75ms\b/g;
|
|
97
|
+
const lines = withoutVars.split("\n");
|
|
98
|
+
for (const line of lines) {
|
|
99
|
+
if (nonExemptPattern.test(line) && !line.includes("transition-duration")) {
|
|
100
|
+
matches.push("75ms (outside transition-duration)");
|
|
101
|
+
}
|
|
102
|
+
nonExemptPattern.lastIndex = 0;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
expect(matches, `Hardcoded duration values found: ${matches.join(", ")}`).toEqual([]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("CSS has no hardcoded easing values", () => {
|
|
109
|
+
const css = readFileSync(resolve(distDir, "css/strand-ui.css"), "utf-8");
|
|
110
|
+
const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
111
|
+
// Strip var() references
|
|
112
|
+
const withoutVars = withoutComments.replace(/var\([^)]+\)/g, "VAR_REF");
|
|
113
|
+
|
|
114
|
+
const violations: string[] = [];
|
|
115
|
+
|
|
116
|
+
// Check for raw cubic-bezier()
|
|
117
|
+
const cubicBezierPattern = /cubic-bezier\([^)]+\)/g;
|
|
118
|
+
let match: RegExpExecArray | null;
|
|
119
|
+
while ((match = cubicBezierPattern.exec(withoutVars)) !== null) {
|
|
120
|
+
violations.push(match[0]);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Check for bare easing keywords (not inside var() and not "linear")
|
|
124
|
+
// linear is acceptable for spinners/continuous rotation
|
|
125
|
+
const easingKeywordPattern = /\b(ease-in-out|ease-in|ease-out)\b/g;
|
|
126
|
+
while ((match = easingKeywordPattern.exec(withoutVars)) !== null) {
|
|
127
|
+
violations.push(match[0]);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
expect(violations, `Hardcoded easing values found: ${violations.join(", ")}`).toEqual([]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("CSS has no hardcoded border-radius pixel values", () => {
|
|
134
|
+
const css = readFileSync(resolve(distDir, "css/strand-ui.css"), "utf-8");
|
|
135
|
+
const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
136
|
+
// Strip var() references
|
|
137
|
+
const withoutVars = withoutComments.replace(/var\([^)]+\)/g, "VAR_REF");
|
|
138
|
+
|
|
139
|
+
// Match border-radius with raw pixel values
|
|
140
|
+
const borderRadiusPattern = /border-radius:\s*\d+px/g;
|
|
141
|
+
const matches: string[] = [];
|
|
142
|
+
let match: RegExpExecArray | null;
|
|
143
|
+
while ((match = borderRadiusPattern.exec(withoutVars)) !== null) {
|
|
144
|
+
matches.push(match[0]);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
expect(matches, `Hardcoded border-radius values found: ${matches.join(", ")}`).toEqual([]);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("All interactive component CSS files include :focus-visible", () => {
|
|
151
|
+
const interactiveComponents = [
|
|
152
|
+
"Button", "Link", "Card", "Checkbox", "Input", "Radio",
|
|
153
|
+
"Select", "Slider", "Switch", "Tabs", "Breadcrumb", "Nav", "Table",
|
|
154
|
+
];
|
|
155
|
+
|
|
156
|
+
for (const name of interactiveComponents) {
|
|
157
|
+
const cssPath = resolve(__dirname, `../components/${name}/${name}.css`);
|
|
158
|
+
const css = readFileSync(cssPath, "utf-8");
|
|
159
|
+
// Compound inputs (Input, Select, Textarea) use :focus-within on the wrapper,
|
|
160
|
+
// which is equivalent for components with a visually hidden native input
|
|
161
|
+
const hasFocusHandling = css.includes(":focus-visible") || css.includes(":focus-within");
|
|
162
|
+
expect(hasFocusHandling, `${name}.css missing :focus-visible or :focus-within`).toBe(true);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("All animated component CSS files include prefers-reduced-motion", () => {
|
|
167
|
+
const allComponents = [
|
|
168
|
+
"Alert", "Avatar", "Badge", "Breadcrumb", "Button", "Card",
|
|
169
|
+
"Checkbox", "Container", "DataReadout", "Dialog", "Divider",
|
|
170
|
+
"FormField", "Grid", "Input", "Link", "Nav", "Progress", "Radio",
|
|
171
|
+
"Section", "Select", "Skeleton", "Slider", "Spinner", "Stack",
|
|
172
|
+
"Switch", "Table", "Tabs", "Tag", "Textarea", "Toast", "Tooltip",
|
|
173
|
+
];
|
|
174
|
+
|
|
175
|
+
for (const name of allComponents) {
|
|
176
|
+
const cssPath = resolve(__dirname, `../components/${name}/${name}.css`);
|
|
177
|
+
const css = readFileSync(cssPath, "utf-8");
|
|
178
|
+
const usesAnimation = /\banimation\b/.test(css) || /\btransition\b/.test(css);
|
|
179
|
+
if (usesAnimation) {
|
|
180
|
+
expect(css, `${name}.css uses animation/transition but missing prefers-reduced-motion`).toContain("prefers-reduced-motion");
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("All component CSS files start with MIT license banner", () => {
|
|
186
|
+
const allComponents = [
|
|
187
|
+
"Alert", "Avatar", "Badge", "Breadcrumb", "Button", "Card",
|
|
188
|
+
"Checkbox", "Container", "DataReadout", "Dialog", "Divider",
|
|
189
|
+
"FormField", "Grid", "Input", "Link", "Nav", "Progress", "Radio",
|
|
190
|
+
"Section", "Select", "Skeleton", "Slider", "Spinner", "Stack",
|
|
191
|
+
"Switch", "Table", "Tabs", "Tag", "Textarea", "Toast", "Tooltip",
|
|
192
|
+
];
|
|
193
|
+
|
|
194
|
+
for (const name of allComponents) {
|
|
195
|
+
const cssPath = resolve(__dirname, `../components/${name}/${name}.css`);
|
|
196
|
+
const css = readFileSync(cssPath, "utf-8");
|
|
197
|
+
expect(css, `${name}.css missing MIT license banner`).toMatch(/^\/\*! Strand/);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
77
200
|
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
const componentDir = resolve(__dirname, "../components");
|
|
6
|
+
|
|
7
|
+
const allComponents: { name: string; cssPath: string }[] = [
|
|
8
|
+
{ name: "Alert", cssPath: resolve(componentDir, "Alert/Alert.css") },
|
|
9
|
+
{ name: "Avatar", cssPath: resolve(componentDir, "Avatar/Avatar.css") },
|
|
10
|
+
{ name: "Badge", cssPath: resolve(componentDir, "Badge/Badge.css") },
|
|
11
|
+
{ name: "Breadcrumb", cssPath: resolve(componentDir, "Breadcrumb/Breadcrumb.css") },
|
|
12
|
+
{ name: "Button", cssPath: resolve(componentDir, "Button/Button.css") },
|
|
13
|
+
{ name: "Card", cssPath: resolve(componentDir, "Card/Card.css") },
|
|
14
|
+
{ name: "Checkbox", cssPath: resolve(componentDir, "Checkbox/Checkbox.css") },
|
|
15
|
+
{ name: "Container", cssPath: resolve(componentDir, "Container/Container.css") },
|
|
16
|
+
{ name: "DataReadout", cssPath: resolve(componentDir, "DataReadout/DataReadout.css") },
|
|
17
|
+
{ name: "Dialog", cssPath: resolve(componentDir, "Dialog/Dialog.css") },
|
|
18
|
+
{ name: "Divider", cssPath: resolve(componentDir, "Divider/Divider.css") },
|
|
19
|
+
{ name: "FormField", cssPath: resolve(componentDir, "FormField/FormField.css") },
|
|
20
|
+
{ name: "Grid", cssPath: resolve(componentDir, "Grid/Grid.css") },
|
|
21
|
+
{ name: "Input", cssPath: resolve(componentDir, "Input/Input.css") },
|
|
22
|
+
{ name: "Link", cssPath: resolve(componentDir, "Link/Link.css") },
|
|
23
|
+
{ name: "Nav", cssPath: resolve(componentDir, "Nav/Nav.css") },
|
|
24
|
+
{ name: "Progress", cssPath: resolve(componentDir, "Progress/Progress.css") },
|
|
25
|
+
{ name: "Radio", cssPath: resolve(componentDir, "Radio/Radio.css") },
|
|
26
|
+
{ name: "Section", cssPath: resolve(componentDir, "Section/Section.css") },
|
|
27
|
+
{ name: "Select", cssPath: resolve(componentDir, "Select/Select.css") },
|
|
28
|
+
{ name: "Skeleton", cssPath: resolve(componentDir, "Skeleton/Skeleton.css") },
|
|
29
|
+
{ name: "Slider", cssPath: resolve(componentDir, "Slider/Slider.css") },
|
|
30
|
+
{ name: "Spinner", cssPath: resolve(componentDir, "Spinner/Spinner.css") },
|
|
31
|
+
{ name: "Stack", cssPath: resolve(componentDir, "Stack/Stack.css") },
|
|
32
|
+
{ name: "Switch", cssPath: resolve(componentDir, "Switch/Switch.css") },
|
|
33
|
+
{ name: "Table", cssPath: resolve(componentDir, "Table/Table.css") },
|
|
34
|
+
{ name: "Tabs", cssPath: resolve(componentDir, "Tabs/Tabs.css") },
|
|
35
|
+
{ name: "Tag", cssPath: resolve(componentDir, "Tag/Tag.css") },
|
|
36
|
+
{ name: "Textarea", cssPath: resolve(componentDir, "Textarea/Textarea.css") },
|
|
37
|
+
{ name: "Toast", cssPath: resolve(componentDir, "Toast/Toast.css") },
|
|
38
|
+
{ name: "Tooltip", cssPath: resolve(componentDir, "Tooltip/Tooltip.css") },
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
describe("CSS design language compliance", () => {
|
|
42
|
+
it("No component CSS uses raw hex colors except known exceptions", () => {
|
|
43
|
+
// Known exceptions: rgba() for Tag tint backgrounds and Alert status backgrounds
|
|
44
|
+
// (opacity variants without tokens yet)
|
|
45
|
+
const rgbaExemptComponents = ["Tag", "Alert"];
|
|
46
|
+
const violations: string[] = [];
|
|
47
|
+
|
|
48
|
+
for (const { name, cssPath } of allComponents) {
|
|
49
|
+
const css = readFileSync(cssPath, "utf-8");
|
|
50
|
+
const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
51
|
+
// Strip var() references
|
|
52
|
+
const withoutVars = withoutComments.replace(/var\([^)]+\)/g, "VAR_REF");
|
|
53
|
+
|
|
54
|
+
// For exempted components, also strip rgba() calls
|
|
55
|
+
let searchable = withoutVars;
|
|
56
|
+
if (rgbaExemptComponents.includes(name)) {
|
|
57
|
+
searchable = searchable.replace(/rgba\([^)]+\)/g, "RGBA_REF");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const hexPattern = /#[0-9a-fA-F]{3,8}\b/g;
|
|
61
|
+
let match: RegExpExecArray | null;
|
|
62
|
+
while ((match = hexPattern.exec(searchable)) !== null) {
|
|
63
|
+
violations.push(`${name}: ${match[0]}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
expect(violations, `Raw hex colors found:\n${violations.join("\n")}`).toEqual([]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("All component CSS uses only --strand- prefixed custom properties", () => {
|
|
71
|
+
const violations: string[] = [];
|
|
72
|
+
|
|
73
|
+
for (const { name, cssPath } of allComponents) {
|
|
74
|
+
const css = readFileSync(cssPath, "utf-8");
|
|
75
|
+
const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
76
|
+
|
|
77
|
+
// Find all var() references
|
|
78
|
+
const varPattern = /var\(--([^)]+)\)/g;
|
|
79
|
+
let match: RegExpExecArray | null;
|
|
80
|
+
while ((match = varPattern.exec(withoutComments)) !== null) {
|
|
81
|
+
const propName = match[1];
|
|
82
|
+
if (!propName.startsWith("strand-")) {
|
|
83
|
+
violations.push(`${name}: var(--${propName})`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
expect(
|
|
89
|
+
violations,
|
|
90
|
+
`Non --strand- prefixed custom properties found:\n${violations.join("\n")}`,
|
|
91
|
+
).toEqual([]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("Interactive components have correct hover transform", () => {
|
|
95
|
+
// Button and Card (interactive) should have translateY(-1px) or translateY(-2px)
|
|
96
|
+
const interactiveSpecs: { name: string; cssPath: string }[] = [
|
|
97
|
+
{ name: "Button", cssPath: resolve(componentDir, "Button/Button.css") },
|
|
98
|
+
{ name: "Card", cssPath: resolve(componentDir, "Card/Card.css") },
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
for (const { name, cssPath } of interactiveSpecs) {
|
|
102
|
+
const css = readFileSync(cssPath, "utf-8");
|
|
103
|
+
const hasHoverTransform =
|
|
104
|
+
css.includes("translateY(-1px)") || css.includes("translateY(-2px)");
|
|
105
|
+
expect(
|
|
106
|
+
hasHoverTransform,
|
|
107
|
+
`${name} missing hover translateY(-1px) or translateY(-2px)`,
|
|
108
|
+
).toBe(true);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("Disabled states use opacity 0.4", () => {
|
|
113
|
+
// Components with :disabled or --disabled class
|
|
114
|
+
const componentsWithDisabled: { name: string; cssPath: string }[] = [
|
|
115
|
+
{ name: "Button", cssPath: resolve(componentDir, "Button/Button.css") },
|
|
116
|
+
{ name: "Checkbox", cssPath: resolve(componentDir, "Checkbox/Checkbox.css") },
|
|
117
|
+
{ name: "Input", cssPath: resolve(componentDir, "Input/Input.css") },
|
|
118
|
+
{ name: "Radio", cssPath: resolve(componentDir, "Radio/Radio.css") },
|
|
119
|
+
{ name: "Select", cssPath: resolve(componentDir, "Select/Select.css") },
|
|
120
|
+
{ name: "Slider", cssPath: resolve(componentDir, "Slider/Slider.css") },
|
|
121
|
+
{ name: "Switch", cssPath: resolve(componentDir, "Switch/Switch.css") },
|
|
122
|
+
{ name: "Textarea", cssPath: resolve(componentDir, "Textarea/Textarea.css") },
|
|
123
|
+
];
|
|
124
|
+
|
|
125
|
+
for (const { name, cssPath } of componentsWithDisabled) {
|
|
126
|
+
const css = readFileSync(cssPath, "utf-8");
|
|
127
|
+
const hasDisabled = css.includes(":disabled") || css.includes("--disabled");
|
|
128
|
+
expect(hasDisabled, `${name} expected to have disabled styles`).toBe(true);
|
|
129
|
+
|
|
130
|
+
// Verify opacity: 0.4 is present
|
|
131
|
+
expect(
|
|
132
|
+
css,
|
|
133
|
+
`${name} disabled state should use opacity: 0.4`,
|
|
134
|
+
).toContain("opacity: 0.4");
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { render } from "@testing-library/preact";
|
|
3
|
+
import { Button } from "../components/Button/index.js";
|
|
4
|
+
|
|
5
|
+
describe("strand-static presentation mode", () => {
|
|
6
|
+
it("CSS file exists and is included in build output", async () => {
|
|
7
|
+
const fs = await import("node:fs");
|
|
8
|
+
const path = await import("node:path");
|
|
9
|
+
const staticPath = path.resolve(__dirname, "../../dist/css/strand-ui.css");
|
|
10
|
+
const content = fs.readFileSync(staticPath, "utf-8");
|
|
11
|
+
expect(content).toContain(".strand-static");
|
|
12
|
+
expect(content).toContain("pointer-events: none");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("disabled button inside .strand-static has opacity override class", async () => {
|
|
16
|
+
const fs = await import("node:fs");
|
|
17
|
+
const path = await import("node:path");
|
|
18
|
+
const staticPath = path.resolve(__dirname, "../../dist/css/strand-ui.css");
|
|
19
|
+
const content = fs.readFileSync(staticPath, "utf-8");
|
|
20
|
+
expect(content).toContain(".strand-static [disabled]");
|
|
21
|
+
expect(content).toContain("opacity: 1");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("strand-static overrides toast position", async () => {
|
|
25
|
+
const fs = await import("node:fs");
|
|
26
|
+
const path = await import("node:path");
|
|
27
|
+
const staticPath = path.resolve(__dirname, "../../dist/css/strand-ui.css");
|
|
28
|
+
const content = fs.readFileSync(staticPath, "utf-8");
|
|
29
|
+
expect(content).toContain(".strand-static .strand-toast");
|
|
30
|
+
expect(content).toContain("position: static");
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("layout utility classes", () => {
|
|
35
|
+
it("Stack gap utilities exist in build output", async () => {
|
|
36
|
+
const fs = await import("node:fs");
|
|
37
|
+
const path = await import("node:path");
|
|
38
|
+
const cssPath = path.resolve(__dirname, "../../dist/css/strand-ui.css");
|
|
39
|
+
const content = fs.readFileSync(cssPath, "utf-8");
|
|
40
|
+
expect(content).toContain(".strand-stack--gap-4");
|
|
41
|
+
expect(content).toContain("var(--strand-space-4)");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("Grid column utilities exist in build output", async () => {
|
|
45
|
+
const fs = await import("node:fs");
|
|
46
|
+
const path = await import("node:path");
|
|
47
|
+
const cssPath = path.resolve(__dirname, "../../dist/css/strand-ui.css");
|
|
48
|
+
const content = fs.readFileSync(cssPath, "utf-8");
|
|
49
|
+
expect(content).toContain(".strand-grid--cols-3");
|
|
50
|
+
expect(content).toContain("repeat(3, 1fr)");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("Grid gap utilities exist in build output", async () => {
|
|
54
|
+
const fs = await import("node:fs");
|
|
55
|
+
const path = await import("node:path");
|
|
56
|
+
const cssPath = path.resolve(__dirname, "../../dist/css/strand-ui.css");
|
|
57
|
+
const content = fs.readFileSync(cssPath, "utf-8");
|
|
58
|
+
expect(content).toContain(".strand-grid--gap-4");
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
align-items: flex-start;
|
|
8
8
|
justify-content: space-between;
|
|
9
9
|
width: 100%;
|
|
10
|
-
padding: var(--strand-space-
|
|
10
|
+
padding: var(--strand-space-6);
|
|
11
|
+
padding-left: var(--strand-space-5);
|
|
11
12
|
border-radius: var(--strand-radius-md);
|
|
12
13
|
border-left: 4px solid transparent;
|
|
13
14
|
font-family: var(--strand-font-sans);
|
|
@@ -57,11 +58,18 @@
|
|
|
57
58
|
color: var(--strand-gray-500);
|
|
58
59
|
font-size: var(--strand-text-base);
|
|
59
60
|
cursor: pointer;
|
|
60
|
-
transition: background var(--strand-duration-fast) ease,
|
|
61
|
-
color var(--strand-duration-fast) ease;
|
|
61
|
+
transition: background var(--strand-duration-fast) var(--strand-ease-out-quart),
|
|
62
|
+
color var(--strand-duration-fast) var(--strand-ease-out-quart);
|
|
62
63
|
}
|
|
63
64
|
|
|
64
65
|
.strand-alert__dismiss:hover {
|
|
65
66
|
background: var(--strand-gray-100);
|
|
66
67
|
color: var(--strand-gray-600);
|
|
67
68
|
}
|
|
69
|
+
|
|
70
|
+
/* ── Reduced motion ── */
|
|
71
|
+
@media (prefers-reduced-motion: reduce) {
|
|
72
|
+
.strand-alert__dismiss {
|
|
73
|
+
transition: none;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -25,13 +25,18 @@
|
|
|
25
25
|
.strand-breadcrumb__link {
|
|
26
26
|
color: var(--strand-gray-500);
|
|
27
27
|
text-decoration: none;
|
|
28
|
-
transition: color var(--strand-duration-fast) ease;
|
|
28
|
+
transition: color var(--strand-duration-fast) var(--strand-ease-out-quart);
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
.strand-breadcrumb__link:hover {
|
|
32
32
|
color: var(--strand-gray-600);
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
.strand-breadcrumb__link:focus-visible {
|
|
36
|
+
outline: 2px solid var(--strand-blue-primary);
|
|
37
|
+
outline-offset: 2px;
|
|
38
|
+
}
|
|
39
|
+
|
|
35
40
|
.strand-breadcrumb__current {
|
|
36
41
|
color: var(--strand-gray-600);
|
|
37
42
|
font-weight: var(--strand-weight-medium);
|
|
@@ -16,11 +16,11 @@
|
|
|
16
16
|
cursor: pointer;
|
|
17
17
|
user-select: none;
|
|
18
18
|
transition:
|
|
19
|
-
background var(--strand-duration-fast) ease,
|
|
20
|
-
border-color var(--strand-duration-fast) ease,
|
|
21
|
-
color var(--strand-duration-fast) ease,
|
|
19
|
+
background var(--strand-duration-fast) var(--strand-ease-out-quart),
|
|
20
|
+
border-color var(--strand-duration-fast) var(--strand-ease-out-quart),
|
|
21
|
+
color var(--strand-duration-fast) var(--strand-ease-out-quart),
|
|
22
22
|
transform var(--strand-duration-fast) var(--strand-ease-out-expo),
|
|
23
|
-
box-shadow var(--strand-duration-fast) ease;
|
|
23
|
+
box-shadow var(--strand-duration-fast) var(--strand-ease-out-quart);
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
.strand-btn:active:not(:disabled) {
|
|
@@ -82,7 +82,7 @@
|
|
|
82
82
|
.strand-btn--primary:hover:not(:disabled) {
|
|
83
83
|
background: var(--strand-blue-vivid);
|
|
84
84
|
transform: translateY(-1px);
|
|
85
|
-
box-shadow:
|
|
85
|
+
box-shadow: var(--strand-hover-shadow-primary);
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
.strand-btn--primary:active:not(:disabled) {
|
|
@@ -115,6 +115,7 @@
|
|
|
115
115
|
|
|
116
116
|
.strand-btn--ghost:hover:not(:disabled) {
|
|
117
117
|
background: var(--strand-blue-glow);
|
|
118
|
+
transform: translateY(-1px);
|
|
118
119
|
}
|
|
119
120
|
|
|
120
121
|
.strand-btn--ghost:active:not(:disabled) {
|
|
@@ -128,13 +129,13 @@
|
|
|
128
129
|
}
|
|
129
130
|
|
|
130
131
|
.strand-btn--danger:hover:not(:disabled) {
|
|
131
|
-
background:
|
|
132
|
+
background: var(--strand-red-alert-vivid);
|
|
132
133
|
transform: translateY(-1px);
|
|
133
|
-
box-shadow:
|
|
134
|
+
box-shadow: var(--strand-hover-shadow-danger);
|
|
134
135
|
}
|
|
135
136
|
|
|
136
137
|
.strand-btn--danger:active:not(:disabled) {
|
|
137
|
-
background:
|
|
138
|
+
background: var(--strand-red-alert-deep);
|
|
138
139
|
}
|
|
139
140
|
|
|
140
141
|
/* ── Loading state ── */
|
|
@@ -171,6 +172,12 @@
|
|
|
171
172
|
gap: var(--strand-space-2);
|
|
172
173
|
}
|
|
173
174
|
|
|
175
|
+
/* ── Focus ring (Part XII: Accessibility Ring) ── */
|
|
176
|
+
.strand-btn:focus-visible {
|
|
177
|
+
outline: 2px solid var(--strand-blue-primary);
|
|
178
|
+
outline-offset: 2px;
|
|
179
|
+
}
|
|
180
|
+
|
|
174
181
|
/* ── Reduced motion ── */
|
|
175
182
|
@media (prefers-reduced-motion: reduce) {
|
|
176
183
|
.strand-btn {
|
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
border-radius: var(--strand-radius-lg);
|
|
6
6
|
background: var(--strand-surface-elevated);
|
|
7
7
|
font-family: var(--strand-font-sans);
|
|
8
|
+
overflow: hidden;
|
|
9
|
+
box-sizing: border-box;
|
|
10
|
+
max-width: 100%;
|
|
8
11
|
}
|
|
9
12
|
|
|
10
13
|
/* ── Variants ── */
|
|
@@ -36,15 +39,21 @@
|
|
|
36
39
|
}
|
|
37
40
|
|
|
38
41
|
.strand-card--pad-sm {
|
|
39
|
-
padding: var(--strand-space-
|
|
42
|
+
padding: var(--strand-space-4);
|
|
40
43
|
}
|
|
41
44
|
|
|
42
45
|
.strand-card--pad-md {
|
|
43
|
-
padding: var(--strand-space-
|
|
46
|
+
padding: var(--strand-space-6);
|
|
44
47
|
}
|
|
45
48
|
|
|
46
49
|
.strand-card--pad-lg {
|
|
47
|
-
padding: var(--strand-space-
|
|
50
|
+
padding: var(--strand-space-10);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/* ── Focus ring (interactive cards) ── */
|
|
54
|
+
.strand-card--interactive:focus-visible {
|
|
55
|
+
outline: 2px solid var(--strand-blue-primary);
|
|
56
|
+
outline-offset: 2px;
|
|
48
57
|
}
|
|
49
58
|
|
|
50
59
|
/* ── Reduced motion ── */
|
|
@@ -39,9 +39,9 @@
|
|
|
39
39
|
color: var(--strand-on-blue-primary);
|
|
40
40
|
flex-shrink: 0;
|
|
41
41
|
transition:
|
|
42
|
-
background var(--strand-duration-fast) ease,
|
|
43
|
-
border-color var(--strand-duration-fast) ease,
|
|
44
|
-
box-shadow var(--strand-duration-fast) ease;
|
|
42
|
+
background var(--strand-duration-fast) var(--strand-ease-out-quart),
|
|
43
|
+
border-color var(--strand-duration-fast) var(--strand-ease-out-quart),
|
|
44
|
+
box-shadow var(--strand-duration-fast) var(--strand-ease-out-quart);
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
.strand-checkbox__icon {
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
/* ── Focus ring ── */
|
|
53
53
|
.strand-checkbox__native:focus-visible ~ .strand-checkbox__control {
|
|
54
54
|
border-color: var(--strand-blue-primary);
|
|
55
|
-
box-shadow:
|
|
55
|
+
box-shadow: var(--strand-focus-ring);
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
/* ── Checked ── */
|
|
@@ -28,3 +28,12 @@
|
|
|
28
28
|
line-height: var(--strand-leading-tight);
|
|
29
29
|
font-variant-numeric: tabular-nums;
|
|
30
30
|
}
|
|
31
|
+
|
|
32
|
+
/* ── Size variants ── */
|
|
33
|
+
.strand-data-readout--sm .strand-data-readout__value {
|
|
34
|
+
font-size: var(--strand-text-xl);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.strand-data-readout--lg .strand-data-readout__value {
|
|
38
|
+
font-size: var(--strand-text-4xl);
|
|
39
|
+
}
|