@volksverpetzer/ui-web 0.7.0 → 0.9.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/dist/Button.css CHANGED
@@ -9,6 +9,7 @@
9
9
  cursor: pointer;
10
10
  font-family: inherit;
11
11
  line-height: 1;
12
+ text-decoration: none;
12
13
  transition:
13
14
  background-color 0.15s ease,
14
15
  color 0.15s ease,
package/dist/Input.css CHANGED
@@ -1,19 +1,26 @@
1
+ /*
2
+ * radius/border-color/padding are pinned to whichever existing token lands
3
+ * closest to vvp_crowdfunding's donation-form input (the component's actual
4
+ * first real consumer, pre-dating this package): radius 14px sits exactly
5
+ * between md/lg, resolved to md since that's the scale's own "buttons and
6
+ * form controls" step; padding 14px/16px both round to lg; the border color
7
+ * (#d8e6ee) is closer to --vvp-surface than the likelier-sounding
8
+ * --vvp-surface-input by actual RGB distance (~16 vs ~32).
9
+ */
1
10
  .vvp-ui-input {
2
11
  display: block;
3
12
  box-sizing: border-box;
4
13
  width: 100%;
5
14
  min-width: 0;
6
- border-radius: var(--vvp-radius-sm, 8px);
7
- border: 1px solid var(--vvp-surface-input, #badde8);
15
+ border-radius: var(--vvp-radius-md, 12px);
16
+ border: 1px solid var(--vvp-surface, #e2f0f5);
8
17
  background: var(--vvp-background, #fff);
9
18
  color: var(--vvp-text, #111);
10
- padding: var(--vvp-spacing-sm, 8px) var(--vvp-spacing-md, 10px);
19
+ padding: var(--vvp-spacing-lg, 16px);
11
20
  font-family: inherit;
12
21
  font-size: var(--vvp-font-size-base, 16px);
13
22
  line-height: 1.4;
14
- transition:
15
- border-color 0.15s ease,
16
- box-shadow 0.15s ease;
23
+ transition: border-color 0.15s ease;
17
24
  }
18
25
 
19
26
  .vvp-ui-input::placeholder {
@@ -21,10 +28,8 @@
21
28
  }
22
29
 
23
30
  .vvp-ui-input:focus-visible {
24
- outline: none;
25
- border-color: var(--vvp-primary, #1b7194);
26
- box-shadow: 0 0 0 3px
27
- color-mix(in srgb, var(--vvp-primary, #1b7194) 25%, transparent);
31
+ outline: 2px solid var(--vvp-primary-muted, #3893c0);
32
+ outline-offset: 2px;
28
33
  }
29
34
 
30
35
  .vvp-ui-input:disabled {
@@ -43,6 +48,5 @@
43
48
  }
44
49
 
45
50
  .vvp-ui-input[aria-invalid="true"]:focus-visible {
46
- box-shadow: 0 0 0 3px
47
- color-mix(in srgb, var(--vvp-error, #c62828) 25%, transparent);
51
+ outline-color: var(--vvp-error, #c62828);
48
52
  }
@@ -0,0 +1,37 @@
1
+ /*
2
+ * LinkButton's fixed look: a subtle bordered "pill card" with a soft
3
+ * drop shadow, distinct from Button's ghost variant (see LinkButton.tsx
4
+ * for why). Colors are expressed via the same --vvp-* tokens Button.css
5
+ * uses, so this stays in sync with the shared palette automatically.
6
+ *
7
+ * The shadow comes from @volksverpetzer/design-tokens'
8
+ * --vvp-elevation-accent-* — the one brand-tinted exception to that
9
+ * package's otherwise-neutral elevation scale (see elevation-accent.json).
10
+ * The literal color-mix() here is only a fallback for apps that haven't
11
+ * loaded elevation-accent.css; it must stay in sync with that token.
12
+ */
13
+ .vvp-ui-linkbtn {
14
+ border-radius: var(--vvp-radius-md, 12px);
15
+ background: transparent;
16
+ color: var(--vvp-primary, #1b7194);
17
+ border: 1.5px solid var(--vvp-surface, #e2f0f5);
18
+ box-shadow: var(
19
+ --vvp-elevation-accent-rest,
20
+ 0px 8px 20px color-mix(in srgb, var(--vvp-primary-muted, #3893c0) 6%, transparent)
21
+ );
22
+ transition:
23
+ transform 120ms ease,
24
+ box-shadow 120ms ease,
25
+ border-color 120ms ease,
26
+ background 120ms ease;
27
+ }
28
+
29
+ .vvp-ui-linkbtn:hover {
30
+ transform: translateY(-1px);
31
+ border-color: var(--vvp-primary-muted, #3893c0);
32
+ background: var(--vvp-surface, #e2f0f5);
33
+ box-shadow: var(
34
+ --vvp-elevation-accent-hover,
35
+ 0px 8px 20px color-mix(in srgb, var(--vvp-primary-muted, #3893c0) 18%, transparent)
36
+ );
37
+ }
@@ -0,0 +1,21 @@
1
+ import type { AnchorHTMLAttributes, ReactNode } from "react";
2
+ import type { ButtonSize } from "./Button";
3
+ import "./Button.css";
4
+ import "./LinkButton.css";
5
+ export interface LinkButtonProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
6
+ href: string;
7
+ size?: ButtonSize;
8
+ children: ReactNode;
9
+ }
10
+ /**
11
+ * Anchor-based counterpart to `Button` — for navigation (external links,
12
+ * downloads), where an actual link is the correct element, not a button
13
+ * with an onClick that changes location.
14
+ *
15
+ * Unlike `Button`, this has no `variant` choice: a link is inherently a
16
+ * secondary/tertiary action, never a page's primary CTA, so it always
17
+ * renders the one bordered, low-emphasis look (see LinkButton.css) rather
18
+ * than reusing `Button`'s ghost variant directly — `Button`'s ghost shape
19
+ * is shared with `ThemeToggle`, which needs its own pill/circle geometry.
20
+ */
21
+ export declare const LinkButton: import("react").ForwardRefExoticComponent<LinkButtonProps & import("react").RefAttributes<HTMLAnchorElement>>;
@@ -0,0 +1,26 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { forwardRef } from "react";
3
+ import "./Button.css";
4
+ import "./LinkButton.css";
5
+ /**
6
+ * Anchor-based counterpart to `Button` — for navigation (external links,
7
+ * downloads), where an actual link is the correct element, not a button
8
+ * with an onClick that changes location.
9
+ *
10
+ * Unlike `Button`, this has no `variant` choice: a link is inherently a
11
+ * secondary/tertiary action, never a page's primary CTA, so it always
12
+ * renders the one bordered, low-emphasis look (see LinkButton.css) rather
13
+ * than reusing `Button`'s ghost variant directly — `Button`'s ghost shape
14
+ * is shared with `ThemeToggle`, which needs its own pill/circle geometry.
15
+ */
16
+ export const LinkButton = forwardRef(function LinkButton({ size = "md", className, children, ...rest }, ref) {
17
+ const classes = [
18
+ "vvp-ui-btn",
19
+ "vvp-ui-linkbtn",
20
+ `vvp-ui-btn--${size}`,
21
+ className,
22
+ ]
23
+ .filter(Boolean)
24
+ .join(" ");
25
+ return (_jsx("a", { ref: ref, className: classes, ...rest, children: children }));
26
+ });
@@ -0,0 +1,6 @@
1
+ .vvp-ui-theme-toggle {
2
+ position: fixed;
3
+ top: var(--vvp-spacing-lg, 16px);
4
+ right: var(--vvp-spacing-lg, 16px);
5
+ z-index: 50;
6
+ }
@@ -0,0 +1,9 @@
1
+ import "./ThemeToggle.css";
2
+ /**
3
+ * Fixed-corner light/dark toggle. Persists to localStorage, falls back to
4
+ * `prefers-color-scheme`, and flips the `dark` class on `<html>` — the same
5
+ * convention the `--vvp-*` tokens' `.dark` scope expects. Browser-only (no
6
+ * Divi/React Native use case), so it always renders — there's no server
7
+ * variant to opt out of.
8
+ */
9
+ export declare function ThemeToggle(): import("react").JSX.Element;
@@ -0,0 +1,75 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useEffect, useSyncExternalStore } from "react";
4
+ import { Button } from "./Button";
5
+ import "./ThemeToggle.css";
6
+ const STORAGE_KEY = "vvp-theme";
7
+ const listeners = new Set();
8
+ // Session fallback for when localStorage throws (private/sandboxed
9
+ // contexts): without it, setIsDark's write would fail silently and
10
+ // getIsDark would keep re-deriving the same value from matchMedia on every
11
+ // read, making the toggle look inert even though the click registered.
12
+ let memoryIsDark = null;
13
+ function getIsDark() {
14
+ if (memoryIsDark !== null)
15
+ return memoryIsDark;
16
+ try {
17
+ const stored = localStorage.getItem(STORAGE_KEY);
18
+ if (stored)
19
+ return stored === "dark";
20
+ }
21
+ catch {
22
+ // localStorage can throw (e.g. blocked storage in private/sandboxed
23
+ // contexts) — fall back to OS preference below.
24
+ }
25
+ return window.matchMedia("(prefers-color-scheme: dark)").matches;
26
+ }
27
+ // The server can't know the stored/OS preference, so it always reports
28
+ // light. useSyncExternalStore uses this consistently during hydration
29
+ // (matching the server-rendered markup exactly), then re-renders with the
30
+ // real client value right after — no hydration mismatch, unlike reading
31
+ // localStorage in a mount effect.
32
+ function getServerIsDark() {
33
+ return false;
34
+ }
35
+ function subscribe(callback) {
36
+ listeners.add(callback);
37
+ const media = window.matchMedia("(prefers-color-scheme: dark)");
38
+ media.addEventListener("change", callback);
39
+ return () => {
40
+ listeners.delete(callback);
41
+ media.removeEventListener("change", callback);
42
+ };
43
+ }
44
+ function setIsDark(next) {
45
+ memoryIsDark = next;
46
+ try {
47
+ localStorage.setItem(STORAGE_KEY, next ? "dark" : "light");
48
+ }
49
+ catch {
50
+ // Storage unavailable — the toggle still works for this session via
51
+ // memoryIsDark above, it just won't persist across reloads.
52
+ }
53
+ listeners.forEach((listener) => listener());
54
+ }
55
+ function applyTheme(dark) {
56
+ document.documentElement.classList.toggle("dark", dark);
57
+ }
58
+ const SunIcon = () => (_jsxs("svg", { xmlns: "http://www.w3.org/2000/svg", width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: [_jsx("circle", { cx: "12", cy: "12", r: "4" }), _jsx("path", { d: "M12 2v2" }), _jsx("path", { d: "M12 20v2" }), _jsx("path", { d: "m4.93 4.93 1.41 1.41" }), _jsx("path", { d: "m17.66 17.66 1.41 1.41" }), _jsx("path", { d: "M2 12h2" }), _jsx("path", { d: "M20 12h2" }), _jsx("path", { d: "m6.34 17.66-1.41 1.41" }), _jsx("path", { d: "m19.07 4.93-1.41 1.41" })] }));
59
+ const MoonIcon = () => (_jsx("svg", { xmlns: "http://www.w3.org/2000/svg", width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: _jsx("path", { d: "M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" }) }));
60
+ /**
61
+ * Fixed-corner light/dark toggle. Persists to localStorage, falls back to
62
+ * `prefers-color-scheme`, and flips the `dark` class on `<html>` — the same
63
+ * convention the `--vvp-*` tokens' `.dark` scope expects. Browser-only (no
64
+ * Divi/React Native use case), so it always renders — there's no server
65
+ * variant to opt out of.
66
+ */
67
+ export function ThemeToggle() {
68
+ const isDark = useSyncExternalStore(subscribe, getIsDark, getServerIsDark);
69
+ // Only synchronizes the DOM with the resolved value — never calls
70
+ // setState, so it can't trigger cascading renders.
71
+ useEffect(() => {
72
+ applyTheme(isDark);
73
+ }, [isDark]);
74
+ return (_jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => setIsDark(!isDark), "aria-label": isDark ? "Switch to light mode" : "Switch to dark mode", className: "vvp-ui-theme-toggle", children: isDark ? _jsx(SunIcon, {}) : _jsx(MoonIcon, {}) }));
75
+ }
package/dist/index.d.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  export { Button, type ButtonProps, type ButtonVariant, type ButtonSize, } from "./Button";
2
+ export { LinkButton, type LinkButtonProps } from "./LinkButton";
2
3
  export { Badge, type BadgeProps, type BadgeVariant } from "./Badge";
3
4
  export { Card, type CardProps } from "./Card";
4
5
  export { ProgressBar, type ProgressBarProps, type ProgressBarMilestone, } from "./ProgressBar";
5
6
  export { Input, type InputProps } from "./Input";
6
7
  export { Alert, type AlertProps, type AlertVariant } from "./Alert";
7
8
  export { Slider, type SliderProps } from "./Slider";
9
+ export { ThemeToggle } from "./ThemeToggle";
package/dist/index.js CHANGED
@@ -1,7 +1,9 @@
1
1
  export { Button, } from "./Button";
2
+ export { LinkButton } from "./LinkButton";
2
3
  export { Badge } from "./Badge";
3
4
  export { Card } from "./Card";
4
5
  export { ProgressBar, } from "./ProgressBar";
5
6
  export { Input } from "./Input";
6
7
  export { Alert } from "./Alert";
7
8
  export { Slider } from "./Slider";
9
+ export { ThemeToggle } from "./ThemeToggle";
package/dist/styles.css CHANGED
@@ -85,6 +85,7 @@
85
85
  cursor: pointer;
86
86
  font-family: inherit;
87
87
  line-height: 1;
88
+ text-decoration: none;
88
89
  transition:
89
90
  background-color 0.15s ease,
90
91
  color 0.15s ease,
@@ -163,22 +164,29 @@
163
164
  border: 1px solid var(--vvp-surface-input, #badde8);
164
165
  }
165
166
 
167
+ /*
168
+ * radius/border-color/padding are pinned to whichever existing token lands
169
+ * closest to vvp_crowdfunding's donation-form input (the component's actual
170
+ * first real consumer, pre-dating this package): radius 14px sits exactly
171
+ * between md/lg, resolved to md since that's the scale's own "buttons and
172
+ * form controls" step; padding 14px/16px both round to lg; the border color
173
+ * (#d8e6ee) is closer to --vvp-surface than the likelier-sounding
174
+ * --vvp-surface-input by actual RGB distance (~16 vs ~32).
175
+ */
166
176
  .vvp-ui-input {
167
177
  display: block;
168
178
  box-sizing: border-box;
169
179
  width: 100%;
170
180
  min-width: 0;
171
- border-radius: var(--vvp-radius-sm, 8px);
172
- border: 1px solid var(--vvp-surface-input, #badde8);
181
+ border-radius: var(--vvp-radius-md, 12px);
182
+ border: 1px solid var(--vvp-surface, #e2f0f5);
173
183
  background: var(--vvp-background, #fff);
174
184
  color: var(--vvp-text, #111);
175
- padding: var(--vvp-spacing-sm, 8px) var(--vvp-spacing-md, 10px);
185
+ padding: var(--vvp-spacing-lg, 16px);
176
186
  font-family: inherit;
177
187
  font-size: var(--vvp-font-size-base, 16px);
178
188
  line-height: 1.4;
179
- transition:
180
- border-color 0.15s ease,
181
- box-shadow 0.15s ease;
189
+ transition: border-color 0.15s ease;
182
190
  }
183
191
 
184
192
  .vvp-ui-input::placeholder {
@@ -186,10 +194,8 @@
186
194
  }
187
195
 
188
196
  .vvp-ui-input:focus-visible {
189
- outline: none;
190
- border-color: var(--vvp-primary, #1b7194);
191
- box-shadow: 0 0 0 3px
192
- color-mix(in srgb, var(--vvp-primary, #1b7194) 25%, transparent);
197
+ outline: 2px solid var(--vvp-primary-muted, #3893c0);
198
+ outline-offset: 2px;
193
199
  }
194
200
 
195
201
  .vvp-ui-input:disabled {
@@ -208,8 +214,45 @@
208
214
  }
209
215
 
210
216
  .vvp-ui-input[aria-invalid="true"]:focus-visible {
211
- box-shadow: 0 0 0 3px
212
- color-mix(in srgb, var(--vvp-error, #c62828) 25%, transparent);
217
+ outline-color: var(--vvp-error, #c62828);
218
+ }
219
+
220
+ /*
221
+ * LinkButton's fixed look: a subtle bordered "pill card" with a soft
222
+ * drop shadow, distinct from Button's ghost variant (see LinkButton.tsx
223
+ * for why). Colors are expressed via the same --vvp-* tokens Button.css
224
+ * uses, so this stays in sync with the shared palette automatically.
225
+ *
226
+ * The shadow comes from @volksverpetzer/design-tokens'
227
+ * --vvp-elevation-accent-* — the one brand-tinted exception to that
228
+ * package's otherwise-neutral elevation scale (see elevation-accent.json).
229
+ * The literal color-mix() here is only a fallback for apps that haven't
230
+ * loaded elevation-accent.css; it must stay in sync with that token.
231
+ */
232
+ .vvp-ui-linkbtn {
233
+ border-radius: var(--vvp-radius-md, 12px);
234
+ background: transparent;
235
+ color: var(--vvp-primary, #1b7194);
236
+ border: 1.5px solid var(--vvp-surface, #e2f0f5);
237
+ box-shadow: var(
238
+ --vvp-elevation-accent-rest,
239
+ 0px 8px 20px color-mix(in srgb, var(--vvp-primary-muted, #3893c0) 6%, transparent)
240
+ );
241
+ transition:
242
+ transform 120ms ease,
243
+ box-shadow 120ms ease,
244
+ border-color 120ms ease,
245
+ background 120ms ease;
246
+ }
247
+
248
+ .vvp-ui-linkbtn:hover {
249
+ transform: translateY(-1px);
250
+ border-color: var(--vvp-primary-muted, #3893c0);
251
+ background: var(--vvp-surface, #e2f0f5);
252
+ box-shadow: var(
253
+ --vvp-elevation-accent-hover,
254
+ 0px 8px 20px color-mix(in srgb, var(--vvp-primary-muted, #3893c0) 18%, transparent)
255
+ );
213
256
  }
214
257
 
215
258
  .vvp-ui-progress {
@@ -356,3 +399,10 @@
356
399
  cursor: not-allowed;
357
400
  }
358
401
 
402
+ .vvp-ui-theme-toggle {
403
+ position: fixed;
404
+ top: var(--vvp-spacing-lg, 16px);
405
+ right: var(--vvp-spacing-lg, 16px);
406
+ z-index: 50;
407
+ }
408
+
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@volksverpetzer/ui-web",
3
- "version": "0.7.0",
4
- "description": "Shared brand-visible React components (Button, Badge, Card, ProgressBar, Input, Alert) for vvp_link_shortener, vvp_divi5_extensions, and crowdfunding. Plain, hand-scoped CSS (vvp-ui-* class prefix) consuming the --vvp-* custom properties emitted by @volksverpetzer/design-tokens — no Tailwind classes, so it drops into a Tailwind app and Divi's WordPress-embedded CSS alike.",
3
+ "version": "0.9.0",
4
+ "description": "Shared brand-visible React components (Button, Badge, Card, ProgressBar, Input, Alert, Slider, ThemeToggle) for vvp_link_shortener, vvp_divi5_extensions, crowdfunding, and vectorcrawl. Plain, hand-scoped CSS (vvp-ui-* class prefix) consuming the --vvp-* custom properties emitted by @volksverpetzer/design-tokens — no Tailwind classes, so it drops into a Tailwind app and Divi's WordPress-embedded CSS alike.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "repository": {