cursedbelt 5.17.0 → 5.19.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.
Files changed (32) hide show
  1. package/dist/react/components/CardDisplayControls.d.ts +37 -0
  2. package/dist/react/components/CardDisplayControls.d.ts.map +1 -0
  3. package/dist/react/components/CardDisplayControls.js +33 -0
  4. package/dist/react/components/CardDisplayControls.js.map +1 -0
  5. package/dist/react/components/SearchInput.d.ts +39 -0
  6. package/dist/react/components/SearchInput.d.ts.map +1 -0
  7. package/dist/react/components/SearchInput.js +50 -0
  8. package/dist/react/components/SearchInput.js.map +1 -0
  9. package/dist/react/hooks/useCardDisplay.d.ts +51 -0
  10. package/dist/react/hooks/useCardDisplay.d.ts.map +1 -0
  11. package/dist/react/hooks/useCardDisplay.js +106 -0
  12. package/dist/react/hooks/useCardDisplay.js.map +1 -0
  13. package/dist/react/index.d.ts +4 -0
  14. package/dist/react/index.d.ts.map +1 -1
  15. package/dist/react/index.js +4 -0
  16. package/dist/react/index.js.map +1 -1
  17. package/dist/react/lib/cardDisplay.d.ts +25 -0
  18. package/dist/react/lib/cardDisplay.d.ts.map +1 -0
  19. package/dist/react/lib/cardDisplay.js +6 -0
  20. package/dist/react/lib/cardDisplay.js.map +1 -0
  21. package/dist/styles-areas/fields.css +4 -4
  22. package/package.json +2 -2
  23. package/scripts/styleAreas.ts +2 -0
  24. package/src/react/components/CardDisplayControls.spec.tsx +71 -0
  25. package/src/react/components/CardDisplayControls.tsx +120 -0
  26. package/src/react/components/SearchInput.spec.tsx +59 -0
  27. package/src/react/components/SearchInput.tsx +117 -0
  28. package/src/react/hooks/useCardDisplay.spec.tsx +131 -0
  29. package/src/react/hooks/useCardDisplay.ts +142 -0
  30. package/src/react/index.ts +10 -0
  31. package/src/react/lib/cardDisplay.ts +29 -0
  32. package/src/styles-areas/fields.css +4 -4
@@ -18,7 +18,7 @@
18
18
  * after an app's responsive variant (task 2124). An app that imports every area gets exactly
19
19
  * what `cursedbelt/styles-utilities.css` emits — proved rule by rule in
20
20
  * src/stylesAreas.spec.ts, which is also what fails when this file is stale.
21
- * 114 candidate(s).
21
+ * 115 candidate(s).
22
22
  *
23
23
  * Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
24
24
  * `bun run build` runs. The map of what is in which area, and why the areas are
@@ -39,9 +39,9 @@
39
39
  @source inline("group-focus-within:opacity-100 h-12 h-3.5 h-5 h-[420px]");
40
40
  @source inline("has-[[aria-invalid=true]]:border-field-error hover:bg-border hover:bg-destructive/10");
41
41
  @source inline("hover:bg-muted/50 hover:border-field-ring hover:scale-110 hover:text-warning italic leading-5");
42
- @source inline("left-0 list-none max-h-64 max-h-[40dvh] max-h-[65dvh] max-h-[85dvh] max-w-40 min-h-20 min-w-24");
43
- @source inline("min-w-64 min-w-[var(--anchor-width)] min-w-max mix-blend-difference mr-2 p-1.5 pl-1.5 pl-2");
44
- @source inline("pl-2.5 pointer-coarse:before:absolute pointer-coarse:before:bottom-[-10px]");
42
+ @source inline("left-0 left-2.5 list-none max-h-64 max-h-[40dvh] max-h-[65dvh] max-h-[85dvh] max-w-40 min-h-20");
43
+ @source inline("min-w-24 min-w-64 min-w-[var(--anchor-width)] min-w-max mix-blend-difference mr-2 p-1.5 pl-1.5");
44
+ @source inline("pl-2 pl-2.5 pointer-coarse:before:absolute pointer-coarse:before:bottom-[-10px]");
45
45
  @source inline("pointer-coarse:before:left-[-4px] pointer-coarse:before:right-[-4px]");
