@reddb-io/ui-kit 0.4.1 → 0.4.3

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.
Files changed (44) hide show
  1. package/package.json +1 -1
  2. package/vendor/ds/design-system.receipt.json +44 -19
  3. package/vendor/ds/kits/app/README.md +38 -0
  4. package/vendor/ds/kits/app/src/change-review.variants.ts +27 -0
  5. package/vendor/ds/kits/app/src/composites/ChangeReview.svelte +110 -0
  6. package/vendor/ds/kits/app/src/composites/ErrorBoundary.svelte +50 -0
  7. package/vendor/ds/kits/app/src/composites/ShortcutHelp.svelte +50 -0
  8. package/vendor/ds/kits/app/src/composites/WorkspaceTabs.svelte +150 -0
  9. package/vendor/ds/kits/app/src/core/change-review.ts +37 -0
  10. package/vendor/ds/kits/app/src/core/contracts/changereview.ts +38 -0
  11. package/vendor/ds/kits/app/src/core/contracts/errorboundary.ts +15 -0
  12. package/vendor/ds/kits/app/src/core/contracts/shortcuthelp.ts +16 -0
  13. package/vendor/ds/kits/app/src/core/contracts/workspacetabs.ts +22 -0
  14. package/vendor/ds/kits/app/src/core/shortcut-help.ts +40 -0
  15. package/vendor/ds/kits/app/src/core/workspace-tabs.ts +69 -0
  16. package/vendor/ds/kits/app/src/error-boundary.variants.ts +12 -0
  17. package/vendor/ds/kits/app/src/index.ts +21 -0
  18. package/vendor/ds/kits/app/src/recipes.ts +8 -0
  19. package/vendor/ds/kits/app/src/shortcut-help.variants.ts +17 -0
  20. package/vendor/ds/kits/app/src/workspace-tabs.variants.ts +30 -0
  21. package/vendor/ds/kits/app/test/coherence.test.ts +2 -2
  22. package/vendor/ds/kits/app/test/density.test.ts +3 -3
  23. package/vendor/ds/kits/app/test/fixtures/ChangeReview.svelte +11 -0
  24. package/vendor/ds/kits/app/test/fixtures/ShortcutHelp.svelte +11 -0
  25. package/vendor/ds/kits/app/test/fixtures/WorkspaceTabs.svelte +11 -0
  26. package/vendor/ds/kits/app/test/react-consumer.tsx +3 -0
  27. package/vendor/ds/kits/app/test/speed-dial.test.ts +1 -1
  28. package/vendor/ds/kits/app/test/tree-shaking.test.ts +6 -0
  29. package/vendor/ds/kits/app/test/workspace-harvest.test.ts +187 -0
  30. package/vendor/ds/kits/base/README.md +16 -0
  31. package/vendor/ds/kits/base/src/SecretValue.svelte +86 -0
  32. package/vendor/ds/kits/base/src/button.variants.ts +6 -4
  33. package/vendor/ds/kits/base/src/core/contracts/secretvalue.ts +27 -0
  34. package/vendor/ds/kits/base/src/core/secret-value.ts +78 -0
  35. package/vendor/ds/kits/base/src/index.ts +6 -0
  36. package/vendor/ds/kits/base/src/recipes.ts +2 -0
  37. package/vendor/ds/kits/base/src/secret-value.variants.ts +16 -0
  38. package/vendor/ds/kits/base/test/components.test.ts +1 -0
  39. package/vendor/ds/kits/base/test/control-size.test.ts +1 -1
  40. package/vendor/ds/kits/base/test/disclosure.test.ts +1 -1
  41. package/vendor/ds/kits/base/test/dropdown-menu.test.ts +1 -1
  42. package/vendor/ds/kits/base/test/fixtures/SecretValue.svelte +11 -0
  43. package/vendor/ds/kits/base/test/navbar.test.ts +1 -1
  44. package/vendor/ds/kits/base/test/secret-value.test.ts +94 -0
