@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 +15 -0
- package/LICENSE +21 -0
- package/README.md +72 -0
- package/dist/index.d.mts +159 -0
- package/dist/index.d.ts +159 -0
- package/dist/index.js +380 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +370 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +112 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
|
2
|
+
import { StyleSheet, View, Image, Text } from 'react-native';
|
|
3
|
+
import { useUi } from '@dloizides/ui-feedback';
|
|
4
|
+
import { jsxs, jsx } from 'react/jsx-runtime';
|
|
5
|
+
import { PressableScale } from '@dloizides/ui-motion';
|
|
6
|
+
|
|
7
|
+
// src/CardDeck.tsx
|
|
8
|
+
|
|
9
|
+
// src/constants.ts
|
|
10
|
+
var DECK_TEST_ID_SUFFIX = {
|
|
11
|
+
card: "card",
|
|
12
|
+
image: "card-image",
|
|
13
|
+
rankBar: "rank-bar",
|
|
14
|
+
verdict: "verdict",
|
|
15
|
+
back: "back",
|
|
16
|
+
progress: "progress",
|
|
17
|
+
complete: "complete"
|
|
18
|
+
};
|
|
19
|
+
var KEY_BACK = "Backspace";
|
|
20
|
+
var KEY_ARROW_LEFT = "ArrowLeft";
|
|
21
|
+
var MAX_KEYBOARD_VERDICTS = 9;
|
|
22
|
+
var DIGIT_RADIX = 10;
|
|
23
|
+
var digitToVerdictIndex = (key) => {
|
|
24
|
+
if (key.length !== 1) {
|
|
25
|
+
return -1;
|
|
26
|
+
}
|
|
27
|
+
const parsed = Number.parseInt(key, DIGIT_RADIX);
|
|
28
|
+
const isVerdictDigit = Number.isInteger(parsed) && parsed >= 1 && parsed <= MAX_KEYBOARD_VERDICTS;
|
|
29
|
+
return isVerdictDigit ? parsed - 1 : -1;
|
|
30
|
+
};
|
|
31
|
+
var CARD_RADIUS = 12;
|
|
32
|
+
var CARD_PADDING = 16;
|
|
33
|
+
var CARD_GAP = 12;
|
|
34
|
+
var CARD_BORDER_WIDTH = 1;
|
|
35
|
+
var CARD_IMAGE_HEIGHT = 220;
|
|
36
|
+
var TITLE_FONT_SIZE = 20;
|
|
37
|
+
var SUBTITLE_FONT_SIZE = 14;
|
|
38
|
+
var PROGRESS_FONT_SIZE = 13;
|
|
39
|
+
var VERDICT_FONT_SIZE = 15;
|
|
40
|
+
var VERDICT_MIN_HEIGHT = 48;
|
|
41
|
+
var VERDICT_PADDING_H = 14;
|
|
42
|
+
var styles = StyleSheet.create({
|
|
43
|
+
image: { borderRadius: CARD_RADIUS, height: CARD_IMAGE_HEIGHT, width: "100%" },
|
|
44
|
+
root: {
|
|
45
|
+
borderRadius: CARD_RADIUS,
|
|
46
|
+
borderWidth: CARD_BORDER_WIDTH,
|
|
47
|
+
gap: CARD_GAP,
|
|
48
|
+
padding: CARD_PADDING
|
|
49
|
+
},
|
|
50
|
+
subtitle: { fontSize: SUBTITLE_FONT_SIZE },
|
|
51
|
+
title: { fontSize: TITLE_FONT_SIZE, fontWeight: "700" }
|
|
52
|
+
});
|
|
53
|
+
var Card = ({
|
|
54
|
+
card,
|
|
55
|
+
testID,
|
|
56
|
+
imageTestID,
|
|
57
|
+
accessibilityLabel,
|
|
58
|
+
accessibilityHint
|
|
59
|
+
}) => {
|
|
60
|
+
const { theme } = useUi();
|
|
61
|
+
const label = accessibilityLabel ?? card.title;
|
|
62
|
+
return /* @__PURE__ */ jsxs(
|
|
63
|
+
View,
|
|
64
|
+
{
|
|
65
|
+
accessible: true,
|
|
66
|
+
accessibilityHint,
|
|
67
|
+
accessibilityLabel: label,
|
|
68
|
+
style: [
|
|
69
|
+
styles.root,
|
|
70
|
+
{ backgroundColor: theme.colors.surface, borderColor: theme.colors.border }
|
|
71
|
+
],
|
|
72
|
+
testID,
|
|
73
|
+
children: [
|
|
74
|
+
card.imageUrl !== void 0 && /* @__PURE__ */ jsx(
|
|
75
|
+
Image,
|
|
76
|
+
{
|
|
77
|
+
accessibilityIgnoresInvertColors: true,
|
|
78
|
+
accessibilityLabel: label,
|
|
79
|
+
source: { uri: card.imageUrl },
|
|
80
|
+
style: styles.image,
|
|
81
|
+
testID: imageTestID
|
|
82
|
+
}
|
|
83
|
+
),
|
|
84
|
+
/* @__PURE__ */ jsx(Text, { style: [styles.title, { color: theme.colors.text }], children: card.title }),
|
|
85
|
+
card.subtitle !== void 0 && /* @__PURE__ */ jsx(Text, { style: [styles.subtitle, { color: theme.colors.textSecondary }], children: card.subtitle })
|
|
86
|
+
]
|
|
87
|
+
}
|
|
88
|
+
);
|
|
89
|
+
};
|
|
90
|
+
var HOVER_OPACITY = 0.85;
|
|
91
|
+
var FULL_OPACITY = 1;
|
|
92
|
+
var styles2 = StyleSheet.create({
|
|
93
|
+
button: {
|
|
94
|
+
alignItems: "center",
|
|
95
|
+
borderRadius: CARD_RADIUS,
|
|
96
|
+
borderWidth: CARD_BORDER_WIDTH,
|
|
97
|
+
justifyContent: "center",
|
|
98
|
+
minHeight: VERDICT_MIN_HEIGHT,
|
|
99
|
+
paddingHorizontal: VERDICT_PADDING_H
|
|
100
|
+
},
|
|
101
|
+
buttonText: { fontSize: VERDICT_FONT_SIZE, fontWeight: "600" },
|
|
102
|
+
progress: { fontSize: PROGRESS_FONT_SIZE },
|
|
103
|
+
root: { gap: CARD_GAP },
|
|
104
|
+
row: { flexDirection: "row", flexWrap: "wrap", gap: CARD_GAP }
|
|
105
|
+
});
|
|
106
|
+
var VerdictButton = ({
|
|
107
|
+
verdict,
|
|
108
|
+
label,
|
|
109
|
+
hint,
|
|
110
|
+
testID,
|
|
111
|
+
onSelect
|
|
112
|
+
}) => {
|
|
113
|
+
const { theme } = useUi();
|
|
114
|
+
const [hovered, setHovered] = useState(false);
|
|
115
|
+
const handleHoverIn = useCallback(() => setHovered(true), []);
|
|
116
|
+
const handleHoverOut = useCallback(() => setHovered(false), []);
|
|
117
|
+
const handlePress = useCallback(() => onSelect(verdict), [onSelect, verdict]);
|
|
118
|
+
return /* @__PURE__ */ jsx(
|
|
119
|
+
PressableScale,
|
|
120
|
+
{
|
|
121
|
+
accessibilityHint: hint,
|
|
122
|
+
accessibilityLabel: label,
|
|
123
|
+
accessibilityRole: "button",
|
|
124
|
+
onHoverIn: handleHoverIn,
|
|
125
|
+
onHoverOut: handleHoverOut,
|
|
126
|
+
onPress: handlePress,
|
|
127
|
+
testID,
|
|
128
|
+
innerStyle: [
|
|
129
|
+
styles2.button,
|
|
130
|
+
{
|
|
131
|
+
backgroundColor: hovered ? theme.colors.surfaceElevated : theme.colors.surface,
|
|
132
|
+
borderColor: theme.colors.border,
|
|
133
|
+
opacity: hovered ? HOVER_OPACITY : FULL_OPACITY
|
|
134
|
+
}
|
|
135
|
+
],
|
|
136
|
+
children: /* @__PURE__ */ jsx(Text, { style: [styles2.buttonText, { color: theme.colors.text }], children: label })
|
|
137
|
+
}
|
|
138
|
+
);
|
|
139
|
+
};
|
|
140
|
+
var RankBar = ({
|
|
141
|
+
verdicts,
|
|
142
|
+
labels,
|
|
143
|
+
onSelect,
|
|
144
|
+
onBack,
|
|
145
|
+
canGoBack,
|
|
146
|
+
position,
|
|
147
|
+
total,
|
|
148
|
+
testID,
|
|
149
|
+
backTestID,
|
|
150
|
+
progressTestID,
|
|
151
|
+
verdictTestIDPrefix
|
|
152
|
+
}) => {
|
|
153
|
+
const { theme } = useUi();
|
|
154
|
+
const progressText = labels?.progress?.(position, total) ?? `${position} / ${total}`;
|
|
155
|
+
return /* @__PURE__ */ jsxs(View, { style: styles2.root, testID, children: [
|
|
156
|
+
/* @__PURE__ */ jsx(
|
|
157
|
+
Text,
|
|
158
|
+
{
|
|
159
|
+
style: [styles2.progress, { color: theme.colors.textSecondary }],
|
|
160
|
+
testID: progressTestID,
|
|
161
|
+
children: progressText
|
|
162
|
+
}
|
|
163
|
+
),
|
|
164
|
+
/* @__PURE__ */ jsx(View, { style: styles2.row, children: verdicts.map((verdict) => /* @__PURE__ */ jsx(
|
|
165
|
+
VerdictButton,
|
|
166
|
+
{
|
|
167
|
+
hint: labels?.verdictHints?.[verdict] ?? verdict,
|
|
168
|
+
label: labels?.verdictLabels?.[verdict] ?? verdict,
|
|
169
|
+
onSelect,
|
|
170
|
+
testID: `${verdictTestIDPrefix}-${verdict}`,
|
|
171
|
+
verdict
|
|
172
|
+
},
|
|
173
|
+
verdict
|
|
174
|
+
)) }),
|
|
175
|
+
canGoBack && /* @__PURE__ */ jsx(
|
|
176
|
+
PressableScale,
|
|
177
|
+
{
|
|
178
|
+
accessibilityHint: labels?.backHint ?? labels?.backLabel ?? KEY_BACK_FALLBACK,
|
|
179
|
+
accessibilityLabel: labels?.backLabel ?? KEY_BACK_FALLBACK,
|
|
180
|
+
accessibilityRole: "button",
|
|
181
|
+
innerStyle: [styles2.button, { borderColor: theme.colors.border }],
|
|
182
|
+
onPress: onBack,
|
|
183
|
+
testID: backTestID,
|
|
184
|
+
children: /* @__PURE__ */ jsx(Text, { style: [styles2.buttonText, { color: theme.colors.textSecondary }], children: labels?.backLabel ?? KEY_BACK_FALLBACK })
|
|
185
|
+
}
|
|
186
|
+
)
|
|
187
|
+
] });
|
|
188
|
+
};
|
|
189
|
+
var KEY_BACK_FALLBACK = "back";
|
|
190
|
+
function useDeck({ cards, verdicts, onVerdict, onComplete }) {
|
|
191
|
+
const [index, setIndex] = useState(0);
|
|
192
|
+
const [answered, setAnswered] = useState({});
|
|
193
|
+
const indexRef = useRef(0);
|
|
194
|
+
const answeredRef = useRef({});
|
|
195
|
+
const inFlightRef = useRef(null);
|
|
196
|
+
const keyboardActivationRef = useRef(false);
|
|
197
|
+
const completedRef = useRef(false);
|
|
198
|
+
const total = cards.length;
|
|
199
|
+
const isComplete = index >= total;
|
|
200
|
+
useEffect(() => {
|
|
201
|
+
inFlightRef.current = null;
|
|
202
|
+
});
|
|
203
|
+
useEffect(() => {
|
|
204
|
+
if (!isComplete || completedRef.current) {
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
completedRef.current = true;
|
|
208
|
+
onComplete();
|
|
209
|
+
}, [isComplete, onComplete]);
|
|
210
|
+
const emit = useCallback(
|
|
211
|
+
(verdict) => {
|
|
212
|
+
if (inFlightRef.current !== null) {
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
const card = cards[indexRef.current];
|
|
216
|
+
if (card === void 0 || !verdicts.includes(verdict)) {
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (answeredRef.current[card.id] !== void 0) {
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
inFlightRef.current = card.id;
|
|
223
|
+
const nextAnswered = { ...answeredRef.current, [card.id]: verdict };
|
|
224
|
+
answeredRef.current = nextAnswered;
|
|
225
|
+
indexRef.current += 1;
|
|
226
|
+
setAnswered(nextAnswered);
|
|
227
|
+
setIndex(indexRef.current);
|
|
228
|
+
onVerdict(card.id, verdict);
|
|
229
|
+
},
|
|
230
|
+
[cards, verdicts, onVerdict]
|
|
231
|
+
);
|
|
232
|
+
const submitVerdict = useCallback(
|
|
233
|
+
(verdict) => {
|
|
234
|
+
if (keyboardActivationRef.current) {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
emit(verdict);
|
|
238
|
+
},
|
|
239
|
+
[emit]
|
|
240
|
+
);
|
|
241
|
+
const releaseKeyboardActivation = useCallback(() => {
|
|
242
|
+
keyboardActivationRef.current = false;
|
|
243
|
+
}, []);
|
|
244
|
+
const goBack = useCallback(() => {
|
|
245
|
+
if (indexRef.current === 0) {
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
indexRef.current -= 1;
|
|
249
|
+
setIndex(indexRef.current);
|
|
250
|
+
}, []);
|
|
251
|
+
const handleKey = useCallback(
|
|
252
|
+
(key) => {
|
|
253
|
+
if (key === KEY_BACK || key === KEY_ARROW_LEFT) {
|
|
254
|
+
goBack();
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
const verdictIndex = digitToVerdictIndex(key);
|
|
258
|
+
if (verdictIndex < 0) {
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
const verdict = verdicts[verdictIndex];
|
|
262
|
+
if (verdict === void 0) {
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
keyboardActivationRef.current = true;
|
|
266
|
+
emit(verdict);
|
|
267
|
+
return true;
|
|
268
|
+
},
|
|
269
|
+
[goBack, emit, verdicts]
|
|
270
|
+
);
|
|
271
|
+
return useMemo(
|
|
272
|
+
() => ({
|
|
273
|
+
index,
|
|
274
|
+
current: cards[index],
|
|
275
|
+
total,
|
|
276
|
+
isComplete,
|
|
277
|
+
answered,
|
|
278
|
+
submitVerdict,
|
|
279
|
+
goBack,
|
|
280
|
+
handleKey,
|
|
281
|
+
releaseKeyboardActivation
|
|
282
|
+
}),
|
|
283
|
+
[
|
|
284
|
+
index,
|
|
285
|
+
cards,
|
|
286
|
+
total,
|
|
287
|
+
isComplete,
|
|
288
|
+
answered,
|
|
289
|
+
submitVerdict,
|
|
290
|
+
goBack,
|
|
291
|
+
handleKey,
|
|
292
|
+
releaseKeyboardActivation
|
|
293
|
+
]
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
var styles3 = StyleSheet.create({
|
|
297
|
+
root: { gap: CARD_GAP }
|
|
298
|
+
});
|
|
299
|
+
var FIRST_POSITION_OFFSET = 1;
|
|
300
|
+
var CardDeck = ({
|
|
301
|
+
cards,
|
|
302
|
+
verdicts,
|
|
303
|
+
onVerdict,
|
|
304
|
+
onComplete,
|
|
305
|
+
testID,
|
|
306
|
+
labels
|
|
307
|
+
}) => {
|
|
308
|
+
const { theme } = useUi();
|
|
309
|
+
const deck = useDeck({ cards, verdicts, onVerdict, onComplete });
|
|
310
|
+
const { handleKey, releaseKeyboardActivation } = deck;
|
|
311
|
+
useEffect(() => {
|
|
312
|
+
if (typeof document === "undefined") {
|
|
313
|
+
return void 0;
|
|
314
|
+
}
|
|
315
|
+
const onKeyDown = (event) => {
|
|
316
|
+
if (handleKey(event.key)) {
|
|
317
|
+
event.preventDefault();
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
const onKeyUp = () => releaseKeyboardActivation();
|
|
321
|
+
document.addEventListener("keydown", onKeyDown);
|
|
322
|
+
document.addEventListener("keyup", onKeyUp);
|
|
323
|
+
return () => {
|
|
324
|
+
document.removeEventListener("keydown", onKeyDown);
|
|
325
|
+
document.removeEventListener("keyup", onKeyUp);
|
|
326
|
+
};
|
|
327
|
+
}, [handleKey, releaseKeyboardActivation]);
|
|
328
|
+
if (deck.current === void 0) {
|
|
329
|
+
return /* @__PURE__ */ jsx(
|
|
330
|
+
View,
|
|
331
|
+
{
|
|
332
|
+
accessibilityLabel: labels?.cardHint,
|
|
333
|
+
style: [styles3.root, { backgroundColor: theme.colors.background }],
|
|
334
|
+
testID: `${testID}-${DECK_TEST_ID_SUFFIX.complete}`
|
|
335
|
+
}
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
return /* @__PURE__ */ jsxs(View, { style: [styles3.root, { backgroundColor: theme.colors.background }], testID, children: [
|
|
339
|
+
/* @__PURE__ */ jsx(
|
|
340
|
+
Card,
|
|
341
|
+
{
|
|
342
|
+
accessibilityHint: labels?.cardHint,
|
|
343
|
+
accessibilityLabel: labels?.cardLabel?.(deck.current.title),
|
|
344
|
+
card: deck.current,
|
|
345
|
+
imageTestID: `${testID}-${DECK_TEST_ID_SUFFIX.image}`,
|
|
346
|
+
testID: `${testID}-${DECK_TEST_ID_SUFFIX.card}`
|
|
347
|
+
}
|
|
348
|
+
),
|
|
349
|
+
/* @__PURE__ */ jsx(
|
|
350
|
+
RankBar,
|
|
351
|
+
{
|
|
352
|
+
backTestID: `${testID}-${DECK_TEST_ID_SUFFIX.back}`,
|
|
353
|
+
canGoBack: deck.index > 0,
|
|
354
|
+
labels,
|
|
355
|
+
onBack: deck.goBack,
|
|
356
|
+
onSelect: deck.submitVerdict,
|
|
357
|
+
position: deck.index + FIRST_POSITION_OFFSET,
|
|
358
|
+
progressTestID: `${testID}-${DECK_TEST_ID_SUFFIX.progress}`,
|
|
359
|
+
testID: `${testID}-${DECK_TEST_ID_SUFFIX.rankBar}`,
|
|
360
|
+
total: deck.total,
|
|
361
|
+
verdictTestIDPrefix: `${testID}-${DECK_TEST_ID_SUFFIX.verdict}`,
|
|
362
|
+
verdicts
|
|
363
|
+
}
|
|
364
|
+
)
|
|
365
|
+
] });
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
export { Card, CardDeck, DECK_TEST_ID_SUFFIX, KEY_ARROW_LEFT, KEY_BACK, MAX_KEYBOARD_VERDICTS, RankBar, digitToVerdictIndex, useDeck };
|
|
369
|
+
//# sourceMappingURL=index.mjs.map
|
|
370
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/constants.ts","../src/Card.tsx","../src/RankBar.tsx","../src/useDeck.ts","../src/CardDeck.tsx"],"names":["styles","StyleSheet","useUi","jsx","Text","jsxs","View","useState","useCallback","useEffect"],"mappings":"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","file":"index.mjs","sourcesContent":["/** Test ids, key names and layout metrics for the deck. Nothing here is a colour. */\n\n/** Stable test id suffixes. Each is appended to the caller-supplied `testID`. */\nexport const DECK_TEST_ID_SUFFIX = {\n card: 'card',\n image: 'card-image',\n rankBar: 'rank-bar',\n verdict: 'verdict',\n back: 'back',\n progress: 'progress',\n complete: 'complete',\n} as const;\n\n/** Keys that navigate rather than rank. */\nexport const KEY_BACK = 'Backspace';\nexport const KEY_ARROW_LEFT = 'ArrowLeft';\n\n/** Digit keys 1..9 pick the nth verdict. The deck never exposes more than nine. */\nexport const MAX_KEYBOARD_VERDICTS = 9;\nconst DIGIT_RADIX = 10;\n\n/** Parse a 1..9 digit key into a 0-based verdict index, or -1 when it is not one. */\nexport const digitToVerdictIndex = (key: string): number => {\n if (key.length !== 1) {\n return -1;\n }\n const parsed = Number.parseInt(key, DIGIT_RADIX);\n const isVerdictDigit = Number.isInteger(parsed) && parsed >= 1 && parsed <= MAX_KEYBOARD_VERDICTS;\n return isVerdictDigit ? parsed - 1 : -1;\n};\n\n/** Layout metrics (spacing and sizing only: colours come from the theme). */\nexport const CARD_RADIUS = 12;\nexport const CARD_PADDING = 16;\nexport const CARD_GAP = 12;\nexport const CARD_BORDER_WIDTH = 1;\nexport const CARD_IMAGE_HEIGHT = 220;\nexport const TITLE_FONT_SIZE = 20;\nexport const SUBTITLE_FONT_SIZE = 14;\nexport const PROGRESS_FONT_SIZE = 13;\nexport const VERDICT_FONT_SIZE = 15;\nexport const VERDICT_MIN_HEIGHT = 48;\nexport const VERDICT_PADDING_H = 14;\nexport const HINT_FONT_SIZE = 12;\n","/**\n * Card — the presentation face of one deck entry.\n *\n * Deliberately NOT interactive: ranking happens in `RankBar`, so there is exactly one\n * place a verdict can be emitted from. `imageUrl` is optional and a missing cover\n * renders as a typographic card rather than a broken image box.\n */\nimport React from 'react';\n\nimport { Image, StyleSheet, Text, View } from 'react-native';\n\nimport { useUi } from '@dloizides/ui-feedback';\n\nimport {\n CARD_BORDER_WIDTH,\n CARD_GAP,\n CARD_IMAGE_HEIGHT,\n CARD_PADDING,\n CARD_RADIUS,\n SUBTITLE_FONT_SIZE,\n TITLE_FONT_SIZE,\n} from './constants';\nimport type { DeckCard } from './types';\n\nconst styles = StyleSheet.create({\n image: { borderRadius: CARD_RADIUS, height: CARD_IMAGE_HEIGHT, width: '100%' },\n root: {\n borderRadius: CARD_RADIUS,\n borderWidth: CARD_BORDER_WIDTH,\n gap: CARD_GAP,\n padding: CARD_PADDING,\n },\n subtitle: { fontSize: SUBTITLE_FONT_SIZE },\n title: { fontSize: TITLE_FONT_SIZE, fontWeight: '700' },\n});\n\nexport interface CardProps {\n card: DeckCard;\n testID: string;\n imageTestID: string;\n /** Already-localized region label. Falls back to the card title. */\n accessibilityLabel?: string;\n /** Already-localized region hint telling the user how to rank. */\n accessibilityHint?: string;\n}\n\nexport const Card = ({\n card,\n testID,\n imageTestID,\n accessibilityLabel,\n accessibilityHint,\n}: CardProps): React.ReactElement => {\n const { theme } = useUi();\n const label = accessibilityLabel ?? card.title;\n return (\n <View\n accessible\n accessibilityHint={accessibilityHint}\n accessibilityLabel={label}\n style={[\n styles.root,\n { backgroundColor: theme.colors.surface, borderColor: theme.colors.border },\n ]}\n testID={testID}\n >\n {card.imageUrl !== undefined && (\n <Image\n accessibilityIgnoresInvertColors\n accessibilityLabel={label}\n source={{ uri: card.imageUrl }}\n style={styles.image}\n testID={imageTestID}\n />\n )}\n <Text style={[styles.title, { color: theme.colors.text }]}>{card.title}</Text>\n {card.subtitle !== undefined && (\n <Text style={[styles.subtitle, { color: theme.colors.textSecondary }]}>{card.subtitle}</Text>\n )}\n </View>\n );\n};\n\nexport default Card;\n","/**\n * RankBar — the verdict buttons, the back control and the progress line.\n *\n * Built on `PressableScale` (a `Pressable`), NEVER `TouchableOpacity`: RN-web silently\n * drops `onHoverIn` / `onHoverOut` on TouchableOpacity, so a hover affordance written\n * that way is dead on the web build with no error to find it by.\n *\n * Every control carries testID + accessibilityLabel + accessibilityHint. The labels are\n * caller-supplied and already localized; a missing one falls back to the verdict id.\n */\nimport React, { useCallback, useState } from 'react';\n\nimport { StyleSheet, Text, View } from 'react-native';\n\nimport { useUi } from '@dloizides/ui-feedback';\nimport { PressableScale } from '@dloizides/ui-motion';\n\nimport {\n CARD_BORDER_WIDTH,\n CARD_GAP,\n CARD_RADIUS,\n PROGRESS_FONT_SIZE,\n VERDICT_FONT_SIZE,\n VERDICT_MIN_HEIGHT,\n VERDICT_PADDING_H,\n} from './constants';\nimport type { DeckLabels } from './types';\n\nconst HOVER_OPACITY = 0.85;\nconst FULL_OPACITY = 1;\n\nconst styles = StyleSheet.create({\n button: {\n alignItems: 'center',\n borderRadius: CARD_RADIUS,\n borderWidth: CARD_BORDER_WIDTH,\n justifyContent: 'center',\n minHeight: VERDICT_MIN_HEIGHT,\n paddingHorizontal: VERDICT_PADDING_H,\n },\n buttonText: { fontSize: VERDICT_FONT_SIZE, fontWeight: '600' },\n progress: { fontSize: PROGRESS_FONT_SIZE },\n root: { gap: CARD_GAP },\n row: { flexDirection: 'row', flexWrap: 'wrap', gap: CARD_GAP },\n});\n\ninterface VerdictButtonProps {\n verdict: string;\n label: string;\n hint: string;\n testID: string;\n onSelect: (verdict: string) => void;\n}\n\nconst VerdictButton = ({\n verdict,\n label,\n hint,\n testID,\n onSelect,\n}: VerdictButtonProps): React.ReactElement => {\n const { theme } = useUi();\n const [hovered, setHovered] = useState(false);\n // onHoverIn / onHoverOut only survive on a Pressable. This is the trap in one place.\n const handleHoverIn = useCallback((): void => setHovered(true), []);\n const handleHoverOut = useCallback((): void => setHovered(false), []);\n const handlePress = useCallback((): void => onSelect(verdict), [onSelect, verdict]);\n return (\n <PressableScale\n accessibilityHint={hint}\n accessibilityLabel={label}\n accessibilityRole=\"button\"\n onHoverIn={handleHoverIn}\n onHoverOut={handleHoverOut}\n onPress={handlePress}\n testID={testID}\n innerStyle={[\n styles.button,\n {\n backgroundColor: hovered ? theme.colors.surfaceElevated : theme.colors.surface,\n borderColor: theme.colors.border,\n opacity: hovered ? HOVER_OPACITY : FULL_OPACITY,\n },\n ]}\n >\n <Text style={[styles.buttonText, { color: theme.colors.text }]}>{label}</Text>\n </PressableScale>\n );\n};\n\nexport interface RankBarProps {\n verdicts: readonly string[];\n labels?: DeckLabels;\n onSelect: (verdict: string) => void;\n onBack: () => void;\n canGoBack: boolean;\n /** 1-based position of the current card. */\n position: number;\n total: number;\n testID: string;\n backTestID: string;\n progressTestID: string;\n verdictTestIDPrefix: string;\n}\n\nexport const RankBar = ({\n verdicts,\n labels,\n onSelect,\n onBack,\n canGoBack,\n position,\n total,\n testID,\n backTestID,\n progressTestID,\n verdictTestIDPrefix,\n}: RankBarProps): React.ReactElement => {\n const { theme } = useUi();\n const progressText = labels?.progress?.(position, total) ?? `${position} / ${total}`;\n return (\n <View style={styles.root} testID={testID}>\n <Text\n style={[styles.progress, { color: theme.colors.textSecondary }]}\n testID={progressTestID}\n >\n {progressText}\n </Text>\n <View style={styles.row}>\n {verdicts.map((verdict) => (\n <VerdictButton\n hint={labels?.verdictHints?.[verdict] ?? verdict}\n key={verdict}\n label={labels?.verdictLabels?.[verdict] ?? verdict}\n onSelect={onSelect}\n testID={`${verdictTestIDPrefix}-${verdict}`}\n verdict={verdict}\n />\n ))}\n </View>\n {canGoBack && (\n <PressableScale\n accessibilityHint={labels?.backHint ?? labels?.backLabel ?? KEY_BACK_FALLBACK}\n accessibilityLabel={labels?.backLabel ?? KEY_BACK_FALLBACK}\n accessibilityRole=\"button\"\n innerStyle={[styles.button, { borderColor: theme.colors.border }]}\n onPress={onBack}\n testID={backTestID}\n >\n <Text style={[styles.buttonText, { color: theme.colors.textSecondary }]}>\n {labels?.backLabel ?? KEY_BACK_FALLBACK}\n </Text>\n </PressableScale>\n )}\n </View>\n );\n};\n\n/** Machine-readable fallback. Apps pass a localized `backLabel`. */\nconst KEY_BACK_FALLBACK = 'back';\n\nexport default RankBar;\n","/**\n * `useDeck` — every decision the card deck makes, with no rendering in it.\n *\n * Two RN-web traps are the reason this lives in a package rather than in an app:\n *\n * - **Keyboard activation does not arrive as `onPress`.** A browser can deliver the\n * key event AND synthesize a click from the same key press, which lands two\n * verdicts in one tick. `inFlightRef` latches the emission for the card and is\n * released only after React commits the advance, so the second path is a no-op.\n * - **A card is answered exactly once.** Navigating back to a ranked card shows the\n * verdict, it does not reopen it — otherwise going back and forth silently\n * re-emits verdicts the caller has already recorded.\n */\nimport { useCallback, useEffect, useMemo, useRef, useState } from 'react';\n\nimport { KEY_ARROW_LEFT, KEY_BACK, digitToVerdictIndex } from './constants';\nimport type { DeckCard } from './types';\n\nexport interface UseDeckOptions {\n cards: DeckCard[];\n /** Ordered verdict ids. Keyboard 1..n selects the nth. */\n verdicts: readonly string[];\n onVerdict: (cardId: string, verdict: string) => void;\n onComplete: () => void;\n}\n\nexport interface UseDeckResult {\n /** 0-based position. Equals `cards.length` once the deck is finished. */\n index: number;\n /** The card awaiting a verdict, or `undefined` when the deck is finished. */\n current: DeckCard | undefined;\n total: number;\n isComplete: boolean;\n /** cardId to verdict, for every card ranked so far. */\n answered: Readonly<Record<string, string>>;\n /** Rank the current card. Ignored when it is already ranked or an emission is in flight. */\n submitVerdict: (verdict: string) => void;\n /** Step back one card. A no-op on the first card. */\n goBack: () => void;\n /** Route a raw key name from keydown. Returns true when the deck consumed it. */\n handleKey: (key: string) => boolean;\n /**\n * Release the keyboard-activation latch. Bind to keyup: the click a browser\n * synthesizes from a key press arrives BEFORE keyup, which is what makes this the\n * window that suppresses it.\n */\n releaseKeyboardActivation: () => void;\n}\n\nexport function useDeck({ cards, verdicts, onVerdict, onComplete }: UseDeckOptions): UseDeckResult {\n const [index, setIndex] = useState(0);\n const [answered, setAnswered] = useState<Readonly<Record<string, string>>>({});\n\n const indexRef = useRef(0);\n const answeredRef = useRef<Readonly<Record<string, string>>>({});\n /** Holds the card id whose verdict is mid-emission. Released on the next commit. */\n const inFlightRef = useRef<string | null>(null);\n /** True from a rank keydown until the matching keyup. See `submitVerdict`. */\n const keyboardActivationRef = useRef(false);\n const completedRef = useRef(false);\n\n const total = cards.length;\n const isComplete = index >= total;\n\n // Release the latch only after React has committed the advance. Anything that fires\n // inside the SAME tick as the first emission (the synthesized click) finds it set.\n useEffect(() => {\n inFlightRef.current = null;\n });\n\n useEffect(() => {\n if (!isComplete || completedRef.current) {\n return;\n }\n completedRef.current = true;\n onComplete();\n }, [isComplete, onComplete]);\n\n const emit = useCallback(\n (verdict: string): void => {\n if (inFlightRef.current !== null) {\n return;\n }\n const card = cards[indexRef.current];\n if (card === undefined || !verdicts.includes(verdict)) {\n return;\n }\n if (answeredRef.current[card.id] !== undefined) {\n return;\n }\n inFlightRef.current = card.id;\n const nextAnswered = { ...answeredRef.current, [card.id]: verdict };\n answeredRef.current = nextAnswered;\n indexRef.current += 1;\n setAnswered(nextAnswered);\n setIndex(indexRef.current);\n onVerdict(card.id, verdict);\n },\n [cards, verdicts, onVerdict],\n );\n\n /**\n * The POINTER path. Suppressed while a keyboard activation is still open, because\n * RN-web keyboard activation does not arrive as `onPress`: the browser delivers the\n * key event and then synthesizes a click from the same press. React commits between\n * the two, so the in-flight latch alone cannot see it — this one spans the commit.\n */\n const submitVerdict = useCallback(\n (verdict: string): void => {\n if (keyboardActivationRef.current) {\n return;\n }\n emit(verdict);\n },\n [emit],\n );\n\n const releaseKeyboardActivation = useCallback((): void => {\n keyboardActivationRef.current = false;\n }, []);\n\n const goBack = useCallback((): void => {\n if (indexRef.current === 0) {\n return;\n }\n indexRef.current -= 1;\n setIndex(indexRef.current);\n }, []);\n\n const handleKey = useCallback(\n (key: string): boolean => {\n if (key === KEY_BACK || key === KEY_ARROW_LEFT) {\n goBack();\n return true;\n }\n const verdictIndex = digitToVerdictIndex(key);\n if (verdictIndex < 0) {\n return false;\n }\n const verdict = verdicts[verdictIndex];\n if (verdict === undefined) {\n return false;\n }\n keyboardActivationRef.current = true;\n emit(verdict);\n return true;\n },\n [goBack, emit, verdicts],\n );\n\n return useMemo(\n () => ({\n index,\n current: cards[index],\n total,\n isComplete,\n answered,\n submitVerdict,\n goBack,\n handleKey,\n releaseKeyboardActivation,\n }),\n [\n index,\n cards,\n total,\n isComplete,\n answered,\n submitVerdict,\n goBack,\n handleKey,\n releaseKeyboardActivation,\n ],\n );\n}\n","/**\n * CardDeck — a ranked card deck that is fully operable by keyboard alone.\n *\n * `1..n` rank the current card with the nth verdict, `Backspace` / `ArrowLeft` step\n * back. The listener is attached to the document on web, so the deck works without\n * the user having to tab into it first; every verdict still routes through the same\n * `useDeck.submitVerdict`, which holds the single in-flight latch. That is what stops\n * a browser-synthesized click from turning one key press into two verdicts.\n *\n * Colours come from the app UiProvider theme (`@dloizides/design-tokens`). There is not\n * one colour literal in this package.\n */\nimport React, { useEffect } from 'react';\n\nimport { StyleSheet, View } from 'react-native';\n\nimport { useUi } from '@dloizides/ui-feedback';\n\nimport { Card } from './Card';\nimport { RankBar } from './RankBar';\nimport { CARD_GAP, DECK_TEST_ID_SUFFIX } from './constants';\nimport type { CardDeckProps } from './types';\nimport { useDeck } from './useDeck';\n\nconst styles = StyleSheet.create({\n root: { gap: CARD_GAP },\n});\n\nconst FIRST_POSITION_OFFSET = 1;\n\nexport const CardDeck = ({\n cards,\n verdicts,\n onVerdict,\n onComplete,\n testID,\n labels,\n}: CardDeckProps): React.ReactElement => {\n const { theme } = useUi();\n const deck = useDeck({ cards, verdicts, onVerdict, onComplete });\n const { handleKey, releaseKeyboardActivation } = deck;\n\n useEffect(() => {\n if (typeof document === 'undefined') {\n return undefined;\n }\n const onKeyDown = (event: KeyboardEvent): void => {\n if (handleKey(event.key)) {\n event.preventDefault();\n }\n };\n // keyup closes the window in which a synthesized click is suppressed. Without it\n // one key press ranks two cards: the key event, then the click the browser makes.\n const onKeyUp = (): void => releaseKeyboardActivation();\n document.addEventListener('keydown', onKeyDown);\n document.addEventListener('keyup', onKeyUp);\n return (): void => {\n document.removeEventListener('keydown', onKeyDown);\n document.removeEventListener('keyup', onKeyUp);\n };\n }, [handleKey, releaseKeyboardActivation]);\n\n if (deck.current === undefined) {\n return (\n <View\n accessibilityLabel={labels?.cardHint}\n style={[styles.root, { backgroundColor: theme.colors.background }]}\n testID={`${testID}-${DECK_TEST_ID_SUFFIX.complete}`}\n />\n );\n }\n\n return (\n <View style={[styles.root, { backgroundColor: theme.colors.background }]} testID={testID}>\n <Card\n accessibilityHint={labels?.cardHint}\n accessibilityLabel={labels?.cardLabel?.(deck.current.title)}\n card={deck.current}\n imageTestID={`${testID}-${DECK_TEST_ID_SUFFIX.image}`}\n testID={`${testID}-${DECK_TEST_ID_SUFFIX.card}`}\n />\n <RankBar\n backTestID={`${testID}-${DECK_TEST_ID_SUFFIX.back}`}\n canGoBack={deck.index > 0}\n labels={labels}\n onBack={deck.goBack}\n onSelect={deck.submitVerdict}\n position={deck.index + FIRST_POSITION_OFFSET}\n progressTestID={`${testID}-${DECK_TEST_ID_SUFFIX.progress}`}\n testID={`${testID}-${DECK_TEST_ID_SUFFIX.rankBar}`}\n total={deck.total}\n verdictTestIDPrefix={`${testID}-${DECK_TEST_ID_SUFFIX.verdict}`}\n verdicts={verdicts}\n />\n </View>\n );\n};\n\nexport default CardDeck;\n"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dloizides/ui-carousel",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Themable, brand-agnostic React Native (RN-web) swipeable card deck with per-card ranking verdicts. Keyboard-first (1..n picks a verdict, arrows/Backspace navigate), Pressable-based so hover survives on RN-web, and guarded by a single in-flight ref so a keyboard activation can never double-fire a verdict. Colours come from the UiProvider theme (@dloizides/design-tokens). Part of the dloizides.com shared UI kit.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"react",
|
|
7
|
+
"react-native",
|
|
8
|
+
"react-native-web",
|
|
9
|
+
"ui",
|
|
10
|
+
"carousel",
|
|
11
|
+
"card-deck",
|
|
12
|
+
"ranking",
|
|
13
|
+
"accessibility",
|
|
14
|
+
"keyboard",
|
|
15
|
+
"dloizides"
|
|
16
|
+
],
|
|
17
|
+
"author": "dloizides",
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "https://github.com/openmindednewby/ui-carousel.git"
|
|
22
|
+
},
|
|
23
|
+
"homepage": "https://github.com/openmindednewby/ui-carousel#readme",
|
|
24
|
+
"bugs": {
|
|
25
|
+
"url": "https://github.com/openmindednewby/ui-carousel/issues"
|
|
26
|
+
},
|
|
27
|
+
"main": "./dist/index.js",
|
|
28
|
+
"module": "./dist/index.mjs",
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"react-native": {
|
|
33
|
+
"types": "./dist/index.d.ts",
|
|
34
|
+
"default": "./dist/index.js"
|
|
35
|
+
},
|
|
36
|
+
"browser": {
|
|
37
|
+
"types": "./dist/index.d.ts",
|
|
38
|
+
"default": "./dist/index.js"
|
|
39
|
+
},
|
|
40
|
+
"require": {
|
|
41
|
+
"types": "./dist/index.d.ts",
|
|
42
|
+
"default": "./dist/index.js"
|
|
43
|
+
},
|
|
44
|
+
"import": {
|
|
45
|
+
"types": "./dist/index.d.mts",
|
|
46
|
+
"default": "./dist/index.mjs"
|
|
47
|
+
},
|
|
48
|
+
"default": {
|
|
49
|
+
"types": "./dist/index.d.ts",
|
|
50
|
+
"default": "./dist/index.js"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
"files": [
|
|
55
|
+
"dist",
|
|
56
|
+
"README.md",
|
|
57
|
+
"CHANGELOG.md"
|
|
58
|
+
],
|
|
59
|
+
"sideEffects": false,
|
|
60
|
+
"engines": {
|
|
61
|
+
"node": ">=18.0.0"
|
|
62
|
+
},
|
|
63
|
+
"scripts": {
|
|
64
|
+
"build": "rimraf dist && tsup",
|
|
65
|
+
"build:watch": "tsup --watch",
|
|
66
|
+
"test": "jest",
|
|
67
|
+
"test:coverage": "jest --coverage",
|
|
68
|
+
"lint": "eslint src --ext .ts,.tsx",
|
|
69
|
+
"lint:fix": "eslint src --ext .ts,.tsx --fix",
|
|
70
|
+
"typecheck": "tsc --noEmit",
|
|
71
|
+
"clean": "rimraf dist",
|
|
72
|
+
"security:audit": "npm audit --audit-level=high",
|
|
73
|
+
"deps:outdated": "npm outdated || exit 0",
|
|
74
|
+
"deps:unused": "npx depcheck --ignores \"@types/jest,@types/node,@types/react,@types/react-dom,rimraf,react-native\"",
|
|
75
|
+
"prepublishOnly": "npm run clean && npm run build && npm run test",
|
|
76
|
+
"version": "npm run build"
|
|
77
|
+
},
|
|
78
|
+
"dependencies": {
|
|
79
|
+
"@dloizides/ui-motion": "^1.0.2"
|
|
80
|
+
},
|
|
81
|
+
"peerDependencies": {
|
|
82
|
+
"@dloizides/ui-feedback": ">=1.2.0",
|
|
83
|
+
"react": ">=18.0.0",
|
|
84
|
+
"react-native": ">=0.74.0"
|
|
85
|
+
},
|
|
86
|
+
"devDependencies": {
|
|
87
|
+
"@dloizides/ui-feedback": "file:../ui-feedback",
|
|
88
|
+
"@dloizides/ui-motion": "file:../ui-motion",
|
|
89
|
+
"@testing-library/dom": "^10.4.0",
|
|
90
|
+
"@testing-library/jest-dom": "^6.6.3",
|
|
91
|
+
"@testing-library/react": "^16.1.0",
|
|
92
|
+
"@types/jest": "^29.5.0",
|
|
93
|
+
"@types/node": "^20.19.32",
|
|
94
|
+
"@types/react": "^19.0.0",
|
|
95
|
+
"@types/react-dom": "^19.0.0",
|
|
96
|
+
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
|
97
|
+
"@typescript-eslint/parser": "^7.0.0",
|
|
98
|
+
"eslint": "^8.57.0",
|
|
99
|
+
"eslint-plugin-react": "^7.34.0",
|
|
100
|
+
"eslint-plugin-react-hooks": "^4.6.0",
|
|
101
|
+
"jest": "^29.7.0",
|
|
102
|
+
"jest-environment-jsdom": "^29.7.0",
|
|
103
|
+
"react": "^19.2.4",
|
|
104
|
+
"react-dom": "^19.2.4",
|
|
105
|
+
"react-native": "0.81.5",
|
|
106
|
+
"react-native-web": "^0.21.0",
|
|
107
|
+
"rimraf": "^5.0.0",
|
|
108
|
+
"ts-jest": "^29.1.0",
|
|
109
|
+
"tsup": "^8.0.0",
|
|
110
|
+
"typescript": "^5.4.0"
|
|
111
|
+
}
|
|
112
|
+
}
|