46
46
  @source inline("pointer-coarse:before:top-[-10px] pointer-coarse:items-center pr-1 pr-16 pr-2 pr-3 pr-9");
47
47
  @source inline("resize-none resize-y right-1.5 right-3 ring-accent/30 ring-field-error/30 rounded-l-xl");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedbelt",
3
- "version": "5.17.0",
3
+ "version": "5.19.0",
4
4
  "license": "ISC",
5
5
  "type": "module",
6
6
  "description": "The React design system of the cursedbelt split — components, styles, theme. cursedbelt-core below it; server tier in cursedbelt-server.",
@@ -750,7 +750,7 @@
750
750
  "class-variance-authority": "^0.7.1",
751
751
  "clsx": "^2.1.1",
752
752
  "cmdk": "^1.1.1",
753
- "cursedbelt-core": "^2.6.0",
753
+ "cursedbelt-core": "^2.10.0",
754
754
  "cwip": "^4.6.0",
755
755
  "lucide-react": "^1.21.0",
756
756
  "react-hook-form": "^7.80.0",
@@ -150,6 +150,7 @@ export const AREAS: readonly Area[] = [
150
150
  'components/Button.tsx',
151
151
  'components/ButtonGroup.tsx',
152
152
  'components/ButtonLink.tsx',
153
+ 'components/CardDisplayControls.tsx',
153
154
  'components/CardTapTarget.tsx',
154
155
  'components/Checkbox.tsx',
155
156
  'components/ChoiceGroup.tsx',
@@ -251,6 +252,7 @@ export const AREAS: readonly Area[] = [
251
252
  'components/OtpInput.tsx',
252
253
  'components/RatingField.tsx',
253
254
  'components/RenderField.tsx',
255
+ 'components/SearchInput.tsx',
254
256
  'components/SecretInput.tsx',
255
257
  'components/SliderField.tsx',
256
258
  'components/StarRating.tsx',
@@ -0,0 +1,71 @@
1
+ import { afterEach, describe, expect, mock, test } from 'bun:test';
2
+ import { cleanup, fireEvent, render } from '@testing-library/react';
3
+ import type { CardDisplay } from '../lib/cardDisplay.js';
4
+ import { CardDisplayControls } from './CardDisplayControls.js';
5
+
6
+ afterEach(cleanup);
7
+
8
+ const LARGE_WITH_WORDS: CardDisplay = { size: 'large', captions: true };
9
+
10
+ describe('CardDisplayControls', () => {
11
+ test('a size segment reports the size, keeping the captions choice', () => {
12
+ const onChange = mock((_: CardDisplay) => {});
13
+ const { getByTestId } = render(
14
+ <CardDisplayControls value={{ size: 'large', captions: false }} onChange={onChange} />,
15
+ );
16
+ // Radix Tabs activates on mousedown, not click.
17
+ fireEvent.mouseDown(getByTestId('card-size-small'), { button: 0 });
18
+ expect(onChange).toHaveBeenLastCalledWith({ size: 'small', captions: false });
19
+ });
20
+
21
+ test('the captions toggle flips the words and keeps the size, pressed when they are hidden', () => {
22
+ const onChange = mock((_: CardDisplay) => {});
23
+ const { getByTestId, rerender } = render(
24
+ <CardDisplayControls value={{ size: 'medium', captions: true }} onChange={onChange} captionsNoun='film titles' imageNoun='posters' />,
25
+ );
26
+ const toggle = getByTestId('card-captions');
27
+ expect(toggle.getAttribute('aria-pressed')).toBe('false');
28
+ expect(toggle.getAttribute('aria-label')).toBe('Hide film titles — show the posters only');
29
+ fireEvent.click(toggle);
30
+ expect(onChange).toHaveBeenLastCalledWith({ size: 'medium', captions: false });
31
+
32
+ rerender(
33
+ <CardDisplayControls value={{ size: 'medium', captions: false }} onChange={onChange} captionsNoun='film titles' imageNoun='posters' />,
34
+ );
35
+ expect(getByTestId('card-captions').getAttribute('aria-pressed')).toBe('true');
36
+ expect(getByTestId('card-captions').getAttribute('aria-label')).toBe('Show film titles under the posters');
37
+ });
38
+
39
+ test('offers only the sizes asked for, with the labels asked for', () => {
40
+ const { queryByTestId, getByTestId } = render(
41
+ <CardDisplayControls
42
+ value={LARGE_WITH_WORDS}
43
+ onChange={() => {}}
44
+ sizes={['large', 'small']}
45
+ sizeLabels={{ small: 'Tiny' }}
46
+ />,
47
+ );
48
+ expect(queryByTestId('card-size-medium')).toBeNull();
49
+ expect(getByTestId('card-size-large').textContent).toBe('L');
50
+ expect(getByTestId('card-size-small').textContent).toBe('Tiny');
51
+ });
52
+
53
+ test('a disabled reason leaves both controls visible and inert', () => {
54
+ const onChange = mock((_: CardDisplay) => {});
55
+ const { getByTestId } = render(
56
+ <CardDisplayControls
57
+ value={LARGE_WITH_WORDS}
58
+ onChange={onChange}
59
+ sizeDisabledReason='Card size applies to the grid view'
60
+ captionsDisabledReason='Titles are always shown in the list view'
61
+ />,
62
+ );
63
+ const small = getByTestId('card-size-small') as HTMLButtonElement;
64
+ const toggle = getByTestId('card-captions') as HTMLButtonElement;
65
+ expect(small.disabled).toBe(true);
66
+ expect(toggle.disabled).toBe(true);
67
+ fireEvent.mouseDown(small, { button: 0 });
68
+ fireEvent.click(toggle);
69
+ expect(onChange).not.toHaveBeenCalled();
70
+ });
71
+ });
@@ -0,0 +1,120 @@
1
+ import type { ReactNode } from 'react';
2
+ import { cn } from '../lib/cn.js';
3
+ import type { ControlSize } from '../lib/field.js';
4
+ import { IconButton } from './IconButton.js';
5
+ import { SegmentedControl } from './SegmentedControl.js';
6
+ import { Tooltip } from './Tooltip.js';
7
+ import { CARD_SIZES, type CardDisplay, type CardSize } from '../lib/cardDisplay.js';
8
+
9
+ const DEFAULT_SIZE_LABELS: Readonly<Record<CardSize, ReactNode>> = { large: 'L', medium: 'M', small: 'S' };
10
+
11
+ const capitalized = (word: string): string => `${word[0]?.toUpperCase() ?? ''}${word.slice(1)}`;
12
+
13
+ export interface CardDisplayControlsProps {
14
+ value: CardDisplay;
15
+ onChange: (next: CardDisplay) => void;
16
+ /** Which sizes to offer, in order. Default {@link CARD_SIZES}. */
17
+ sizes?: readonly CardSize[];
18
+ /** What each segment says. Default `L` / `M` / `S`. */
19
+ sizeLabels?: Partial<Record<CardSize, ReactNode>>;
20
+ /** What the words under a card are called, lower-case — `"game titles"`. Default `"titles"`. */
21
+ captionsNoun?: string;
22
+ /** What the image is called, lower-case — `"art"`, `"posters"`. Default `"images"`. */
23
+ imageNoun?: string;
24
+ /**
25
+ * Set when the size does not apply (a list view). The segments stay VISIBLE and inert with
26
+ * this on hover — one UI, never a hidden control.
27
+ */
28
+ sizeDisabledReason?: string | null;
29
+ /** The same, for the captions toggle — `"Titles are always shown in the list view"`. */
30
+ captionsDisabledReason?: string | null;
31
+ /** Height on the shared control scale. Default `sm` — this sits in a toolbar. */
32
+ controlSize?: Extract<ControlSize, 'sm' | 'md'>;
33
+ className?: string;
34
+ }
35
+
36
+ /**
37
+ * The S / M / L switch and the captions toggle, side by side. The why, and the model it draws,
38
+ * are in `../lib/cardDisplay.ts`.
39
+ *
40
+ * Controlled: the app owns the state and where it is kept (`useCardDisplay` is the hook for
41
+ * that). The captions toggle is `aria-pressed` when the words are HIDDEN — pressing it is the act
42
+ * of hiding them, and its icon shows the card with or without its lines.
43
+ *
44
+ * Test hooks: `card-size-<size>` on each segment and `card-captions` on the toggle.
45
+ */
46
+ export function CardDisplayControls({
47
+ value,
48
+ onChange,
49
+ sizes = CARD_SIZES,
50
+ sizeLabels,
51
+ captionsNoun = 'titles',
52
+ imageNoun = 'images',
53
+ sizeDisabledReason = null,
54
+ captionsDisabledReason = null,
55
+ controlSize = 'sm',
56
+ className,
57
+ }: CardDisplayControlsProps) {
58
+ const labels = { ...DEFAULT_SIZE_LABELS, ...sizeLabels };
59
+ const { captions } = value;
60
+ const toggle = (
61
+ <IconButton
62
+ size={controlSize}
63
+ variant='ghost'
64
+ disabled={captionsDisabledReason !== null}
65
+ onClick={() => onChange({ ...value, captions: !captions })}
66
+ aria-pressed={!captions}
67
+ data-testid='card-captions'
68
+ label={
69
+ captions
70
+ ? `Hide ${captionsNoun} — show the ${imageNoun} only`
71
+ : `Show ${captionsNoun} under the ${imageNoun}`
72
+ }
73
+ tooltip={captions ? `${capitalized(imageNoun)} only — hide the ${captionsNoun}` : `Show the ${captionsNoun}`}
74
+ >
75
+ <svg
76
+ viewBox='0 0 24 24'
77
+ fill='none'
78
+ stroke='currentColor'
79
+ strokeWidth={2}
80
+ strokeLinecap='round'
81
+ strokeLinejoin='round'
82
+ aria-hidden='true'
83
+ >
84
+ <rect x='4' y='3' width='16' height='12' rx='1.5' />
85
+ {captions ? (
86
+ <>
87
+ <line x1='4' y1='18.5' x2='20' y2='18.5' />
88
+ <line x1='4' y1='21.5' x2='14' y2='21.5' />
89
+ </>
90
+ ) : null}
91
+ </svg>
92
+ </IconButton>
93
+ );
94
+ return (
95
+ <div className={cn('inline-flex items-center gap-1', className)} data-cb-card-display=''>
96
+ <SegmentedControl<CardSize>
97
+ size={controlSize}
98
+ aria-label='Card size'
99
+ value={value.size}
100
+ onChange={(size) => onChange({ ...value, size })}
101
+ options={sizes.map((size) => ({
102
+ value: size,
103
+ label: labels[size],
104
+ disabled: sizeDisabledReason !== null,
105
+ tooltip: sizeDisabledReason ?? `${capitalized(size)} cards`,
106
+ 'data-testid': `card-size-${size}`,
107
+ }))}
108
+ />
109
+ {captionsDisabledReason === null ? (
110
+ toggle
111
+ ) : (
112
+ // A disabled button takes no pointer events, so its own tooltip can never open —
113
+ // the span carries the reason instead (SegmentedControl does the same per segment).
114
+ <Tooltip content={captionsDisabledReason}>
115
+ <span className='inline-flex'>{toggle}</span>
116
+ </Tooltip>
117
+ )}
118
+ </div>
119
+ );
120
+ }
@@ -0,0 +1,59 @@
1
+ import { afterEach, describe, expect, mock, test } from 'bun:test';
2
+ import { cleanup, fireEvent, render } from '@testing-library/react';
3
+ import { SearchInput } from './SearchInput.js';
4
+
5
+ afterEach(cleanup);
6
+
7
+ const badge = (container: HTMLElement) => container.querySelector('[data-testid="search-syntax-badge"]');
8
+
9
+ describe('SearchInput', () => {
10
+ test('reports what was typed, not an event', () => {
11
+ const onChange = mock((_: string) => {});
12
+ const { getByRole } = render(<SearchInput value='' onChange={onChange} placeholder='Search films' />);
13
+ const input = getByRole('searchbox');
14
+ expect(input.getAttribute('aria-label')).toBe('Search films');
15
+ fireEvent.change(input, { target: { value: 'hunt*red' } });
16
+ expect(onChange).toHaveBeenLastCalledWith('hunt*red');
17
+ });
18
+
19
+ test('no badge while the text is plain words — the nine searches in ten', () => {
20
+ const { container } = render(<SearchInput value='the office' onChange={() => {}} />);
21
+ expect(badge(container)).toBeNull();
22
+ });
23
+
24
+ test('a star shows the wildcard badge, and the input makes room for it', () => {
25
+ const { container, getByRole } = render(<SearchInput value='the*the' onChange={() => {}} />);
26
+ expect(badge(container)?.getAttribute('data-kind')).toBe('wildcard');
27
+ expect(badge(container)?.getAttribute('aria-label')).toBe('Wildcard search');
28
+ expect(getByRole('searchbox').className).toContain('pr-9');
29
+ });
30
+
31
+ test('syntax={false} makes a star an ordinary character', () => {
32
+ const { container } = render(<SearchInput value='M*A*S*H' onChange={() => {}} syntax={false} />);
33
+ expect(badge(container)).toBeNull();
34
+ });
35
+
36
+ test('regex is off unless asked for; on, a broken one paints the field invalid', () => {
37
+ const off = render(<SearchInput value='/(broken/' onChange={() => {}} />);
38
+ expect(badge(off.container)).toBeNull();
39
+ off.unmount();
40
+
41
+ const on = render(<SearchInput value='/(broken/' onChange={() => {}} syntax={{ regex: true }} />);
42
+ expect(badge(on.container)?.getAttribute('data-kind')).toBe('invalid');
43
+ expect(on.getByRole('searchbox').getAttribute('aria-invalid')).toBe('true');
44
+ });
45
+
46
+ test('a working regex gets its own badge and a valid field', () => {
47
+ const { container, getByRole } = render(
48
+ <SearchInput value='/^the (mask|agony)/' onChange={() => {}} syntax={{ regex: true }} />,
49
+ );
50
+ expect(badge(container)?.getAttribute('data-kind')).toBe('regex');
51
+ expect(getByRole('searchbox').getAttribute('aria-invalid')).toBeNull();
52
+ });
53
+
54
+ test('showSyntaxBadge={false} hides it without changing what the text means', () => {
55
+ const { container, getByRole } = render(<SearchInput value='hunt*red' onChange={() => {}} showSyntaxBadge={false} />);
56
+ expect(badge(container)).toBeNull();
57
+ expect(getByRole('searchbox').className).not.toContain('pr-9');
58
+ });
59
+ });
@@ -0,0 +1,117 @@
1
+ import { Asterisk, Regex, Search, TriangleAlert } from 'lucide-react';
2
+ import { useMemo } from 'react';
3
+ import {
4
+ parseSearchPattern,
5
+ type SearchPattern,
6
+ type SearchSyntaxOptions,
7
+ } from 'cursedbelt-core/search-pattern';
8
+ import { cn } from '../lib/cn.js';
9
+ import { Input, type InputProps } from './Input.js';
10
+ import { Tooltip } from './Tooltip.js';
11
+
12
+ export interface SearchInputProps extends Omit<InputProps, 'value' | 'onChange' | 'type'> {
13
+ value: string;
14
+ onChange: (value: string) => void;
15
+ /**
16
+ * Which syntaxes the box understands — the SAME options the app hands
17
+ * `parseSearchPattern` when it runs the query, so the badge here is the syntax
18
+ * the query ran in. Default: `*` wildcard on, `/regex/` off. `false` is plain
19
+ * text only (a `*` is just a character).
20
+ */
21
+ syntax?: SearchSyntaxOptions | false;
22
+ /** Show the badge that says a wildcard or regex is in play. Default `true`. */
23
+ showSyntaxBadge?: boolean;
24
+ /** Classes for the wrapper, which carries the width. */
25
+ className?: string;
26
+ /** Classes for the `<input>` itself — colours, radius, text size. */
27
+ inputClassName?: string;
28
+ }
29
+
30
+ const PLAIN_ONLY: SearchSyntaxOptions = { wildcard: false, regex: false };
31
+
32
+ /** What the badge says, per pattern kind that has one. */
33
+ function badgeFor(pattern: SearchPattern) {
34
+ switch (pattern.kind) {
35
+ case 'wildcard':
36
+ return {
37
+ icon: Asterisk,
38
+ label: 'Wildcard search',
39
+ tip: '* matches any characters, in order — hunt*red finds The Hunt for Red October.',
40
+ };
41
+ case 'regex':
42
+ return { icon: Regex, label: 'Regular expression', tip: `Matching /${pattern.source}/, ignoring case.` };
43
+ case 'invalid':
44
+ return { icon: TriangleAlert, label: 'Search cannot run', tip: pattern.message };
45
+ default:
46
+ return null;
47
+ }
48
+ }
49
+
50
+ /**
51
+ * The one search box — a magnifier, a themed {@link Input}, and a badge the
52
+ * moment the text stops being plain words.
53
+ *
54
+ * ── Why a badge, and only then ──────────────────────────────────────────────
55
+ * `*` means "any characters" here (owner request via flix, 2026-09-28 —
56
+ * `cursedbelt-core/search-pattern` has the whole semantics). A search that
57
+ * silently changed meaning when a star appeared would read as a bug the first
58
+ * time it returned something the words alone would not; a badge that is ALWAYS
59
+ * there is noise for the nine searches in ten that are plain. So it appears
60
+ * exactly while a syntax is in play, and its tooltip is where a viewer learns
61
+ * what the star does. An uncompilable regex paints the field invalid
62
+ * (`aria-invalid`, the Input's own error state) and the tooltip says why.
63
+ *
64
+ * Parsing is the app's too: this component only DISPLAYS the pattern. An app
65
+ * that filters in memory calls `createSearchMatcher(value, syntax)`; one that
66
+ * queries a server parses there with the same options.
67
+ */
68
+ export function SearchInput({
69
+ value,
70
+ onChange,
71
+ syntax,
72
+ showSyntaxBadge = true,
73
+ className,
74
+ inputClassName,
75
+ placeholder = 'Search…',
76
+ 'aria-label': ariaLabel,
77
+ ...inputProps
78
+ }: SearchInputProps) {
79
+ const pattern = useMemo(() => parseSearchPattern(value, syntax === false ? PLAIN_ONLY : syntax), [value, syntax]);
80
+ const badge = showSyntaxBadge ? badgeFor(pattern) : null;
81
+ const invalid = pattern.kind === 'invalid';
82
+
83
+ return (
84
+ <div className={cn('relative min-w-0', className)}>
85
+ <Search
86
+ aria-hidden
87
+ className='-translate-y-1/2 pointer-events-none absolute top-1/2 left-2.5 size-4 text-muted-foreground'
88
+ />
89
+ <Input
90
+ {...inputProps}
91
+ type='search'
92
+ value={value}
93
+ onChange={(event) => onChange(event.target.value)}
94
+ placeholder={placeholder}
95
+ aria-label={ariaLabel ?? (typeof placeholder === 'string' ? placeholder : 'Search')}
96
+ aria-invalid={invalid || undefined}
97
+ className={cn('pl-8', badge && 'pr-9', inputClassName)}
98
+ />
99
+ {badge ? (
100
+ <Tooltip content={badge.tip} side='bottom' multiline>
101
+ <span
102
+ role='img'
103
+ aria-label={badge.label}
104
+ data-testid='search-syntax-badge'
105
+ data-kind={pattern.kind}
106
+ className={cn(
107
+ '-translate-y-1/2 absolute top-1/2 right-2 inline-flex size-6 items-center justify-center rounded-full',
108
+ invalid ? 'bg-destructive/15 text-destructive' : 'bg-primary/15 text-primary',
109
+ )}
110
+ >
111
+ <badge.icon className='size-3.5' aria-hidden />
112
+ </span>
113
+ </Tooltip>
114
+ ) : null}
115
+ </div>
116
+ );
117
+ }
@@ -0,0 +1,131 @@
1
+ import { afterEach, describe, expect, mock, test } from 'bun:test';
2
+ import { act, cleanup, renderHook, waitFor } from '@testing-library/react';
3
+ import type { CardDisplay } from '../lib/cardDisplay.js';
4
+ import { useCardDisplay } from './useCardDisplay.js';
5
+
6
+ afterEach(cleanup);
7
+
8
+ const WAIT = 20;
9
+ const settle = () => new Promise((resolve) => setTimeout(resolve, WAIT * 3));
10
+
11
+ type Save = (display: CardDisplay, options: { keepalive: boolean }) => void | Promise<void>;
12
+ const saver = (impl?: Save) => mock<Save>(impl ?? (() => {}));
13
+
14
+ describe('useCardDisplay', () => {
15
+ test('starts from `initial`, or large cards with their words', () => {
16
+ expect(renderHook(() => useCardDisplay({ save: () => {} })).result.current.value).toEqual({
17
+ size: 'large',
18
+ captions: true,
19
+ });
20
+ const { result } = renderHook(() =>
21
+ useCardDisplay({ save: () => {}, initial: () => ({ size: 'small', captions: false }) }),
22
+ );
23
+ expect(result.current.value).toEqual({ size: 'small', captions: false });
24
+ expect(result.current.ready).toBe(true);
25
+ });
26
+
27
+ test('draws a change at once and keeps only the SETTLED value of a burst, once', async () => {
28
+ const save = saver();
29
+ const { result } = renderHook(() => useCardDisplay({ save, debounceMs: WAIT }));
30
+ act(() => result.current.setValue({ size: 'medium', captions: true }));
31
+ act(() => result.current.setValue({ size: 'small', captions: true }));
32
+ act(() => result.current.setValue({ size: 'small', captions: false }));
33
+ expect(result.current.value).toEqual({ size: 'small', captions: false });
34
+ expect(save).not.toHaveBeenCalled();
35
+ await settle();
36
+ expect(save).toHaveBeenCalledTimes(1);
37
+ expect(save).toHaveBeenLastCalledWith({ size: 'small', captions: false }, { keepalive: false });
38
+ });
39
+
40
+ test('a burst that ends where it started saves nothing', async () => {
41
+ const save = saver();
42
+ const { result } = renderHook(() => useCardDisplay({ save, debounceMs: WAIT }));
43
+ act(() => result.current.setValue({ size: 'large', captions: false }));
44
+ act(() => result.current.setValue({ size: 'large', captions: true }));
45
+ await settle();
46
+ expect(save).not.toHaveBeenCalled();
47
+ });
48
+
49
+ test('adopts the stored choice, and a stored equal value is not written back', async () => {
50
+ const save = saver();
51
+ const { result } = renderHook(() =>
52
+ useCardDisplay({ save, debounceMs: WAIT, load: async () => ({ size: 'medium', captions: false }) }),
53
+ );
54
+ expect(result.current.ready).toBe(false);
55
+ await waitFor(() => expect(result.current.ready).toBe(true));
56
+ expect(result.current.value).toEqual({ size: 'medium', captions: false });
57
+ act(() => result.current.setValue({ size: 'small', captions: false }));
58
+ act(() => result.current.setValue({ size: 'medium', captions: false }));
59
+ await settle();
60
+ expect(save).not.toHaveBeenCalled();
61
+ });
62
+
63
+ test('🔴 a tap made while the stored choice is in flight wins over it', async () => {
64
+ let answer: (value: Partial<CardDisplay>) => void = () => {};
65
+ const save = saver();
66
+ const { result } = renderHook(() =>
67
+ useCardDisplay({
68
+ save,
69
+ debounceMs: WAIT,
70
+ load: () => new Promise<Partial<CardDisplay>>((resolve) => (answer = resolve)),
71
+ }),
72
+ );
73
+ act(() => result.current.setValue({ size: 'small', captions: true }));
74
+ await act(async () => answer({ size: 'medium', captions: false }));
75
+ await waitFor(() => expect(result.current.ready).toBe(true));
76
+ expect(result.current.value).toEqual({ size: 'small', captions: true });
77
+ await settle();
78
+ expect(save).toHaveBeenLastCalledWith({ size: 'small', captions: true }, { keepalive: false });
79
+ });
80
+
81
+ test('a malformed stored value is ignored field by field; a failed read keeps what is drawn', async () => {
82
+ const { result } = renderHook(() =>
83
+ useCardDisplay({ save: () => {}, load: async () => ({ size: 'huge' as never, captions: false }) }),
84
+ );
85
+ await waitFor(() => expect(result.current.value).toEqual({ size: 'large', captions: false }));
86
+
87
+ const failed = renderHook(() =>
88
+ useCardDisplay({ save: () => {}, load: () => Promise.reject(new Error('500')) }),
89
+ );
90
+ await waitFor(() => expect(failed.result.current.ready).toBe(true));
91
+ expect(failed.result.current.value).toEqual({ size: 'large', captions: true });
92
+ });
93
+
94
+ test('does not read until enabled', async () => {
95
+ const load = mock(async () => ({ size: 'small' as const }));
96
+ const { result, rerender } = renderHook(({ on }) => useCardDisplay({ save: () => {}, load, enabled: on }), {
97
+ initialProps: { on: false },
98
+ });
99
+ expect(result.current.ready).toBe(true);
100
+ expect(load).not.toHaveBeenCalled();
101
+ rerender({ on: true });
102
+ await waitFor(() => expect(result.current.value.size).toBe('small'));
103
+ expect(load).toHaveBeenCalledTimes(1);
104
+ });
105
+
106
+ test('🔴 a rejected save is not counted as kept — the same value goes again', async () => {
107
+ let fail = true;
108
+ const save = saver(() => (fail ? Promise.reject(new Error('offline')) : undefined));
109
+ const { result } = renderHook(() => useCardDisplay({ save, debounceMs: WAIT }));
110
+ act(() => result.current.setValue({ size: 'small', captions: true }));
111
+ await settle();
112
+ expect(save).toHaveBeenCalledTimes(1);
113
+ fail = false;
114
+ act(() => result.current.setValue({ size: 'small', captions: true }));
115
+ await settle();
116
+ expect(save).toHaveBeenCalledTimes(2);
117
+ });
118
+
119
+ test('a change still pending when the page hides or the view unmounts is kept, with keepalive', async () => {
120
+ const save = saver();
121
+ const { result, unmount } = renderHook(() => useCardDisplay({ save, debounceMs: 10_000 }));
122
+ act(() => result.current.setValue({ size: 'medium', captions: true }));
123
+ globalThis.dispatchEvent(new Event('pagehide'));
124
+ expect(save).toHaveBeenLastCalledWith({ size: 'medium', captions: true }, { keepalive: true });
125
+
126
+ act(() => result.current.setValue({ size: 'small', captions: true }));
127
+ unmount();
128
+ expect(save).toHaveBeenLastCalledWith({ size: 'small', captions: true }, { keepalive: true });
129
+ expect(save).toHaveBeenCalledTimes(2);
130
+ });
131
+ });