@dloizides/ui-carousel 0.1.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/CHANGELOG.md ADDED
@@ -0,0 +1,15 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ First release.
6
+
7
+ - `CardDeck` — one card at a time with a verdict row, a back control and a progress line.
8
+ - `useDeck` — all deck logic, no rendering: advance, complete, answered-once, keyboard routing.
9
+ - `Card` / `RankBar` — the presentation halves, both themed from the UiProvider.
10
+ - `Pressable` throughout (never `TouchableOpacity`, whose hover callbacks RN-web drops), with a
11
+ test that greps `src` so the regression cannot land silently.
12
+ - A single in-flight latch guards every verdict emission, so a keyboard activation plus the click
13
+ a browser synthesizes from it counts once. Asserted at both the hook and the component level.
14
+ - Keyboard: `1`..`9` rank, `Backspace` / `ArrowLeft` step back. Fully operable without a pointer.
15
+ - No colour literals in `src`, enforced by a test.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 dloizides
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,72 @@
1
+ # @dloizides/ui-carousel
2
+
3
+ A themable, brand-agnostic React Native (RN-web) **card deck with per-card ranking**: one card at
4
+ a time, a row of verdict buttons, and a keyboard path that reaches every one of them.
5
+
6
+ ```tsx
7
+ import { CardDeck } from '@dloizides/ui-carousel';
8
+
9
+ <CardDeck
10
+ cards={cards}
11
+ verdicts={['loved', 'liked', 'bounced', 'never']}
12
+ onVerdict={(cardId, verdict) => record(cardId, verdict)}
13
+ onComplete={() => goToResults()}
14
+ testID="quiz-deck"
15
+ labels={{
16
+ verdictLabels: { loved: FM('quiz.loved'), liked: FM('quiz.liked') },
17
+ verdictHints: { loved: FM('quiz.lovedHint'), liked: FM('quiz.likedHint') },
18
+ backLabel: FM('quiz.back'),
19
+ backHint: FM('quiz.backHint'),
20
+ progress: (position, total) => FM('quiz.progress', String(position), String(total)),
21
+ }}
22
+ />;
23
+ ```
24
+
25
+ ## Why this is a package and not app code
26
+
27
+ Two RN-web traps have cost this estate real defects. Both are handled once, here:
28
+
29
+ 1. **`TouchableOpacity` silently drops `onHoverIn` / `onHoverOut` on RN-web.** A hover affordance
30
+ written that way is dead on the web build with nothing in the console to find it by. This
31
+ package uses `Pressable` (via `@dloizides/ui-motion`'s `PressableScale`) and a test greps `src`
32
+ to prove `TouchableOpacity` never comes back.
33
+ 2. **Keyboard activation does not arrive as `onPress`.** The browser can deliver the key event AND
34
+ synthesize a click from the same press, which lands two verdicts in one tick. Every emission
35
+ passes a single in-flight latch in `useDeck` that is released only after React commits the
36
+ advance, so the second path is a no-op. `useDeck.test.ts` asserts the double-fire case directly.
37
+
38
+ ## Keyboard contract
39
+
40
+ | Key | Effect |
41
+ |---|---|
42
+ | `1` .. `9` | rank the current card with the nth verdict (**verdict order is the contract**) |
43
+ | `Backspace`, `ArrowLeft` | step back one card |
44
+
45
+ A card is ranked **exactly once**. Stepping back shows a ranked card, it does not reopen it, so
46
+ navigating back and forth cannot re-emit a verdict the caller has already recorded.
47
+
48
+ ## Theming and copy
49
+
50
+ Colours come from the app's UiProvider theme (`@dloizides/ui-feedback`, fed by
51
+ `@dloizides/design-tokens`). There is not one colour literal in `src`, and a test enforces it.
52
+
53
+ The kit has no i18n. Every string arrives already localized through `labels`; a missing entry falls
54
+ back to the verdict id, which is machine-readable rather than untranslated English.
55
+
56
+ ## Accessibility
57
+
58
+ Every interactive control carries `testID` + `accessibilityLabel` + `accessibilityHint`, and the
59
+ deck is fully operable by keyboard alone. A deck that only works by swipe is not accessible.
60
+
61
+ ## Exports
62
+
63
+ `CardDeck` · `Card` · `RankBar` · `useDeck` · `DECK_TEST_ID_SUFFIX` · `digitToVerdictIndex`
64
+ · types `CardDeckProps`, `DeckCard`, `DeckLabels`, `UseDeckOptions`, `UseDeckResult`.
65
+
66
+ ## Peer dependencies
67
+
68
+ `react` >= 18 · `react-native` >= 0.74 · `@dloizides/ui-feedback` >= 1.2 (the theme provider).
69
+
70
+ ## License
71
+
72
+ MIT
@@ -0,0 +1,159 @@
1
+ import React from 'react';
2
+
3
+ /**
4
+ * Public shapes for `@dloizides/ui-carousel`.
5
+ *
6
+ * `CardDeckProps` is fixed by the consuming spec. Every extra prop is OPTIONAL, so a
7
+ * caller that knows only the five required fields keeps compiling.
8
+ */
9
+ /** One card in the deck. Presentation only: the deck never fetches anything. */
10
+ interface DeckCard {
11
+ id: string;
12
+ title: string;
13
+ imageUrl?: string;
14
+ subtitle?: string;
15
+ }
16
+ /**
17
+ * Caller-supplied, already-localized copy. The kit has no i18n of its own (apps own
18
+ * `FM()`), so every string the deck renders arrives from here. A missing entry falls
19
+ * back to the verdict id, which is machine-readable rather than English prose.
20
+ */
21
+ interface DeckLabels {
22
+ /** Per-verdict button label, keyed by verdict id. */
23
+ verdictLabels?: Readonly<Record<string, string>>;
24
+ /** Per-verdict `accessibilityHint`, keyed by verdict id. */
25
+ verdictHints?: Readonly<Record<string, string>>;
26
+ /** Label for the "go back one card" control. */
27
+ backLabel?: string;
28
+ /** Hint for the "go back one card" control. */
29
+ backHint?: string;
30
+ /** Progress line, e.g. "3 / 12". Receives the 1-based position and the total. */
31
+ progress?: (position: number, total: number) => string;
32
+ /** Label announced on the card region itself. Receives the card title. */
33
+ cardLabel?: (title: string) => string;
34
+ /** Hint announced on the card region (how to rank it). */
35
+ cardHint?: string;
36
+ }
37
+ interface CardDeckProps {
38
+ cards: DeckCard[];
39
+ /** Ordered verdict ids. Keyboard 1..n selects the nth, so ORDER IS THE CONTRACT. */
40
+ verdicts: readonly string[];
41
+ onVerdict: (cardId: string, verdict: string) => void;
42
+ onComplete: () => void;
43
+ testID: string;
44
+ /** Already-localized copy. Optional: verdict ids are used when a string is missing. */
45
+ labels?: DeckLabels;
46
+ }
47
+
48
+ /**
49
+ * CardDeck — a ranked card deck that is fully operable by keyboard alone.
50
+ *
51
+ * `1..n` rank the current card with the nth verdict, `Backspace` / `ArrowLeft` step
52
+ * back. The listener is attached to the document on web, so the deck works without
53
+ * the user having to tab into it first; every verdict still routes through the same
54
+ * `useDeck.submitVerdict`, which holds the single in-flight latch. That is what stops
55
+ * a browser-synthesized click from turning one key press into two verdicts.
56
+ *
57
+ * Colours come from the app UiProvider theme (`@dloizides/design-tokens`). There is not
58
+ * one colour literal in this package.
59
+ */
60
+
61
+ declare const CardDeck: ({ cards, verdicts, onVerdict, onComplete, testID, labels, }: CardDeckProps) => React.ReactElement;
62
+
63
+ /**
64
+ * Card — the presentation face of one deck entry.
65
+ *
66
+ * Deliberately NOT interactive: ranking happens in `RankBar`, so there is exactly one
67
+ * place a verdict can be emitted from. `imageUrl` is optional and a missing cover
68
+ * renders as a typographic card rather than a broken image box.
69
+ */
70
+
71
+ interface CardProps {
72
+ card: DeckCard;
73
+ testID: string;
74
+ imageTestID: string;
75
+ /** Already-localized region label. Falls back to the card title. */
76
+ accessibilityLabel?: string;
77
+ /** Already-localized region hint telling the user how to rank. */
78
+ accessibilityHint?: string;
79
+ }
80
+ declare const Card: ({ card, testID, imageTestID, accessibilityLabel, accessibilityHint, }: CardProps) => React.ReactElement;
81
+
82
+ /**
83
+ * RankBar — the verdict buttons, the back control and the progress line.
84
+ *
85
+ * Built on `PressableScale` (a `Pressable`), NEVER `TouchableOpacity`: RN-web silently
86
+ * drops `onHoverIn` / `onHoverOut` on TouchableOpacity, so a hover affordance written
87
+ * that way is dead on the web build with no error to find it by.
88
+ *
89
+ * Every control carries testID + accessibilityLabel + accessibilityHint. The labels are
90
+ * caller-supplied and already localized; a missing one falls back to the verdict id.
91
+ */
92
+
93
+ interface RankBarProps {
94
+ verdicts: readonly string[];
95
+ labels?: DeckLabels;
96
+ onSelect: (verdict: string) => void;
97
+ onBack: () => void;
98
+ canGoBack: boolean;
99
+ /** 1-based position of the current card. */
100
+ position: number;
101
+ total: number;
102
+ testID: string;
103
+ backTestID: string;
104
+ progressTestID: string;
105
+ verdictTestIDPrefix: string;
106
+ }
107
+ declare const RankBar: ({ verdicts, labels, onSelect, onBack, canGoBack, position, total, testID, backTestID, progressTestID, verdictTestIDPrefix, }: RankBarProps) => React.ReactElement;
108
+
109
+ interface UseDeckOptions {
110
+ cards: DeckCard[];
111
+ /** Ordered verdict ids. Keyboard 1..n selects the nth. */
112
+ verdicts: readonly string[];
113
+ onVerdict: (cardId: string, verdict: string) => void;
114
+ onComplete: () => void;
115
+ }
116
+ interface UseDeckResult {
117
+ /** 0-based position. Equals `cards.length` once the deck is finished. */
118
+ index: number;
119
+ /** The card awaiting a verdict, or `undefined` when the deck is finished. */
120
+ current: DeckCard | undefined;
121
+ total: number;
122
+ isComplete: boolean;
123
+ /** cardId to verdict, for every card ranked so far. */
124
+ answered: Readonly<Record<string, string>>;
125
+ /** Rank the current card. Ignored when it is already ranked or an emission is in flight. */
126
+ submitVerdict: (verdict: string) => void;
127
+ /** Step back one card. A no-op on the first card. */
128
+ goBack: () => void;
129
+ /** Route a raw key name from keydown. Returns true when the deck consumed it. */
130
+ handleKey: (key: string) => boolean;
131
+ /**
132
+ * Release the keyboard-activation latch. Bind to keyup: the click a browser
133
+ * synthesizes from a key press arrives BEFORE keyup, which is what makes this the
134
+ * window that suppresses it.
135
+ */
136
+ releaseKeyboardActivation: () => void;
137
+ }
138
+ declare function useDeck({ cards, verdicts, onVerdict, onComplete }: UseDeckOptions): UseDeckResult;
139
+
140
+ /** Test ids, key names and layout metrics for the deck. Nothing here is a colour. */
141
+ /** Stable test id suffixes. Each is appended to the caller-supplied `testID`. */
142
+ declare const DECK_TEST_ID_SUFFIX: {
143
+ readonly card: "card";
144
+ readonly image: "card-image";
145
+ readonly rankBar: "rank-bar";
146
+ readonly verdict: "verdict";
147
+ readonly back: "back";
148
+ readonly progress: "progress";
149
+ readonly complete: "complete";
150
+ };
151
+ /** Keys that navigate rather than rank. */
152
+ declare const KEY_BACK = "Backspace";
153
+ declare const KEY_ARROW_LEFT = "ArrowLeft";
154
+ /** Digit keys 1..9 pick the nth verdict. The deck never exposes more than nine. */
155
+ declare const MAX_KEYBOARD_VERDICTS = 9;
156
+ /** Parse a 1..9 digit key into a 0-based verdict index, or -1 when it is not one. */
157
+ declare const digitToVerdictIndex: (key: string) => number;
158
+
159
+ export { Card, CardDeck, type CardDeckProps, type CardProps, DECK_TEST_ID_SUFFIX, type DeckCard, type DeckLabels, KEY_ARROW_LEFT, KEY_BACK, MAX_KEYBOARD_VERDICTS, RankBar, type RankBarProps, type UseDeckOptions, type UseDeckResult, digitToVerdictIndex, useDeck };
@@ -0,0 +1,159 @@
1
+ import React from 'react';
2
+
3
+ /**
4
+ * Public shapes for `@dloizides/ui-carousel`.
5
+ *
6
+ * `CardDeckProps` is fixed by the consuming spec. Every extra prop is OPTIONAL, so a
7
+ * caller that knows only the five required fields keeps compiling.
8
+ */
9
+ /** One card in the deck. Presentation only: the deck never fetches anything. */
10
+ interface DeckCard {
11
+ id: string;
12
+ title: string;
13
+ imageUrl?: string;
14
+ subtitle?: string;
15
+ }
16
+ /**
17
+ * Caller-supplied, already-localized copy. The kit has no i18n of its own (apps own
18
+ * `FM()`), so every string the deck renders arrives from here. A missing entry falls
19
+ * back to the verdict id, which is machine-readable rather than English prose.
20
+ */
21
+ interface DeckLabels {
22
+ /** Per-verdict button label, keyed by verdict id. */
23
+ verdictLabels?: Readonly<Record<string, string>>;
24
+ /** Per-verdict `accessibilityHint`, keyed by verdict id. */
25
+ verdictHints?: Readonly<Record<string, string>>;
26
+ /** Label for the "go back one card" control. */
27
+ backLabel?: string;
28
+ /** Hint for the "go back one card" control. */
29
+ backHint?: string;
30
+ /** Progress line, e.g. "3 / 12". Receives the 1-based position and the total. */
31
+ progress?: (position: number, total: number) => string;
32
+ /** Label announced on the card region itself. Receives the card title. */
33
+ cardLabel?: (title: string) => string;
34
+ /** Hint announced on the card region (how to rank it). */
35
+ cardHint?: string;
36
+ }
37
+ interface CardDeckProps {
38
+ cards: DeckCard[];
39
+ /** Ordered verdict ids. Keyboard 1..n selects the nth, so ORDER IS THE CONTRACT. */
40
+ verdicts: readonly string[];
41
+ onVerdict: (cardId: string, verdict: string) => void;
42
+ onComplete: () => void;
43
+ testID: string;
44
+ /** Already-localized copy. Optional: verdict ids are used when a string is missing. */
45
+ labels?: DeckLabels;
46
+ }
47
+
48
+ /**
49
+ * CardDeck — a ranked card deck that is fully operable by keyboard alone.
50
+ *
51
+ * `1..n` rank the current card with the nth verdict, `Backspace` / `ArrowLeft` step
52
+ * back. The listener is attached to the document on web, so the deck works without
53
+ * the user having to tab into it first; every verdict still routes through the same
54
+ * `useDeck.submitVerdict`, which holds the single in-flight latch. That is what stops
55
+ * a browser-synthesized click from turning one key press into two verdicts.
56
+ *
57
+ * Colours come from the app UiProvider theme (`@dloizides/design-tokens`). There is not
58
+ * one colour literal in this package.
59
+ */
60
+
61
+ declare const CardDeck: ({ cards, verdicts, onVerdict, onComplete, testID, labels, }: CardDeckProps) => React.ReactElement;
62
+
63
+ /**
64
+ * Card — the presentation face of one deck entry.
65
+ *
66
+ * Deliberately NOT interactive: ranking happens in `RankBar`, so there is exactly one
67
+ * place a verdict can be emitted from. `imageUrl` is optional and a missing cover
68
+ * renders as a typographic card rather than a broken image box.
69
+ */
70
+
71
+ interface CardProps {
72
+ card: DeckCard;
73
+ testID: string;
74
+ imageTestID: string;
75
+ /** Already-localized region label. Falls back to the card title. */
76
+ accessibilityLabel?: string;
77
+ /** Already-localized region hint telling the user how to rank. */
78
+ accessibilityHint?: string;
79
+ }
80
+ declare const Card: ({ card, testID, imageTestID, accessibilityLabel, accessibilityHint, }: CardProps) => React.ReactElement;
81
+
82
+ /**
83
+ * RankBar — the verdict buttons, the back control and the progress line.
84
+ *
85
+ * Built on `PressableScale` (a `Pressable`), NEVER `TouchableOpacity`: RN-web silently
86
+ * drops `onHoverIn` / `onHoverOut` on TouchableOpacity, so a hover affordance written
87
+ * that way is dead on the web build with no error to find it by.
88
+ *
89
+ * Every control carries testID + accessibilityLabel + accessibilityHint. The labels are
90
+ * caller-supplied and already localized; a missing one falls back to the verdict id.
91
+ */
92
+
93
+ interface RankBarProps {
94
+ verdicts: readonly string[];
95
+ labels?: DeckLabels;
96
+ onSelect: (verdict: string) => void;
97
+ onBack: () => void;
98
+ canGoBack: boolean;
99
+ /** 1-based position of the current card. */
100
+ position: number;
101
+ total: number;
102
+ testID: string;
103
+ backTestID: string;
104
+ progressTestID: string;
105
+ verdictTestIDPrefix: string;
106
+ }
107
+ declare const RankBar: ({ verdicts, labels, onSelect, onBack, canGoBack, position, total, testID, backTestID, progressTestID, verdictTestIDPrefix, }: RankBarProps) => React.ReactElement;
108
+
109
+ interface UseDeckOptions {
110
+ cards: DeckCard[];
111
+ /** Ordered verdict ids. Keyboard 1..n selects the nth. */
112
+ verdicts: readonly string[];
113
+ onVerdict: (cardId: string, verdict: string) => void;
114
+ onComplete: () => void;
115
+ }
116
+ interface UseDeckResult {
117
+ /** 0-based position. Equals `cards.length` once the deck is finished. */
118
+ index: number;
119
+ /** The card awaiting a verdict, or `undefined` when the deck is finished. */
120
+ current: DeckCard | undefined;
121
+ total: number;
122
+ isComplete: boolean;
123
+ /** cardId to verdict, for every card ranked so far. */
124
+ answered: Readonly<Record<string, string>>;
125
+ /** Rank the current card. Ignored when it is already ranked or an emission is in flight. */
126
+ submitVerdict: (verdict: string) => void;
127
+ /** Step back one card. A no-op on the first card. */
128
+ goBack: () => void;
129
+ /** Route a raw key name from keydown. Returns true when the deck consumed it. */
130
+ handleKey: (key: string) => boolean;
131
+ /**
132
+ * Release the keyboard-activation latch. Bind to keyup: the click a browser
133
+ * synthesizes from a key press arrives BEFORE keyup, which is what makes this the
134
+ * window that suppresses it.
135
+ */
136
+ releaseKeyboardActivation: () => void;
137
+ }
138
+ declare function useDeck({ cards, verdicts, onVerdict, onComplete }: UseDeckOptions): UseDeckResult;
139
+
140
+ /** Test ids, key names and layout metrics for the deck. Nothing here is a colour. */
141
+ /** Stable test id suffixes. Each is appended to the caller-supplied `testID`. */
142
+ declare const DECK_TEST_ID_SUFFIX: {
143
+ readonly card: "card";
144
+ readonly image: "card-image";
145
+ readonly rankBar: "rank-bar";
146
+ readonly verdict: "verdict";
147
+ readonly back: "back";
148
+ readonly progress: "progress";
149
+ readonly complete: "complete";
150
+ };
151
+ /** Keys that navigate rather than rank. */
152
+ declare const KEY_BACK = "Backspace";
153
+ declare const KEY_ARROW_LEFT = "ArrowLeft";
154
+ /** Digit keys 1..9 pick the nth verdict. The deck never exposes more than nine. */
155
+ declare const MAX_KEYBOARD_VERDICTS = 9;
156
+ /** Parse a 1..9 digit key into a 0-based verdict index, or -1 when it is not one. */
157
+ declare const digitToVerdictIndex: (key: string) => number;
158
+
159
+ export { Card, CardDeck, type CardDeckProps, type CardProps, DECK_TEST_ID_SUFFIX, type DeckCard, type DeckLabels, KEY_ARROW_LEFT, KEY_BACK, MAX_KEYBOARD_VERDICTS, RankBar, type RankBarProps, type UseDeckOptions, type UseDeckResult, digitToVerdictIndex, useDeck };