@@ -0,0 +1,187 @@
1
+ import { mount, unmount, flushSync } from "svelte";
2
+ import { act, createElement as h } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { expect, it, vi } from "vitest";
5
+ import { WorkspaceTabs } from "../react/src/WorkspaceTabs";
6
+ import { ChangeReview } from "../react/src/ChangeReview";
7
+ import WorkspaceFixture from "./fixtures/WorkspaceTabs.svelte";
8
+ import ReviewFixture from "./fixtures/ChangeReview.svelte";
9
+ import { workspaceTabs } from "../src/core/workspace-tabs";
10
+ import { reviewChanges } from "../src/core/change-review";
11
+ import { shortcutGroups } from "../src/core/shortcut-help";
12
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
13
+ it("refuses ambiguous identities and invalid review/chord data", () => {
14
+ expect(() =>
15
+ workspaceTabs([
16
+ { id: "a", label: "A" },
17
+ { id: "a", label: "B" },
18
+ ]),
19
+ ).toThrow();
20
+ expect(() =>
21
+ reviewChanges([
22
+ { id: "a", label: "A", before: "1", after: "2", state: "guessed" as any },
23
+ ]),
24
+ ).toThrow();
25
+ expect(() =>
26
+ shortcutGroups([
27
+ { id: "a", label: "Global", items: [{ id: "x", label: "X", keys: [] }] },
28
+ ]),
29
+ ).toThrow();
30
+ });
31
+ async function harness(
32
+ flavor: string,
33
+ Component: any,
34
+ Fixture: any,
35
+ initial: any,
36
+ ) {
37
+ const target = document.createElement("div");
38
+ document.body.append(target);
39
+ if (flavor === "svelte") {
40
+ const app = mount(Fixture, { target, props: { options: initial } });
41
+ flushSync();
42
+ return {
43
+ target,
44
+ update: async (options: any) => {
45
+ flushSync(() => app.update(options));
46
+ },
47
+ dispose: async () => {
48
+ await unmount(app);
49
+ target.remove();
50
+ },
51
+ };
52
+ }
53
+ const root = createRoot(target);
54
+ await act(() => root.render(h(Component, initial)));
55
+ return {
56
+ target,
57
+ update: async (options: any) => {
58
+ await act(() => root.render(h(Component, options)));
59
+ },
60
+ dispose: async () => {
61
+ await act(() => root.unmount());
62
+ target.remove();
63
+ },
64
+ };
65
+ }
66
+ for (const flavor of ["svelte", "react"]) {
67
+ it(`${flavor} requests closing a dirty tab without discarding it and recovers focus after accepted removal`, async () => {
68
+ const close = vi.fn(),
69
+ select = vi.fn();
70
+ let options: any = {
71
+ label: "Editors",
72
+ items: [
73
+ { id: "a", label: "Draft", dirty: true },
74
+ { id: "b", label: "Result", busy: true },
75
+ { id: "c", label: "Unavailable", disabled: true },
76
+ ],
77
+ selected: "a",
78
+ oncloserequest: close,
79
+ onselectedchange: select,
80
+ };
81
+ const app = await harness(flavor, WorkspaceTabs, WorkspaceFixture, options);
82
+ try {
83
+ expect(
84
+ app.target
85
+ .querySelector('[role="tab"][aria-selected="true"]')
86
+ ?.getAttribute("aria-label"),
87
+ ).toBe("Draft");
88
+ const button = app.target.querySelector<HTMLButtonElement>(
89
+ '[aria-label="Close Draft"]',
90
+ )!;
91
+ await act(() => {
92
+ button.focus();
93
+ button.click();
94
+ flushSync();
95
+ });
96
+ expect(close).toHaveBeenCalledWith("a");
97
+ expect(app.target.querySelectorAll('[role="tab"]')).toHaveLength(3);
98
+ options = { ...options, items: options.items.slice(1), selected: "b" };
99
+ await app.update(options);
100
+ expect(document.activeElement?.getAttribute("data-workspace-tab")).toBe(
101
+ "b",
102
+ );
103
+ expect(
104
+ app.target.querySelector<HTMLButtonElement>('[data-workspace-tab="c"]')
105
+ ?.disabled,
106
+ ).toBe(true);
107
+ const current = app.target.querySelector<HTMLElement>(
108
+ '[data-workspace-tab="b"]',
109
+ )!;
110
+ await act(() => {
111
+ current.dispatchEvent(
112
+ new KeyboardEvent("keydown", { key: "Delete", bubbles: true }),
113
+ );
114
+ flushSync();
115
+ });
116
+ expect(close).toHaveBeenLastCalledWith("b");
117
+ } finally {
118
+ await app.dispose();
119
+ }
120
+ });
121
+ it(`${flavor} preserves exact changes and requests only pending/failed ids after partial results`, async () => {
122
+ const apply = vi.fn(),
123
+ discard = vi.fn();
124
+ let options: any = {
125
+ title: "Review",
126
+ items: [
127
+ {
128
+ id: "a",
129
+ label: "Applied",
130
+ before: "0",
131
+ after: "1",
132
+ state: "applied",
133
+ },
134
+ {
135
+ id: "b",
136
+ label: "Conflict",
137
+ before: "2",
138
+ after: "3",
139
+ state: "failed",
140
+ error: "Version conflict",
141
+ },
142
+ ],
143
+ onapplyrequest: apply,
144
+ ondiscardrequest: discard,
145
+ };
146
+ const app = await harness(flavor, ChangeReview, ReviewFixture, options);
147
+ try {
148
+ const button = [
149
+ ...app.target.querySelectorAll<HTMLButtonElement>("button"),
150
+ ].find((b) => b.textContent?.trim() === "Apply changes")!;
151
+ await act(() => {
152
+ button.click();
153
+ flushSync();
154
+ });
155
+ expect(apply).toHaveBeenCalledWith(["b"]);
156
+ expect(
157
+ app.target.querySelector('[data-review-change="b"] [role="alert"]')
158
+ ?.textContent,
159
+ ).toBe("Version conflict");
160
+ expect(options.items[1].state).toBe("failed");
161
+ options = { ...options, blockedReason: "Read only" };
162
+ await app.update(options);
163
+ expect(
164
+ [...app.target.querySelectorAll<HTMLButtonElement>("button")].find(
165
+ (b) => b.textContent?.trim() === "Apply changes",
166
+ )?.disabled,
167
+ ).toBe(true);
168
+ const discardButton = app.target.querySelector<HTMLButtonElement>(
169
+ '[aria-label="Discard Conflict"]',
170
+ )!;
171
+ await act(() => {
172
+ discardButton.click();
173
+ flushSync();
174
+ });
175
+ expect(discard).toHaveBeenCalledWith(["b"]);
176
+ options = { ...options, applying: true };
177
+ await app.update(options);
178
+ expect(
179
+ [...app.target.querySelectorAll<HTMLButtonElement>("button")].every(
180
+ (button) => button.disabled,
181
+ ),
182
+ ).toBe(true);
183
+ } finally {
184
+ await app.dispose();
185
+ }
186
+ });
187
+ }
@@ -1480,3 +1480,19 @@ Import `PhoneInput` from the canonical Base Kit. Its pinned
1480
1480
  `libphonenumber-js/min` engine and metadata are component-scoped; unrelated
