@dillingerstaffing/strand-ui 0.5.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/HTML_REFERENCE.md +79 -3
- package/README.md +1 -1
- package/dist/components/Alert/Alert.d.ts.map +1 -1
- package/dist/components/InstrumentViewport/InstrumentViewport.d.ts +10 -0
- package/dist/components/InstrumentViewport/InstrumentViewport.d.ts.map +1 -0
- package/dist/components/InstrumentViewport/index.d.ts +3 -0
- package/dist/components/InstrumentViewport/index.d.ts.map +1 -0
- package/dist/components/ScrollReveal/ScrollReveal.d.ts +12 -0
- package/dist/components/ScrollReveal/ScrollReveal.d.ts.map +1 -0
- package/dist/components/ScrollReveal/index.d.ts +3 -0
- package/dist/components/ScrollReveal/index.d.ts.map +1 -0
- package/dist/components/Toast/Toast.d.ts.map +1 -1
- package/dist/css/strand-ui.css +145 -54
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +578 -534
- package/package.json +2 -2
- package/src/__tests__/build-output.test.ts +1 -0
- package/src/components/Alert/Alert.css +24 -13
- package/src/components/Alert/Alert.test.tsx +30 -0
- package/src/components/Alert/Alert.tsx +4 -0
- package/src/components/Avatar/Avatar.css +2 -2
- package/src/components/Button/Button.css +4 -4
- package/src/components/Checkbox/Checkbox.css +3 -2
- package/src/components/CodeBlock/CodeBlock.css +1 -1
- package/src/components/FormField/FormField.css +5 -3
- package/src/components/InstrumentViewport/InstrumentViewport.css +36 -0
- package/src/components/InstrumentViewport/InstrumentViewport.test.tsx +70 -0
- package/src/components/InstrumentViewport/InstrumentViewport.tsx +31 -0
- package/src/components/InstrumentViewport/index.ts +2 -0
- package/src/components/Nav/Nav.css +2 -2
- package/src/components/Radio/Radio.css +3 -2
- package/src/components/ScrollReveal/ScrollReveal.css +29 -0
- package/src/components/ScrollReveal/ScrollReveal.test.tsx +68 -0
- package/src/components/ScrollReveal/ScrollReveal.tsx +64 -0
- package/src/components/ScrollReveal/index.ts +2 -0
- package/src/components/Slider/Slider.css +10 -4
- package/src/components/Switch/Switch.css +1 -0
- package/src/components/Tabs/Tabs.css +1 -1
- package/src/components/Tag/Tag.css +3 -3
- package/src/components/Toast/Toast.css +15 -17
- package/src/components/Toast/Toast.test.tsx +28 -0
- package/src/components/Toast/Toast.tsx +8 -0
- package/src/index.ts +8 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dillingerstaffing/strand-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
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",
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
}
|
|
61
61
|
},
|
|
62
62
|
"dependencies": {
|
|
63
|
-
"@dillingerstaffing/strand": "^0.
|
|
63
|
+
"@dillingerstaffing/strand": "^0.7.0"
|
|
64
64
|
},
|
|
65
65
|
"devDependencies": {
|
|
66
66
|
"@testing-library/preact": "^3.2.0",
|
|
@@ -44,6 +44,7 @@ describe("Build output", () => {
|
|
|
44
44
|
".strand-nav", ".strand-toast", ".strand-alert",
|
|
45
45
|
".strand-dialog", ".strand-tooltip", ".strand-progress",
|
|
46
46
|
".strand-spinner", ".strand-skeleton",
|
|
47
|
+
".strand-instrument-viewport", ".strand-reveal",
|
|
47
48
|
];
|
|
48
49
|
for (const cls of expectedClasses) {
|
|
49
50
|
expect(css, `Missing CSS class: ${cls}`).toContain(cls);
|
|
@@ -8,34 +8,45 @@
|
|
|
8
8
|
justify-content: space-between;
|
|
9
9
|
width: 100%;
|
|
10
10
|
padding: var(--strand-space-6);
|
|
11
|
-
padding-left: var(--strand-space-5);
|
|
12
11
|
border-radius: var(--strand-radius-md);
|
|
13
|
-
border-left: 4px solid transparent;
|
|
14
12
|
font-family: var(--strand-font-sans);
|
|
15
13
|
font-size: var(--strand-text-sm);
|
|
14
|
+
background: var(--strand-surface-recessed);
|
|
16
15
|
}
|
|
17
16
|
|
|
18
|
-
/* ── Status variants ── */
|
|
17
|
+
/* ── Status variants (neutral background, status color on prefix only) ── */
|
|
19
18
|
.strand-alert--info {
|
|
20
|
-
background: var(--strand-
|
|
21
|
-
border-left-color: var(--strand-blue-primary);
|
|
19
|
+
background: var(--strand-surface-recessed);
|
|
22
20
|
}
|
|
23
21
|
|
|
24
22
|
.strand-alert--success {
|
|
25
|
-
background:
|
|
26
|
-
border-left-color: var(--strand-green-positive);
|
|
23
|
+
background: var(--strand-surface-recessed);
|
|
27
24
|
}
|
|
28
25
|
|
|
29
26
|
.strand-alert--warning {
|
|
30
|
-
background:
|
|
31
|
-
border-left-color: var(--strand-amber-caution);
|
|
27
|
+
background: var(--strand-surface-recessed);
|
|
32
28
|
}
|
|
33
29
|
|
|
34
30
|
.strand-alert--error {
|
|
35
|
-
background:
|
|
36
|
-
border-left-color: var(--strand-red-alert);
|
|
31
|
+
background: var(--strand-surface-recessed);
|
|
37
32
|
}
|
|
38
33
|
|
|
34
|
+
/* ── Status prefix ── */
|
|
35
|
+
.strand-alert__status {
|
|
36
|
+
font-family: var(--strand-font-mono);
|
|
37
|
+
font-size: var(--strand-text-xs);
|
|
38
|
+
font-weight: var(--strand-weight-semibold);
|
|
39
|
+
letter-spacing: var(--strand-tracking-wider);
|
|
40
|
+
text-transform: uppercase;
|
|
41
|
+
margin-right: var(--strand-space-3);
|
|
42
|
+
flex-shrink: 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.strand-alert--info .strand-alert__status { color: var(--strand-blue-primary); }
|
|
46
|
+
.strand-alert--success .strand-alert__status { color: var(--strand-teal-vital); }
|
|
47
|
+
.strand-alert--warning .strand-alert__status { color: var(--strand-amber-caution); }
|
|
48
|
+
.strand-alert--error .strand-alert__status { color: var(--strand-red-alert); }
|
|
49
|
+
|
|
39
50
|
/* ── Content ── */
|
|
40
51
|
.strand-alert__content {
|
|
41
52
|
flex: 1;
|
|
@@ -48,8 +59,8 @@
|
|
|
48
59
|
display: inline-flex;
|
|
49
60
|
align-items: center;
|
|
50
61
|
justify-content: center;
|
|
51
|
-
width:
|
|
52
|
-
height:
|
|
62
|
+
width: var(--strand-space-6);
|
|
63
|
+
height: var(--strand-space-6);
|
|
53
64
|
margin-left: var(--strand-space-4);
|
|
54
65
|
padding: 0;
|
|
55
66
|
border: none;
|
|
@@ -79,6 +79,36 @@ describe("Alert", () => {
|
|
|
79
79
|
expect(queryByLabelText("Dismiss")).toBeNull();
|
|
80
80
|
});
|
|
81
81
|
|
|
82
|
+
// ── Status prefix ──
|
|
83
|
+
|
|
84
|
+
it("renders status prefix for info", () => {
|
|
85
|
+
const { container } = render(<Alert status="info">Info</Alert>);
|
|
86
|
+
const status = container.querySelector(".strand-alert__status");
|
|
87
|
+
expect(status).toBeTruthy();
|
|
88
|
+
expect(status!.textContent).toBe("INFO");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("renders status prefix for success as COMPLETE", () => {
|
|
92
|
+
const { container } = render(<Alert status="success">OK</Alert>);
|
|
93
|
+
const status = container.querySelector(".strand-alert__status");
|
|
94
|
+
expect(status).toBeTruthy();
|
|
95
|
+
expect(status!.textContent).toBe("COMPLETE");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("renders status prefix for warning", () => {
|
|
99
|
+
const { container } = render(<Alert status="warning">Warn</Alert>);
|
|
100
|
+
const status = container.querySelector(".strand-alert__status");
|
|
101
|
+
expect(status).toBeTruthy();
|
|
102
|
+
expect(status!.textContent).toBe("WARNING");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("renders status prefix for error", () => {
|
|
106
|
+
const { container } = render(<Alert status="error">Fail</Alert>);
|
|
107
|
+
const status = container.querySelector(".strand-alert__status");
|
|
108
|
+
expect(status).toBeTruthy();
|
|
109
|
+
expect(status!.textContent).toBe("ERROR");
|
|
110
|
+
});
|
|
111
|
+
|
|
82
112
|
// ── Custom className ──
|
|
83
113
|
|
|
84
114
|
it("merges custom className", () => {
|
|
@@ -38,8 +38,12 @@ export const Alert = forwardRef<HTMLDivElement, AlertProps>(
|
|
|
38
38
|
.filter(Boolean)
|
|
39
39
|
.join(" ");
|
|
40
40
|
|
|
41
|
+
const statusLabel =
|
|
42
|
+
status === "success" ? "COMPLETE" : status.toUpperCase();
|
|
43
|
+
|
|
41
44
|
return (
|
|
42
45
|
<div ref={ref} className={classes} role={role} {...rest}>
|
|
46
|
+
<span className="strand-alert__status">{statusLabel}</span>
|
|
43
47
|
<div className="strand-alert__content">{children}</div>
|
|
44
48
|
{dismissible && (
|
|
45
49
|
<button
|
|
@@ -37,13 +37,13 @@
|
|
|
37
37
|
.strand-btn--sm {
|
|
38
38
|
padding: var(--strand-space-1) var(--strand-space-3);
|
|
39
39
|
font-size: var(--strand-text-sm);
|
|
40
|
-
min-height:
|
|
40
|
+
min-height: var(--strand-touch-target);
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
.strand-btn--md {
|
|
44
44
|
padding: calc(var(--strand-space-2) + var(--strand-space-1) / 2) var(--strand-space-5);
|
|
45
45
|
font-size: var(--strand-text-sm);
|
|
46
|
-
min-height:
|
|
46
|
+
min-height: var(--strand-touch-target);
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
.strand-btn--lg {
|
|
@@ -55,12 +55,12 @@
|
|
|
55
55
|
/* ── Icon-only ── */
|
|
56
56
|
.strand-btn--icon-only.strand-btn--sm {
|
|
57
57
|
padding: var(--strand-space-1);
|
|
58
|
-
min-width:
|
|
58
|
+
min-width: var(--strand-touch-target);
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
.strand-btn--icon-only.strand-btn--md {
|
|
62
62
|
padding: var(--strand-space-2);
|
|
63
|
-
min-width:
|
|
63
|
+
min-width: var(--strand-touch-target);
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
.strand-btn--icon-only.strand-btn--lg {
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
display: inline-flex;
|
|
6
6
|
align-items: center;
|
|
7
7
|
gap: var(--strand-space-2);
|
|
8
|
+
min-height: var(--strand-touch-target);
|
|
8
9
|
cursor: pointer;
|
|
9
10
|
user-select: none;
|
|
10
11
|
font-family: var(--strand-font-sans);
|
|
@@ -31,8 +32,8 @@
|
|
|
31
32
|
display: flex;
|
|
32
33
|
align-items: center;
|
|
33
34
|
justify-content: center;
|
|
34
|
-
width:
|
|
35
|
-
height:
|
|
35
|
+
width: var(--strand-control-size);
|
|
36
|
+
height: var(--strand-control-size);
|
|
36
37
|
border: 1px solid var(--strand-gray-200);
|
|
37
38
|
border-radius: var(--strand-radius-sm);
|
|
38
39
|
background: var(--strand-surface-elevated);
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
line-height: var(--strand-leading-relaxed);
|
|
25
25
|
color: var(--strand-blue-midnight);
|
|
26
26
|
background: var(--strand-surface-recessed);
|
|
27
|
-
box-shadow: inset 0 1px 3px rgba(15, 23, 42, 0.06);
|
|
27
|
+
box-shadow: inset 0 1px 3px rgba(15, 23, 42, 0.06); /* strand-blue-abyss at 6% opacity */
|
|
28
28
|
border-radius: var(--strand-radius-lg);
|
|
29
29
|
padding: var(--strand-space-3) var(--strand-space-4);
|
|
30
30
|
overflow-x: auto;
|
|
@@ -9,10 +9,12 @@
|
|
|
9
9
|
|
|
10
10
|
/* ── Label ── */
|
|
11
11
|
.strand-form-field__label {
|
|
12
|
-
font-family: var(--strand-font-
|
|
13
|
-
font-size: var(--strand-text-
|
|
12
|
+
font-family: var(--strand-font-mono);
|
|
13
|
+
font-size: var(--strand-text-xs);
|
|
14
14
|
font-weight: var(--strand-weight-medium);
|
|
15
|
-
|
|
15
|
+
letter-spacing: var(--strand-tracking-widest);
|
|
16
|
+
text-transform: uppercase;
|
|
17
|
+
color: var(--strand-gray-500);
|
|
16
18
|
line-height: var(--strand-leading-snug);
|
|
17
19
|
}
|
|
18
20
|
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/*! Strand UI | MIT License | dillingerstaffing.com */
|
|
2
|
+
|
|
3
|
+
.strand-instrument-viewport {
|
|
4
|
+
background: var(--strand-blue-abyss);
|
|
5
|
+
color: var(--strand-gray-100);
|
|
6
|
+
border-radius: var(--strand-radius-lg);
|
|
7
|
+
overflow: hidden;
|
|
8
|
+
position: relative;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.strand-instrument-viewport__label {
|
|
12
|
+
color: var(--strand-gray-400);
|
|
13
|
+
font-family: var(--strand-font-mono);
|
|
14
|
+
font-size: var(--strand-text-xs);
|
|
15
|
+
font-weight: var(--strand-weight-medium);
|
|
16
|
+
letter-spacing: var(--strand-tracking-widest);
|
|
17
|
+
text-transform: uppercase;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.strand-instrument-viewport__value {
|
|
21
|
+
color: var(--strand-on-blue-primary);
|
|
22
|
+
font-family: var(--strand-font-mono);
|
|
23
|
+
font-variant-numeric: tabular-nums;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/* Optional grid overlay */
|
|
27
|
+
.strand-instrument-viewport--grid::before {
|
|
28
|
+
content: '';
|
|
29
|
+
position: absolute;
|
|
30
|
+
inset: 0;
|
|
31
|
+
background:
|
|
32
|
+
linear-gradient(rgba(59, 142, 246, 0.04) 1px, transparent 1px),
|
|
33
|
+
linear-gradient(90deg, rgba(59, 142, 246, 0.04) 1px, transparent 1px);
|
|
34
|
+
background-size: var(--strand-viewport-grid-size) var(--strand-viewport-grid-size);
|
|
35
|
+
pointer-events: none;
|
|
36
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { render } from "@testing-library/preact";
|
|
3
|
+
import { InstrumentViewport } from "./InstrumentViewport.js";
|
|
4
|
+
|
|
5
|
+
describe("InstrumentViewport", () => {
|
|
6
|
+
// ── Rendering ──
|
|
7
|
+
|
|
8
|
+
it("renders a div element", () => {
|
|
9
|
+
const { container } = render(
|
|
10
|
+
<InstrumentViewport>Content</InstrumentViewport>,
|
|
11
|
+
);
|
|
12
|
+
const el = container.firstElementChild;
|
|
13
|
+
expect(el?.tagName).toBe("DIV");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("applies base class", () => {
|
|
17
|
+
const { container } = render(
|
|
18
|
+
<InstrumentViewport>Content</InstrumentViewport>,
|
|
19
|
+
);
|
|
20
|
+
const el = container.firstElementChild;
|
|
21
|
+
expect(el?.className).toContain("strand-instrument-viewport");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("renders children", () => {
|
|
25
|
+
const { getByText } = render(
|
|
26
|
+
<InstrumentViewport>Hello viewport</InstrumentViewport>,
|
|
27
|
+
);
|
|
28
|
+
expect(getByText("Hello viewport")).toBeTruthy();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// ── Grid modifier ──
|
|
32
|
+
|
|
33
|
+
it("does not apply grid class by default", () => {
|
|
34
|
+
const { container } = render(
|
|
35
|
+
<InstrumentViewport>Content</InstrumentViewport>,
|
|
36
|
+
);
|
|
37
|
+
const el = container.firstElementChild;
|
|
38
|
+
expect(el?.className).not.toContain("strand-instrument-viewport--grid");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("applies grid modifier class when grid prop is true", () => {
|
|
42
|
+
const { container } = render(
|
|
43
|
+
<InstrumentViewport grid>Content</InstrumentViewport>,
|
|
44
|
+
);
|
|
45
|
+
const el = container.firstElementChild;
|
|
46
|
+
expect(el?.className).toContain("strand-instrument-viewport--grid");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// ── Custom className ──
|
|
50
|
+
|
|
51
|
+
it("merges custom className with component classes", () => {
|
|
52
|
+
const { container } = render(
|
|
53
|
+
<InstrumentViewport className="custom">Content</InstrumentViewport>,
|
|
54
|
+
);
|
|
55
|
+
const el = container.firstElementChild;
|
|
56
|
+
expect(el?.className).toContain("strand-instrument-viewport");
|
|
57
|
+
expect(el?.className).toContain("custom");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// ── Props forwarding ──
|
|
61
|
+
|
|
62
|
+
it("forwards additional props", () => {
|
|
63
|
+
const { container } = render(
|
|
64
|
+
<InstrumentViewport id="vp-1" data-testid="viewport">
|
|
65
|
+
Content
|
|
66
|
+
</InstrumentViewport>,
|
|
67
|
+
);
|
|
68
|
+
expect(container.firstElementChild?.getAttribute("id")).toBe("vp-1");
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/*! Strand UI | MIT License | dillingerstaffing.com */
|
|
2
|
+
|
|
3
|
+
import type { JSX } from "preact";
|
|
4
|
+
import { forwardRef } from "preact/compat";
|
|
5
|
+
|
|
6
|
+
export interface InstrumentViewportProps
|
|
7
|
+
extends JSX.HTMLAttributes<HTMLDivElement> {
|
|
8
|
+
/** Show subtle grid overlay */
|
|
9
|
+
grid?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const InstrumentViewport = forwardRef<
|
|
13
|
+
HTMLDivElement,
|
|
14
|
+
InstrumentViewportProps
|
|
15
|
+
>(({ grid = false, className = "", children, ...rest }, ref) => {
|
|
16
|
+
const classes = [
|
|
17
|
+
"strand-instrument-viewport",
|
|
18
|
+
grid ? "strand-instrument-viewport--grid" : "",
|
|
19
|
+
className,
|
|
20
|
+
]
|
|
21
|
+
.filter(Boolean)
|
|
22
|
+
.join(" ");
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div ref={ref} className={classes} {...rest}>
|
|
26
|
+
{children}
|
|
27
|
+
</div>
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
InstrumentViewport.displayName = "InstrumentViewport";
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
.strand-nav {
|
|
5
5
|
position: relative;
|
|
6
6
|
width: 100%;
|
|
7
|
-
height:
|
|
7
|
+
height: var(--strand-nav-height);
|
|
8
8
|
background: var(--strand-surface-elevated);
|
|
9
9
|
border-bottom: 1px solid var(--strand-gray-200);
|
|
10
10
|
font-family: var(--strand-font-sans);
|
|
@@ -165,7 +165,7 @@
|
|
|
165
165
|
|
|
166
166
|
.strand-nav {
|
|
167
167
|
height: auto;
|
|
168
|
-
min-height:
|
|
168
|
+
min-height: var(--strand-nav-height);
|
|
169
169
|
}
|
|
170
170
|
}
|
|
171
171
|
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
display: inline-flex;
|
|
6
6
|
align-items: center;
|
|
7
7
|
gap: var(--strand-space-2);
|
|
8
|
+
min-height: var(--strand-touch-target);
|
|
8
9
|
cursor: pointer;
|
|
9
10
|
user-select: none;
|
|
10
11
|
font-family: var(--strand-font-sans);
|
|
@@ -31,8 +32,8 @@
|
|
|
31
32
|
display: flex;
|
|
32
33
|
align-items: center;
|
|
33
34
|
justify-content: center;
|
|
34
|
-
width:
|
|
35
|
-
height:
|
|
35
|
+
width: var(--strand-control-size);
|
|
36
|
+
height: var(--strand-control-size);
|
|
36
37
|
border: 1px solid var(--strand-gray-200);
|
|
37
38
|
border-radius: var(--strand-radius-full);
|
|
38
39
|
background: var(--strand-surface-elevated);
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/*! Strand UI | MIT License | dillingerstaffing.com */
|
|
2
|
+
|
|
3
|
+
.strand-reveal {
|
|
4
|
+
opacity: 0;
|
|
5
|
+
transform: translateY(24px);
|
|
6
|
+
transition: opacity var(--strand-duration-glacial) var(--strand-ease-out-expo),
|
|
7
|
+
transform var(--strand-duration-glacial) var(--strand-ease-out-expo);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.strand-reveal--visible {
|
|
11
|
+
opacity: 1;
|
|
12
|
+
transform: translateY(0);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/* Stagger children */
|
|
16
|
+
.strand-reveal-group > .strand-reveal:nth-child(1) { transition-delay: 0ms; }
|
|
17
|
+
.strand-reveal-group > .strand-reveal:nth-child(2) { transition-delay: var(--strand-stagger-delay); }
|
|
18
|
+
.strand-reveal-group > .strand-reveal:nth-child(3) { transition-delay: calc(var(--strand-stagger-delay) * 2); }
|
|
19
|
+
.strand-reveal-group > .strand-reveal:nth-child(4) { transition-delay: calc(var(--strand-stagger-delay) * 3); }
|
|
20
|
+
.strand-reveal-group > .strand-reveal:nth-child(5) { transition-delay: calc(var(--strand-stagger-delay) * 4); }
|
|
21
|
+
.strand-reveal-group > .strand-reveal:nth-child(6) { transition-delay: var(--strand-duration-slow); }
|
|
22
|
+
|
|
23
|
+
@media (prefers-reduced-motion: reduce) {
|
|
24
|
+
.strand-reveal {
|
|
25
|
+
opacity: 1;
|
|
26
|
+
transform: none;
|
|
27
|
+
transition: none;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { render } from "@testing-library/preact";
|
|
3
|
+
import { ScrollReveal } from "./ScrollReveal.js";
|
|
4
|
+
|
|
5
|
+
describe("ScrollReveal", () => {
|
|
6
|
+
// ── Rendering ──
|
|
7
|
+
|
|
8
|
+
it("renders a div element", () => {
|
|
9
|
+
const { container } = render(<ScrollReveal>Content</ScrollReveal>);
|
|
10
|
+
const el = container.firstElementChild;
|
|
11
|
+
expect(el?.tagName).toBe("DIV");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("applies strand-reveal class", () => {
|
|
15
|
+
const { container } = render(<ScrollReveal>Content</ScrollReveal>);
|
|
16
|
+
const el = container.firstElementChild;
|
|
17
|
+
expect(el?.className).toContain("strand-reveal");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("renders children", () => {
|
|
21
|
+
const { getByText } = render(<ScrollReveal>Hello reveal</ScrollReveal>);
|
|
22
|
+
expect(getByText("Hello reveal")).toBeTruthy();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("does not apply visible class initially", () => {
|
|
26
|
+
const { container } = render(<ScrollReveal>Content</ScrollReveal>);
|
|
27
|
+
const el = container.firstElementChild;
|
|
28
|
+
expect(el?.className).not.toContain("strand-reveal--visible");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// ── Custom className ──
|
|
32
|
+
|
|
33
|
+
it("merges custom className with component classes", () => {
|
|
34
|
+
const { container } = render(
|
|
35
|
+
<ScrollReveal className="custom">Content</ScrollReveal>,
|
|
36
|
+
);
|
|
37
|
+
const el = container.firstElementChild;
|
|
38
|
+
expect(el?.className).toContain("strand-reveal");
|
|
39
|
+
expect(el?.className).toContain("custom");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// ── Props forwarding ──
|
|
43
|
+
|
|
44
|
+
it("forwards additional props", () => {
|
|
45
|
+
const { container } = render(
|
|
46
|
+
<ScrollReveal id="reveal-1" data-testid="reveal">
|
|
47
|
+
Content
|
|
48
|
+
</ScrollReveal>,
|
|
49
|
+
);
|
|
50
|
+
expect(container.firstElementChild?.getAttribute("id")).toBe("reveal-1");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// ── Props acceptance ──
|
|
54
|
+
|
|
55
|
+
it("accepts threshold prop without error", () => {
|
|
56
|
+
const { container } = render(
|
|
57
|
+
<ScrollReveal threshold={0.5}>Content</ScrollReveal>,
|
|
58
|
+
);
|
|
59
|
+
expect(container.firstElementChild).toBeTruthy();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("accepts once prop without error", () => {
|
|
63
|
+
const { container } = render(
|
|
64
|
+
<ScrollReveal once={false}>Content</ScrollReveal>,
|
|
65
|
+
);
|
|
66
|
+
expect(container.firstElementChild).toBeTruthy();
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/*! Strand UI | MIT License | dillingerstaffing.com */
|
|
2
|
+
|
|
3
|
+
import type { JSX } from "preact";
|
|
4
|
+
import { useEffect, useRef } from "preact/hooks";
|
|
5
|
+
import { forwardRef } from "preact/compat";
|
|
6
|
+
|
|
7
|
+
export interface ScrollRevealProps
|
|
8
|
+
extends JSX.HTMLAttributes<HTMLDivElement> {
|
|
9
|
+
/** IntersectionObserver visibility threshold (0-1) */
|
|
10
|
+
threshold?: number;
|
|
11
|
+
/** Only trigger reveal once */
|
|
12
|
+
once?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const ScrollReveal = forwardRef<HTMLDivElement, ScrollRevealProps>(
|
|
16
|
+
({ threshold = 0.1, once = true, className = "", children, ...rest }, ref) => {
|
|
17
|
+
const innerRef = useRef<HTMLDivElement>(null);
|
|
18
|
+
|
|
19
|
+
const classes = ["strand-reveal", className].filter(Boolean).join(" ");
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
const el = innerRef.current;
|
|
23
|
+
if (!el || typeof IntersectionObserver === "undefined") return;
|
|
24
|
+
|
|
25
|
+
const observer = new IntersectionObserver(
|
|
26
|
+
(entries) => {
|
|
27
|
+
for (const entry of entries) {
|
|
28
|
+
if (entry.isIntersecting) {
|
|
29
|
+
entry.target.classList.add("strand-reveal--visible");
|
|
30
|
+
if (once) {
|
|
31
|
+
observer.unobserve(entry.target);
|
|
32
|
+
}
|
|
33
|
+
} else if (!once) {
|
|
34
|
+
entry.target.classList.remove("strand-reveal--visible");
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
{ threshold },
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
observer.observe(el);
|
|
42
|
+
|
|
43
|
+
return () => {
|
|
44
|
+
observer.disconnect();
|
|
45
|
+
};
|
|
46
|
+
}, [threshold, once]);
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<div
|
|
50
|
+
ref={(node) => {
|
|
51
|
+
(innerRef as { current: HTMLDivElement | null }).current = node;
|
|
52
|
+
if (typeof ref === "function") ref(node);
|
|
53
|
+
else if (ref) (ref as { current: HTMLDivElement | null }).current = node;
|
|
54
|
+
}}
|
|
55
|
+
className={classes}
|
|
56
|
+
{...rest}
|
|
57
|
+
>
|
|
58
|
+
{children}
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
},
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
ScrollReveal.displayName = "ScrollReveal";
|
|
@@ -20,14 +20,15 @@
|
|
|
20
20
|
transition: background var(--strand-duration-fast) var(--strand-ease-out-quart);
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
/* ── Thumb: Webkit ── */
|
|
23
|
+
/* ── Thumb: Webkit (44px touch target, 20px visual) ── */
|
|
24
24
|
.strand-slider__field::-webkit-slider-thumb {
|
|
25
25
|
appearance: none;
|
|
26
26
|
width: 20px;
|
|
27
27
|
height: 20px;
|
|
28
28
|
background: var(--strand-blue-primary);
|
|
29
|
-
border:
|
|
29
|
+
border: 12px solid transparent;
|
|
30
30
|
border-radius: var(--strand-radius-full);
|
|
31
|
+
background-clip: padding-box;
|
|
31
32
|
cursor: pointer;
|
|
32
33
|
box-shadow: var(--strand-elevation-1);
|
|
33
34
|
transition:
|
|
@@ -37,21 +38,24 @@
|
|
|
37
38
|
|
|
38
39
|
.strand-slider__field:hover:not(:disabled)::-webkit-slider-thumb {
|
|
39
40
|
background: var(--strand-blue-vivid);
|
|
41
|
+
background-clip: padding-box;
|
|
40
42
|
transform: scale(1.15);
|
|
41
43
|
}
|
|
42
44
|
|
|
43
45
|
.strand-slider__field:active:not(:disabled)::-webkit-slider-thumb {
|
|
44
46
|
background: var(--strand-blue-deep);
|
|
47
|
+
background-clip: padding-box;
|
|
45
48
|
transform: scale(1.05);
|
|
46
49
|
}
|
|
47
50
|
|
|
48
|
-
/* ── Thumb: Firefox ── */
|
|
51
|
+
/* ── Thumb: Firefox (44px touch target, 20px visual) ── */
|
|
49
52
|
.strand-slider__field::-moz-range-thumb {
|
|
50
53
|
width: 20px;
|
|
51
54
|
height: 20px;
|
|
52
55
|
background: var(--strand-blue-primary);
|
|
53
|
-
border:
|
|
56
|
+
border: 12px solid transparent;
|
|
54
57
|
border-radius: var(--strand-radius-full);
|
|
58
|
+
background-clip: padding-box;
|
|
55
59
|
cursor: pointer;
|
|
56
60
|
box-shadow: var(--strand-elevation-1);
|
|
57
61
|
transition:
|
|
@@ -61,11 +65,13 @@
|
|
|
61
65
|
|
|
62
66
|
.strand-slider__field:hover:not(:disabled)::-moz-range-thumb {
|
|
63
67
|
background: var(--strand-blue-vivid);
|
|
68
|
+
background-clip: padding-box;
|
|
64
69
|
transform: scale(1.15);
|
|
65
70
|
}
|
|
66
71
|
|
|
67
72
|
.strand-slider__field:active:not(:disabled)::-moz-range-thumb {
|
|
68
73
|
background: var(--strand-blue-deep);
|
|
74
|
+
background-clip: padding-box;
|
|
69
75
|
transform: scale(1.05);
|
|
70
76
|
}
|
|
71
77
|
|