1481
1481
  components retain their tree-shaking boundary. Flags need no image bundle or
1482
1482
  external requests. Sync selects `framework: "react"` or `"svelte"`.
1483
+
1484
+ ## Protected values
1485
+
1486
+ `SecretValue` presents a named sensitive value as selectable text, masked by
1487
+ default with a fixed-length visual marker. Set `revealable` only after the
1488
+ application has authorized the action. `onrevealedchange(true, "control")`
1489
+ requests retrieval; supply `value` and `revealed` only after that succeeds.
1490
+ Loading and caller-safe errors remain application-owned.
1491
+
1492
+ The shared exposure controller expires after `hideAfter` milliseconds (30000 by
1493
+ default; zero explicitly disables expiry), without extending the deadline on
1494
+ ordinary refreshes. Hiding, permission loss, replacement values and hidden
1495
+ documents remove plaintext from the rendered DOM. Handle false callbacks by
1496
+ clearing the supplied value as well. The component stores no credentials and
1497
+ provides no access control; masking does not make client-held plaintext secret.
1498
+ See `/kit/secret-value` and `/examples/operations-workbench` for both flavors.
@@ -0,0 +1,86 @@
1
+ <script lang="ts">
2
+ import Button from "./Button.svelte";
3
+ import { secretValue } from "./secret-value.variants";
4
+ import { createSecretExposure, secretIsVisible } from "./core/secret-value";
5
+ import type { SecretValueContract } from "./core/contracts/secretvalue";
6
+ interface Props extends SecretValueContract {
7
+ /** Additional composition classes. */
8
+ class?: string;
9
+ }
10
+ let {
11
+ label,
12
+ value,
13
+ revealable = false,
14
+ revealed = false,
15
+ pending = false,
16
+ error,
17
+ onrevealedchange,
18
+ hideAfter = 30000,
19
+ hiddenLabel = "Hidden value",
20
+ revealLabel = "Reveal",
21
+ hideLabel = "Hide",
22
+ class: className,
23
+ }: Props = $props();
24
+ const s = secretValue(),
25
+ id = $props.id();
26
+ let exposed = $state(false);
27
+ let root: HTMLDivElement;
28
+ const options = () => ({
29
+ label,
30
+ value,
31
+ revealable,
32
+ revealed,
33
+ pending,
34
+ error,
35
+ onrevealedchange,
36
+ hideAfter,
37
+ });
38
+ const exposure = createSecretExposure(options, (next) => {
39
+ exposed = next;
40
+ });
41
+ const visible = $derived(
42
+ exposed && secretIsVisible(options()) && exposure.matches(value),
43
+ );
44
+ $effect(() => {
45
+ exposure.sync();
46
+ });
47
+ $effect(() => {
48
+ const document = root.ownerDocument;
49
+ const changed = () => {
50
+ if (document.visibilityState === "hidden") exposure.hide("visibility");
51
+ };
52
+ document.addEventListener("visibilitychange", changed);
53
+ return () => {
54
+ document.removeEventListener("visibilitychange", changed);
55
+ exposure.destroy();
56
+ };
57
+ });
58
+ </script>
59
+
60
+ <div
61
+ bind:this={root}
62
+ data-secret-value
63
+ data-revealed={visible}
64
+ role="group"
65
+ aria-labelledby={`${id}-label`}
66
+ class={s.root({ class: className })}
67
+ >
68
+ <span id={`${id}-label`} class={s.label()}>{label}</span>
69
+ <div class={s.row()} aria-busy={pending}>
70
+ <span data-secret-content class={s.value()}
71
+ >{#if visible}{value}{:else}<span aria-hidden="true">••••••••</span><span
72
+ class="sr-only">{hiddenLabel}</span
73
+ >{/if}</span
74
+ >
75
+ {#if revealable && onrevealedchange}<Button
76
+ variant="ghost"
77
+ size="sm"
78
+ loading={pending}
79
+ aria-label={`${visible ? hideLabel : revealLabel} ${label}`}
80
+ aria-pressed={visible}
81
+ onclick={() => exposure.request(!visible)}
82
+ >{visible ? hideLabel : revealLabel}</Button
83
+ >{/if}
84
+ </div>
85
+ {#if error}<p role="alert" class={s.error()}>{error}</p>{/if}
86
+ </div>
@@ -33,10 +33,12 @@ const TONE = {
33
33
  danger: "",
34
34
  } as const satisfies Record<Tone, string>;
35
35
 
36
+ // A control owns a minimum rhythm, not a fixed text box: long labels and
37
+ // enlarged text may add lines without clipping or widening their container.
36
38
  const SIZE = {
37
- sm: "h-[var(--reddb-spatial-control-height-sm)] px-[var(--reddb-spatial-inset-sm)] text-sm",
38
- md: "h-[var(--reddb-spatial-control-height-md)] px-[var(--reddb-spatial-inset-md)] text-sm",
39
- lg: "h-[var(--reddb-spatial-control-height-lg)] px-[var(--reddb-spatial-inset-lg)] text-base",
39
+ sm: "min-h-[var(--reddb-spatial-control-height-sm)] px-[var(--reddb-spatial-inset-sm)] text-sm",
40
+ md: "min-h-[var(--reddb-spatial-control-height-md)] px-[var(--reddb-spatial-inset-md)] text-sm",
41
+ lg: "min-h-[var(--reddb-spatial-control-height-lg)] px-[var(--reddb-spatial-inset-lg)] text-base",
40
42
  } as const;
41
43
 
42
44
  const BLOCK = {
@@ -50,7 +52,7 @@ export const button = tv({
50
52
  base: [
51
53
  "inline-flex items-center justify-center gap-[var(--reddb-spatial-gap-md)]",
52
54
  "rounded-md border border-transparent",
53
- "font-medium leading-tight whitespace-nowrap",
55
+ "h-auto min-w-0 max-w-full font-medium leading-tight whitespace-normal [overflow-wrap:anywhere]",
54
56
  // No colour transition (quiet-control.variants.ts): states and a Color
55
57
  // Scheme switch land at full contrast at once.
56
58
  // One ink focus ring for every variant and tone (ADR 0023): 2px, offset
@@ -0,0 +1,27 @@
1
+ export type SecretHideReason =
2
+ "control" | "timeout" | "unavailable" | "value-change" | "visibility";
3
+ /** Explicit presentation of a server-authorized value; not an authorization boundary. */
4
+ export interface SecretValueContract {
5
+ /** Visible name of the sensitive value. */
6
+ label: string;
7
+ /** Plaintext only after application-authorized retrieval; omit while masked. */
8
+ value?: string | null;
9
+ /** Permission to offer reveal; defaults to false and never checks authorization itself. */
10
+ revealable?: boolean;
11
+ /** Caller-approved exposure; defaults to false. */
12
+ revealed?: boolean;
13
+ /** Application-owned retrieval in progress. */
14
+ pending?: boolean;
15
+ /** Caller-safe retrieval error, never inferred from exception text. */
16
+ error?: string;
17
+ /** Requests exposure or reports hiding; applications should drop plaintext when hiding. */
18
+ onrevealedchange?: (revealed: boolean, reason: SecretHideReason) => void;
19
+ /** Exposure lifetime in milliseconds; defaults to 30000; zero explicitly disables expiry. */
20
+ hideAfter?: number;
21
+ /** Masked-state text, independent of the secret length. */
22
+ hiddenLabel?: string;
23
+ /** Reveal action label. */
24
+ revealLabel?: string;
25
+ /** Hide action label. */
26
+ hideLabel?: string;
27
+ }
@@ -0,0 +1,78 @@
1
+ import type {
2
+ SecretValueContract,
3
+ SecretHideReason,
4
+ } from "./contracts/secretvalue";
5
+ export function secretIsVisible(options: SecretValueContract): boolean {
6
+ return (
7
+ !!options.revealable &&
8
+ !!options.revealed &&
9
+ !options.pending &&
10
+ !options.error &&
11
+ options.value != null
12
+ );
13
+ }
14
+ /** Portable exposure lifetime. Refreshing props never extends the current deadline. */
15
+ export function createSecretExposure(
16
+ read: () => SecretValueContract,
17
+ publish: (visible: boolean) => void,
18
+ ) {
19
+ let timer: ReturnType<typeof setTimeout> | undefined;
20
+ let visible = false,
21
+ blocked = false,
22
+ observed = false;
23
+ let previousValue: string | null | undefined;
24
+ const clear = () => {
25
+ clearTimeout(timer);
26
+ timer = undefined;
27
+ };
28
+ const hide = (reason: SecretHideReason) => {
29
+ clear();
30
+ blocked = true;
31
+ const was = visible;
32
+ visible = false;
33
+ publish(false);
34
+ if (was || reason === "control") read().onrevealedchange?.(false, reason);
35
+ };
36
+ return {
37
+ sync() {
38
+ const options = read();
39
+ if (!options.revealed) blocked = false;
40
+ if (visible && previousValue !== options.value) {
41
+ hide("value-change");
42
+ previousValue = options.value;
43
+ return;
44
+ }
45
+ const next = secretIsVisible(options) && !blocked;
46
+ if (visible && !next) {
47
+ hide("unavailable");
48
+ previousValue = options.value;
49
+ observed = !!options.revealed;
50
+ return;
51
+ }
52
+ if (options.revealed && !observed) blocked = false;
53
+ observed = !!options.revealed;
54
+ if (next && !visible) {
55
+ clear();
56
+ const lifetime = Number.isFinite(options.hideAfter ?? 30000)
57
+ ? Math.max(0, options.hideAfter ?? 30000)
58
+ : 30000;
59
+ if (lifetime > 0) timer = setTimeout(() => hide("timeout"), lifetime);
60
+ }
61
+ visible = next;
62
+ previousValue = options.value;
63
+ publish(next);
64
+ },
65
+ matches(value: string | null | undefined) {
66
+ return visible && value === previousValue;
67
+ },
68
+ request(next: boolean) {
69
+ if (!next) hide("control");
70
+ else if (read().revealable && !read().pending)
71
+ read().onrevealedchange?.(true, "control");
72
+ },
73
+ hide,
74
+ destroy() {
75
+ clear();
76
+ },
77
+ };
78
+ }
@@ -252,6 +252,7 @@ export const BASE_COMPONENTS = [
252
252
  "Reveal",
253
253
  "ScrollArea",
254
254
  "SearchSelect",
255
+ "SecretValue",
255
256
  "SectionHeading",
256
257
  "SectionSlide",
257
258
  "Select",
@@ -399,6 +400,7 @@ export const BASE_COMPONENT_PURPOSES = {
399
400
  ScrollArea: "Layout",
400
401
  SectionHeading: "Data display",
401
402
  SectionSlide: "Layout",
403
+ SecretValue: "Data display",
402
404
  SearchSelect: "Data input",
403
405
  Select: "Data input",
404
406
  Skeleton: "Feedback",
@@ -502,6 +504,7 @@ export const BASE_COMPOSITES = [
502
504
  "RangeCalendar",
503
505
  "Rating",
504
506
  "SearchSelect",
507
+ "SecretValue",
505
508
  "SectionSlide",
506
509
  "Slider",
507
510
  "Statistic",
@@ -1037,3 +1040,6 @@ export { afterFormReset, associatedForm } from "./core/native-controls";
1037
1040
 
1038
1041
  export { default as Transfer } from "./Transfer.svelte";
1039
1042
  export { observeActivity } from "./core/operational-motion";
1043
+
1044
+ export { default as SecretValue } from "./SecretValue.svelte";
1045
+ export type { SecretValueContract, SecretHideReason } from "./core/contracts/secretvalue";
@@ -98,3 +98,5 @@ export { fieldAssociation } from "./core/field";
98
98
  export { typeRoleMerge, TYPE_ROLE_UTILITIES } from "./type-roles";
99
99
 
100
100
  export * from "./phone-input.variants";
101
+
102
+ export * from "./secret-value.variants";
@@ -0,0 +1,16 @@
1
+ import { tv } from "tailwind-variants";
2
+ import { typeRoleMerge } from "./type-roles";
3
+ export const secretValue = tv(
4
+ {
5
+ slots: {
6
+ root: "@container/secret-value grid min-w-0 gap-[var(--reddb-spatial-gap-sm)] text-foreground",
7
+ label: "text-caption font-medium [overflow-wrap:anywhere]",
8
+ row: "flex min-w-0 flex-wrap items-center gap-[var(--reddb-spatial-gap-sm)]",
9
+ value:
10
+ "min-w-0 flex-1 whitespace-pre-wrap font-mono text-body [overflow-wrap:anywhere]",
11
+ error:
12
+ "m-0 text-caption text-feedback-danger-foreground [overflow-wrap:anywhere]",
13
+ },
14
+ },
15
+ typeRoleMerge,
16
+ );
@@ -91,6 +91,7 @@ describe("the Base Kit's public surface", () => {
91
91
  "RangeCalendar",
92
92
  "Rating",
93
93
  "SearchSelect",
94
+ "SecretValue",
94
95
  "SectionSlide",
95
96
  "Slider",
96
97
  "Statistic",
@@ -23,7 +23,7 @@ describe("field control sizes", () => {
23
23
  const buttonClasses = classesOf(button({ size }));
24
24
  const height = `h-${role(`control-height-${size}`)}`;
25
25
  const inset = `px-${role(INSET[size])}`;
26
- expect(buttonClasses.has(height) && buttonClasses.has(inset)).toBe(true);
26
+ expect(buttonClasses.has(`min-${height}`) && buttonClasses.has(inset)).toBe(true);
27
27
 
28
28
  const field = rendered(render(Input, { controlSize: size }));
29
29
  const choice = rendered(render(Select, { controlSize: size, options: [{ value: "a", label: "A" }] }));
@@ -43,7 +43,7 @@ describe("the Base Disclosure", () => {
43
43
  const styles = disclosure();
44
44
 
45
45
  expect(classes(root)).toEqual(classesOf(styles.root()));
46
- expect(classes(root.querySelector("button")!).has("h-[var(--reddb-spatial-control-height-sm)]")).toBe(true);
46
+ expect(classes(root.querySelector("button")!).has("min-h-[var(--reddb-spatial-control-height-sm)]")).toBe(true);
47
47
  expect(root.hasAttribute("data-theme")).toBe(false);
48
48
  expect(root.hasAttribute("data-color-scheme")).toBe(false);
49
49
  expect(root.hasAttribute("data-density")).toBe(false);
@@ -286,7 +286,7 @@ describe("the Base DropdownMenu appearance", () => {
286
286
  it("moves trigger and rows to the requested density size", async () => {
287
287
  const trigger = mountMenu({ size: "sm" });
288
288
  await open(trigger);
289
- expect(classes(trigger).has("h-[var(--reddb-spatial-control-height-sm)]")).toBe(true);
289
+ expect(classes(trigger).has("min-h-[var(--reddb-spatial-control-height-sm)]")).toBe(true);
290
290
  expect(classes(rows()[0]!).has("h-[var(--reddb-spatial-control-height-sm)]")).toBe(true);
291
291
  });
292
292
 
@@ -0,0 +1,11 @@
1
+ <script lang="ts">
2
+ import { untrack } from "svelte";
3
+ import SecretValue from "../../src/SecretValue.svelte";
4
+ let { options }: { options: any } = $props();
5
+ let current = $state(untrack(() => options));
6
+ export function update(next: any) {
7
+ current = next;
8
+ }
9
+ </script>
10
+
11
+ <SecretValue {...current} />
@@ -381,7 +381,7 @@ describe("the Base Navbar appearance", () => {
381
381
  it("uses a canonical Button for the collapsed toggle", () => {
382
382
  const worn = classes(toggle(mountNavbar({ collapse: "collapsed" })));
383
383
  expect(worn.has("inline-flex")).toBe(true);
384
- expect(worn.has("h-[var(--reddb-spatial-control-height-md)]")).toBe(true);
384
+ expect(worn.has("min-h-[var(--reddb-spatial-control-height-md)]")).toBe(true);
385
385
  });
386
386
 
387
387
  it("merges caller classes over its canonical appearance", () => {
@@ -0,0 +1,94 @@
1
+ import { mount, unmount, flushSync } from "svelte";
2
+ import { act, createElement as h } from "react";
3
+ import { createRoot } from "react-dom/client";
4
+ import { expect, it, vi } from "vitest";
5
+ import { SecretValue } from "../react/src/SecretValue";
6
+ import Fixture from "./fixtures/SecretValue.svelte";
7
+ import { createSecretExposure } from "../src/core/secret-value";
8
+ Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
9
+ it("expires without extending exposure on refresh, blocks stale approvals and masks replacement values", () => {
10
+ vi.useFakeTimers();
11
+ const changed = vi.fn(),
12
+ publish = vi.fn();
13
+ let options: any = {
14
+ label: "Token",
15
+ value: "sample-alpha",
16
+ revealed: true,
17
+ revealable: true,
18
+ hideAfter: 1000,
19
+ onrevealedchange: changed,
20
+ };
21
+ const controller = createSecretExposure(() => options, publish);
22
+ try {
23
+ controller.sync();
24
+ vi.advanceTimersByTime(600);
25
+ controller.sync();
26
+ vi.advanceTimersByTime(400);
27
+ expect(publish).toHaveBeenLastCalledWith(false);
28
+ expect(changed).toHaveBeenLastCalledWith(false, "timeout");
29
+ controller.sync();
30
+ expect(publish).toHaveBeenLastCalledWith(false);
31
+ options = { ...options, revealed: false };
32
+ controller.sync();
33
+ options = { ...options, revealed: true };
34
+ controller.sync();
35
+ expect(publish).toHaveBeenLastCalledWith(true);
36
+ options = { ...options, value: "sample-beta" };
37
+ expect(controller.matches(options.value)).toBe(false);
38
+ controller.sync();
39
+ expect(publish).toHaveBeenLastCalledWith(false);
40
+ expect(changed).toHaveBeenLastCalledWith(false, "value-change");
41
+ } finally {
42
+ controller.destroy();
43
+ vi.useRealTimers();
44
+ }
45
+ });
46
+ for (const flavor of ["react", "svelte"])
47
+ it(`${flavor} keeps masked plaintext out of the DOM and immediately masks revoked access`, async () => {
48
+ const target = document.createElement("div");
49
+ document.body.append(target);
50
+ const changed = vi.fn();
51
+ let options: any = {
52
+ label: "Token",
53
+ value: "sample-sensitive-value",
54
+ revealable: true,
55
+ onrevealedchange: changed,
56
+ };
57
+ let update: (next: any) => Promise<void>, dispose: () => Promise<void>;
58
+ if (flavor === "svelte") {
59
+ const app = mount(Fixture, { target, props: { options } });
60
+ flushSync();
61
+ update = async (next) => {
62
+ flushSync(() => app.update(next));
63
+ };
64
+ dispose = () => unmount(app);
65
+ } else {
66
+ const root = createRoot(target);
67
+ await act(() => root.render(h(SecretValue, options)));
68
+ update = async (next) => {
69
+ await act(() => root.render(h(SecretValue, next)));
70
+ };
71
+ dispose = async () => {
72
+ await act(() => root.unmount());
73
+ };
74
+ }
75
+ try {
76
+ expect(target.innerHTML).not.toContain("sample-sensitive-value");
77
+ await act(() => {
78
+ target.querySelector<HTMLButtonElement>("button")!.click();
79
+ flushSync();
80
+ });
81
+ expect(changed).toHaveBeenLastCalledWith(true, "control");
82
+ expect(target.innerHTML).not.toContain("sample-sensitive-value");
83
+ options = { ...options, revealed: true };
84
+ await update(options);
85
+ expect(target.textContent).toContain("sample-sensitive-value");
86
+ options = { ...options, revealable: false };
87
+ await update(options);
88
+ expect(target.innerHTML).not.toContain("sample-sensitive-value");
89
+ expect(target.querySelector("button")).toBeNull();
90
+ } finally {
91
+ await dispose();
92
+ target.remove();
93
+ }
94
+ });