@rpg-engine/long-bow 0.8.235 → 0.8.236
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/Spellbook/Spell.d.ts +2 -1
- package/dist/components/Spellbook/Spellbook.d.ts +2 -1
- package/dist/components/Spellbook/cards/SpellTooltip.d.ts +1 -0
- package/dist/long-bow.cjs.development.js +561 -200
- package/dist/long-bow.cjs.development.js.map +1 -1
- package/dist/long-bow.cjs.production.min.js +1 -1
- package/dist/long-bow.cjs.production.min.js.map +1 -1
- package/dist/long-bow.esm.js +562 -201
- package/dist/long-bow.esm.js.map +1 -1
- package/package.json +13 -3
- package/src/components/Shortcuts/ShortcutsSetter.tsx +6 -3
- package/src/components/Spellbook/Spell.tsx +195 -234
- package/src/components/Spellbook/Spellbook.tsx +407 -111
- package/src/components/Spellbook/__tests__/SpellInfoWrapper.test.tsx +102 -0
- package/src/components/Spellbook/__tests__/Spellbook.test.tsx +113 -0
- package/src/components/Spellbook/__tests__/imageMock.js +1 -0
- package/src/components/Spellbook/cards/SpellInfo.tsx +84 -29
- package/src/components/Spellbook/cards/SpellInfoWrapper.tsx +48 -13
- package/src/components/Spellbook/cards/SpellTooltip.tsx +13 -6
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import { IShortcut, ISpell } from '@rpg-engine/shared';
|
|
2
|
-
import React, {
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import { RPGUIContainerTypes } from '../RPGUI/RPGUIContainer';
|
|
1
|
+
import { IShortcut, ISpell, ItemRarities } from '@rpg-engine/shared';
|
|
2
|
+
import React, { useMemo, useRef, useState } from 'react';
|
|
3
|
+
import Draggable from 'react-draggable';
|
|
4
|
+
import { FaChevronLeft, FaChevronRight } from 'react-icons/fa';
|
|
5
|
+
import styled, { css } from 'styled-components';
|
|
6
|
+
import { uiColors } from '../../constants/uiColors';
|
|
8
7
|
import { ShortcutsSetter } from '../Shortcuts/ShortcutsSetter';
|
|
8
|
+
import BookImg from '../StaticBook/img/0.png';
|
|
9
9
|
import { Spell } from './Spell';
|
|
10
10
|
|
|
11
11
|
export interface ISpellbookProps {
|
|
@@ -26,129 +26,425 @@ export interface ISpellbookProps {
|
|
|
26
26
|
iconAtlasJSON?: any;
|
|
27
27
|
scale?: number;
|
|
28
28
|
spellCooldowns?: Record<string, number>;
|
|
29
|
+
// Rarity each learned spell was learned at, keyed by spell key (the `learnedSpellRarities` of the
|
|
30
|
+
// learned-spells event). Spells without an entry have no known rarity and show no badge.
|
|
31
|
+
learnedSpellRarities?: Partial<Record<string, ItemRarities>>;
|
|
29
32
|
}
|
|
30
33
|
|
|
34
|
+
// Two rows of four cards: two per physical page on the art book.
|
|
35
|
+
const PAGE_SIZE = 8;
|
|
36
|
+
|
|
31
37
|
export const Spellbook: React.FC<ISpellbookProps> = ({
|
|
32
|
-
onClose,
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
spells,
|
|
36
|
-
magicLevel,
|
|
37
|
-
mana,
|
|
38
|
-
charSkillLevels,
|
|
39
|
-
onSpellClick,
|
|
40
|
-
setSpellShortcut,
|
|
41
|
-
shortcuts,
|
|
42
|
-
removeShortcut,
|
|
43
|
-
atlasIMG,
|
|
44
|
-
atlasJSON,
|
|
45
|
-
iconAtlasIMG,
|
|
46
|
-
iconAtlasJSON,
|
|
47
|
-
scale,
|
|
48
|
-
spellCooldowns,
|
|
38
|
+
onClose, onInputFocus, onInputBlur, spells, magicLevel, mana, charSkillLevels,
|
|
39
|
+
onSpellClick, setSpellShortcut, shortcuts, removeShortcut, atlasIMG, atlasJSON,
|
|
40
|
+
iconAtlasIMG, iconAtlasJSON, scale = 1, spellCooldowns, learnedSpellRarities,
|
|
49
41
|
}) => {
|
|
50
42
|
const [search, setSearch] = useState('');
|
|
43
|
+
const [currentPage, setCurrentPage] = useState(0);
|
|
51
44
|
const [settingShortcutIndex, setSettingShortcutIndex] = useState(-1);
|
|
45
|
+
const bookRef = useRef<HTMLDivElement>(null);
|
|
46
|
+
const filteredSpells = useMemo(() => {
|
|
47
|
+
const query = search.trim().toLocaleLowerCase();
|
|
48
|
+
return [...spells]
|
|
49
|
+
.sort((a, b) => (a.minSkillLevelRequired ?? a.minMagicLevelRequired) - (b.minSkillLevelRequired ?? b.minMagicLevelRequired))
|
|
50
|
+
.filter(spell => `${spell.name} ${spell.magicWords}`.toLocaleLowerCase().includes(query));
|
|
51
|
+
}, [spells, search]);
|
|
52
|
+
const pageCount = Math.max(1, Math.ceil(filteredSpells.length / PAGE_SIZE));
|
|
53
|
+
const page = Math.min(currentPage, pageCount - 1);
|
|
54
|
+
const visibleSpells = filteredSpells.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
|
|
55
|
+
const assigning = settingShortcutIndex !== -1;
|
|
52
56
|
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
.filter(
|
|
61
|
-
spell =>
|
|
62
|
-
spell.name.toLocaleLowerCase().includes(search.toLocaleLowerCase()) ||
|
|
63
|
-
spell.magicWords
|
|
64
|
-
.toLocaleLowerCase()
|
|
65
|
-
.includes(search.toLocaleLowerCase())
|
|
66
|
-
);
|
|
67
|
-
}, [search, spells]);
|
|
68
|
-
|
|
69
|
-
const setShortcut = (spellKey: string) => {
|
|
70
|
-
setSpellShortcut?.(spellKey, settingShortcutIndex);
|
|
71
|
-
setSettingShortcutIndex(-1);
|
|
57
|
+
const selectSpell = (key: string): void => {
|
|
58
|
+
if (assigning) {
|
|
59
|
+
setSpellShortcut(key, settingShortcutIndex);
|
|
60
|
+
setSettingShortcutIndex(-1);
|
|
61
|
+
} else {
|
|
62
|
+
onSpellClick(key);
|
|
63
|
+
}
|
|
72
64
|
};
|
|
73
65
|
|
|
74
66
|
return (
|
|
75
|
-
<
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
67
|
+
<Draggable nodeRef={bookRef} handle=".spellbook-handle" cancel="button, input" scale={scale}>
|
|
68
|
+
<Book ref={bookRef} aria-label="Spellbook">
|
|
69
|
+
<Pages>
|
|
70
|
+
<Header className="spellbook-handle">
|
|
71
|
+
<HeaderLeft>
|
|
72
|
+
<div><Eyebrow>Your grimoire</Eyebrow><Title>Spellbook</Title></div>
|
|
73
|
+
<SearchLabel>
|
|
74
|
+
<span>Find a spell</span>
|
|
75
|
+
<Search type="search" aria-label="Search spells" placeholder="Name or magic words…"
|
|
76
|
+
value={search} onFocus={onInputFocus} onBlur={onInputBlur}
|
|
77
|
+
onChange={event => { setSearch(event.target.value); setCurrentPage(0); }} />
|
|
78
|
+
</SearchLabel>
|
|
79
|
+
</HeaderLeft>
|
|
80
|
+
<HeaderRight>
|
|
81
|
+
<Count aria-live="polite">
|
|
82
|
+
{search.trim() ? `${filteredSpells.length} of ${spells.length} spells` : `${spells.length} learned spells`}
|
|
83
|
+
</Count>
|
|
84
|
+
<Close type="button" aria-label="Close spellbook" onClick={onClose}>×</Close>
|
|
85
|
+
</HeaderRight>
|
|
86
|
+
</Header>
|
|
87
|
+
<Spread aria-label="Learned spells">
|
|
88
|
+
{visibleSpells.map(spell => (
|
|
89
|
+
<Spell key={spell.key} atlasIMG={iconAtlasIMG} atlasJSON={iconAtlasJSON}
|
|
90
|
+
charMana={mana} charMagicLevel={magicLevel} charSkillLevels={charSkillLevels}
|
|
91
|
+
onClick={selectSpell} spellKey={spell.key} spell={spell} isSettingShortcut={assigning}
|
|
92
|
+
rarity={learnedSpellRarities?.[spell.key]}
|
|
93
|
+
activeCooldown={spellCooldowns?.[spell.magicWords.trim().toLowerCase().replace(/\s+/g, '_')]} />
|
|
94
|
+
))}
|
|
95
|
+
{!visibleSpells.length && <Empty role="status">
|
|
96
|
+
<strong>{spells.length ? 'No matching spells' : 'Your spellbook is empty'}</strong>
|
|
97
|
+
<span>{spells.length ? `Nothing matches “${search.trim()}”. Try another name or magic phrase.` : 'Learn a spell to start filling these pages.'}</span>
|
|
98
|
+
{search && <SmallButton type="button" onClick={() => { setSearch(''); setCurrentPage(0); }}>Clear search</SmallButton>}
|
|
99
|
+
</Empty>}
|
|
100
|
+
</Spread>
|
|
101
|
+
<Pagination aria-label="Spellbook pages">
|
|
102
|
+
<BaseNote>Mana and cooldown shown are base values.</BaseNote>
|
|
103
|
+
<Pager>
|
|
104
|
+
<PageTurn type="button" aria-label="Previous page" disabled={page === 0}
|
|
105
|
+
onClick={() => setCurrentPage(page - 1)}><FaChevronLeft aria-hidden="true" size={10} />Previous</PageTurn>
|
|
106
|
+
<PageLabel aria-live="polite">Page {page + 1} of {pageCount}</PageLabel>
|
|
107
|
+
<PageTurn type="button" aria-label="Next page" disabled={page === pageCount - 1}
|
|
108
|
+
onClick={() => setCurrentPage(page + 1)}>Next<FaChevronRight aria-hidden="true" size={10} /></PageTurn>
|
|
109
|
+
</Pager>
|
|
110
|
+
</Pagination>
|
|
111
|
+
</Pages>
|
|
112
|
+
<ShortcutArea>
|
|
113
|
+
<Assignment role="status">
|
|
114
|
+
<span>{assigning ? `Choose a spell for shortcut ${settingShortcutIndex + 1}` : 'Choose an empty shortcut, then select a spell to assign it.'}</span>
|
|
115
|
+
{assigning && <SmallButton type="button" onClick={() => setSettingShortcutIndex(-1)}>Cancel assignment</SmallButton>}
|
|
116
|
+
</Assignment>
|
|
117
|
+
<ShortcutsSetter setSettingShortcutIndex={setSettingShortcutIndex} settingShortcutIndex={settingShortcutIndex}
|
|
118
|
+
shortcuts={shortcuts} removeShortcut={removeShortcut} atlasIMG={atlasIMG} atlasJSON={atlasJSON} />
|
|
119
|
+
</ShortcutArea>
|
|
120
|
+
</Book>
|
|
121
|
+
</Draggable>
|
|
129
122
|
);
|
|
130
123
|
};
|
|
131
124
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
125
|
+
// 0.png is 778x538 with transparent margins; the opaque book spans x 40-716, y 38-463.
|
|
126
|
+
// Percent size/position crop those margins so the box edge is the book edge. The page text
|
|
127
|
+
// area is x 73-687 (spine rings x 370-390 sit in the GUTTER) and y 72-428 of that art.
|
|
128
|
+
// The book has no fixed ratio: its height is the header, the card rows and the pager, and the art
|
|
129
|
+
// is stretched over whatever that comes to. Every desktop card is as tall as the tallest (see Spread).
|
|
130
|
+
const GUTTER = '5.2%';
|
|
131
|
+
const FLAT = '@media (max-width: 960px)';
|
|
132
|
+
const PHONE = '@media (max-width: 640px)';
|
|
133
|
+
// Mouse-driven desktop book: controls are drawn compact (34px) and keep a 44px hit area through ::after.
|
|
134
|
+
// Flat, phone and touch (coarse pointer) books keep the 44px targets.
|
|
135
|
+
const DESKTOP = '@media (min-width: 961px) and (pointer: fine)';
|
|
136
|
+
const hitArea = '&::after { content: ""; position: absolute; top: -5px; right: -5px; bottom: -5px; left: -5px; }';
|
|
137
|
+
// 12 shortcut slots plus label need ~720px of tray; below that the slots wrap to 6 per row.
|
|
138
|
+
const TRAY = '@media (max-width: 860px)';
|
|
139
|
+
const twoPages = `display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: ${GUTTER};`;
|
|
140
|
+
const PARCHMENT = '#392718';
|
|
141
|
+
const focusRing = '3px solid #3a2110';
|
|
142
|
+
const INK = '#140c1c';
|
|
143
|
+
|
|
144
|
+
// The client BaseUI `.rpgui-button` plank (rpgui img/button.png) as CSS: uiColors.orange fill, 1px
|
|
145
|
+
// near-black outline, pale top bevel, uiColors.brown bottom bevel. Dark ink keeps ~6:1 on the orange.
|
|
146
|
+
const plank = css`
|
|
147
|
+
position: relative;
|
|
148
|
+
display: inline-flex;
|
|
149
|
+
align-items: center;
|
|
150
|
+
justify-content: center;
|
|
151
|
+
gap: 8px;
|
|
152
|
+
box-sizing: border-box;
|
|
153
|
+
min-width: 44px;
|
|
154
|
+
min-height: 44px;
|
|
155
|
+
padding: 6px 12px;
|
|
156
|
+
background: ${uiColors.orange};
|
|
157
|
+
border: 1px solid ${INK};
|
|
158
|
+
border-radius: 2px;
|
|
159
|
+
box-shadow: inset 0 1px 0 #dad45e, inset 0 -2px 0 ${uiColors.brown};
|
|
160
|
+
color: ${INK};
|
|
161
|
+
font: inherit;
|
|
162
|
+
font-size: 10px;
|
|
163
|
+
line-height: 1.4;
|
|
164
|
+
cursor: pointer;
|
|
165
|
+
&:hover:not(:disabled) { background: #e08f38; }
|
|
166
|
+
&:active:not(:disabled) { background: ${uiColors.brown}; box-shadow: inset 0 2px 0 ${uiColors.darkRed}; color: #fff1d6; }
|
|
167
|
+
&:focus-visible { outline: ${focusRing}; outline-offset: 2px; }
|
|
168
|
+
/* RPGUI greys and recolors every :disabled (0,2,0): keep a flat, readable parchment-toned button */
|
|
169
|
+
&&:disabled {
|
|
170
|
+
cursor: default;
|
|
171
|
+
background: transparent;
|
|
172
|
+
box-shadow: none;
|
|
173
|
+
border-color: rgba(89, 56, 28, .45);
|
|
174
|
+
color: #5a3f27;
|
|
175
|
+
filter: none;
|
|
176
|
+
}
|
|
177
|
+
&:disabled svg { opacity: .55; }
|
|
178
|
+
${DESKTOP} {
|
|
179
|
+
min-width: 34px;
|
|
180
|
+
min-height: 34px;
|
|
181
|
+
padding: 4px 10px;
|
|
182
|
+
gap: 6px;
|
|
183
|
+
${hitArea}
|
|
184
|
+
}
|
|
135
185
|
`;
|
|
136
186
|
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
187
|
+
const Book = styled.div`
|
|
188
|
+
box-sizing: border-box;
|
|
189
|
+
position: relative;
|
|
190
|
+
width: min(100%, 1040px);
|
|
191
|
+
margin: 0 auto;
|
|
192
|
+
color: ${PARCHMENT};
|
|
193
|
+
font-family: 'Press Start 2P', monospace;
|
|
194
|
+
`;
|
|
195
|
+
const Pages = styled.div`
|
|
196
|
+
box-sizing: border-box;
|
|
141
197
|
display: flex;
|
|
142
198
|
flex-direction: column;
|
|
199
|
+
/* the stitched frame art already touches the header, so the height cut comes from the bottom padding */
|
|
200
|
+
padding: 38px 4.29% 38px 4.88%;
|
|
201
|
+
background: url(${BookImg}) 39.2157% 33.6283% / 115.0888% 126.5882% no-repeat;
|
|
202
|
+
image-rendering: pixelated;
|
|
203
|
+
filter: drop-shadow(0 14px 20px rgba(0, 0, 0, .5));
|
|
204
|
+
${FLAT} {
|
|
205
|
+
padding: 22px 20px 18px;
|
|
206
|
+
background: #eec28d;
|
|
207
|
+
border: 4px double #80512f;
|
|
208
|
+
border-radius: 4px;
|
|
209
|
+
box-shadow: inset 0 0 0 7px #eec28d, inset 0 0 0 8px rgba(89, 56, 28, .35);
|
|
210
|
+
}
|
|
211
|
+
${PHONE} { padding: 20px 16px 16px; }
|
|
143
212
|
`;
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
213
|
+
const Header = styled.div`
|
|
214
|
+
${twoPages}
|
|
215
|
+
align-items: center;
|
|
216
|
+
padding-bottom: 8px;
|
|
217
|
+
cursor: move;
|
|
218
|
+
${FLAT} { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 12px; padding-bottom: 14px; }
|
|
219
|
+
`;
|
|
220
|
+
const HeaderLeft = styled.div`
|
|
221
|
+
display: flex;
|
|
222
|
+
align-items: center;
|
|
223
|
+
gap: 16px;
|
|
224
|
+
min-width: 0;
|
|
225
|
+
${FLAT} { display: contents; }
|
|
226
|
+
`;
|
|
227
|
+
// The game client wraps the UI in RPGUI's `.rpgui-content`, whose span/p/h1/label/input rules
|
|
228
|
+
// (specificity 0,1,1) force white outlined text. The doubled `&&` class (0,2,0) keeps this book's type.
|
|
229
|
+
const Eyebrow = styled.span`
|
|
230
|
+
display: block;
|
|
231
|
+
margin-bottom: 4px;
|
|
232
|
+
&& { font-size: 9px; line-height: 1.3; color: #6b4d32; text-shadow: none; }
|
|
233
|
+
`;
|
|
234
|
+
const Title = styled.h1`
|
|
235
|
+
&& {
|
|
236
|
+
margin: 0;
|
|
237
|
+
font-size: 18px;
|
|
238
|
+
line-height: 1.3;
|
|
239
|
+
text-align: left;
|
|
240
|
+
color: ${PARCHMENT};
|
|
241
|
+
text-shadow: none;
|
|
242
|
+
${PHONE} { font-size: 16px; }
|
|
243
|
+
}
|
|
244
|
+
`;
|
|
245
|
+
const HeaderRight = styled.div`
|
|
246
|
+
display: flex;
|
|
247
|
+
justify-self: end;
|
|
248
|
+
align-items: center;
|
|
249
|
+
gap: 12px;
|
|
250
|
+
`;
|
|
251
|
+
const Count = styled.span`
|
|
252
|
+
text-align: right;
|
|
253
|
+
max-width: 190px;
|
|
254
|
+
${PHONE} { max-width: 96px; }
|
|
255
|
+
&& { font-size: 9px; line-height: 1.7; color: #4a3220; text-shadow: none; }
|
|
256
|
+
`;
|
|
257
|
+
const Close = styled.button`
|
|
258
|
+
${plank}
|
|
259
|
+
flex: none;
|
|
260
|
+
width: 44px;
|
|
261
|
+
padding: 0;
|
|
262
|
+
font-size: 22px;
|
|
263
|
+
line-height: 1;
|
|
264
|
+
${DESKTOP} { width: 34px; padding: 0; font-size: 18px; }
|
|
265
|
+
`;
|
|
266
|
+
const SearchLabel = styled.label`
|
|
267
|
+
flex: 1;
|
|
268
|
+
min-width: 0;
|
|
269
|
+
align-items: center;
|
|
270
|
+
/* RPGUI makes label display:inline and span white: keep the flex row and caption type */
|
|
271
|
+
&& { display: flex; font-size: 9px; line-height: 1.4; color: #4a3220; text-shadow: none; }
|
|
272
|
+
/* the input keeps its aria-label; the visible caption only appears once the book is flat */
|
|
273
|
+
&& span {
|
|
274
|
+
position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap;
|
|
275
|
+
font-size: inherit; line-height: inherit; color: inherit; text-shadow: none;
|
|
276
|
+
}
|
|
277
|
+
/* a name or a magic phrase is short: the field stays a field instead of filling the page */
|
|
278
|
+
${DESKTOP} { flex: 0 1 240px; }
|
|
279
|
+
${FLAT} {
|
|
280
|
+
order: 3;
|
|
281
|
+
flex: 1 0 100%;
|
|
282
|
+
flex-direction: column;
|
|
283
|
+
align-items: stretch;
|
|
284
|
+
gap: 6px;
|
|
285
|
+
&& span { position: static; width: auto; height: auto; clip: auto; }
|
|
286
|
+
}
|
|
287
|
+
`;
|
|
288
|
+
const Search = styled.input`
|
|
148
289
|
flex: 1;
|
|
149
|
-
|
|
290
|
+
min-width: 0;
|
|
291
|
+
border: 1px solid #7b5836;
|
|
292
|
+
border-radius: 3px;
|
|
293
|
+
font: inherit;
|
|
294
|
+
/* RPGUI gives every input a dark box, white outlined text, 32px line and 30px min height */
|
|
295
|
+
&& {
|
|
296
|
+
box-sizing: border-box;
|
|
297
|
+
width: 100%;
|
|
298
|
+
min-height: 44px;
|
|
299
|
+
padding: 10px;
|
|
300
|
+
color: ${PARCHMENT};
|
|
301
|
+
background: rgba(255, 250, 229, .72);
|
|
302
|
+
text-shadow: none;
|
|
303
|
+
font-size: 10px;
|
|
304
|
+
line-height: 1.4;
|
|
305
|
+
${DESKTOP} { min-height: 34px; padding: 6px 10px; }
|
|
306
|
+
}
|
|
307
|
+
&::placeholder { color: #6b4d32; opacity: 1; font-size: 9px; }
|
|
308
|
+
&:focus { outline: ${focusRing}; outline-offset: 1px; }
|
|
309
|
+
`;
|
|
310
|
+
const PageLabel = styled.span`
|
|
311
|
+
white-space: nowrap;
|
|
312
|
+
&& { line-height: normal; color: ${PARCHMENT}; text-shadow: none; }
|
|
313
|
+
`;
|
|
314
|
+
const BaseNote = styled.span`
|
|
315
|
+
text-wrap: balance;
|
|
316
|
+
&& { font-size: 9px; line-height: 1.7; color: #5a3f27; text-shadow: none; }
|
|
317
|
+
`;
|
|
318
|
+
// Art book: [card][card] spine [card][card], two rows. DOM order is row-major so the first four spells
|
|
319
|
+
// share a row; the spine track keeps the page gap the old two-page grid had (GUTTER = 10 + track + 10).
|
|
320
|
+
// Every populated row is 1fr, so all cards on a page share one width and one height: the tallest card sets it
|
|
321
|
+
// and each card is stretched to it (its wrapper is a flex row so the button fills the cell). A shorter card
|
|
322
|
+
// keeps its content at the top, facts right under the description, and leaves the spare height at the bottom.
|
|
323
|
+
// Rows are implicit, so only populated rows exist (1-4 cards make one row, 5-8 two) and a sparse page still
|
|
324
|
+
// shrinks the book; a row never goes below CARD_MIN. Flat book: two columns, phone: one, rows grow with their
|
|
325
|
+
// text and cards are equal within a row.
|
|
326
|
+
const CARD_MIN = '150px';
|
|
327
|
+
const Spread = styled.div`
|
|
328
|
+
display: grid;
|
|
329
|
+
align-items: stretch;
|
|
330
|
+
grid-template-columns: repeat(2, minmax(0, 1fr)) calc(${GUTTER} - 20px) repeat(2, minmax(0, 1fr));
|
|
331
|
+
grid-auto-rows: minmax(${CARD_MIN}, 1fr);
|
|
332
|
+
gap: 8px 10px;
|
|
333
|
+
> div { display: flex; min-width: 0; }
|
|
334
|
+
> :nth-child(4n + 1) { grid-column: 1; }
|
|
335
|
+
> :nth-child(4n + 2) { grid-column: 2; }
|
|
336
|
+
> :nth-child(4n + 3) { grid-column: 4; }
|
|
337
|
+
> :nth-child(4n) { grid-column: 5; }
|
|
338
|
+
${FLAT} {
|
|
339
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
340
|
+
grid-auto-rows: auto;
|
|
341
|
+
column-gap: 14px;
|
|
342
|
+
> :nth-child(n) { grid-column: auto; }
|
|
343
|
+
}
|
|
344
|
+
${PHONE} { grid-template-columns: minmax(0, 1fr); }
|
|
345
|
+
`;
|
|
346
|
+
const Empty = styled.div`
|
|
347
|
+
/* && outranks the card placement rules on Spread's children (0,2,0) */
|
|
348
|
+
&& { grid-column: 1 / span 2; grid-row: 1 / span 2; }
|
|
150
349
|
display: flex;
|
|
350
|
+
align-items: center;
|
|
351
|
+
justify-content: center;
|
|
151
352
|
flex-direction: column;
|
|
152
|
-
gap:
|
|
153
|
-
|
|
353
|
+
gap: 14px;
|
|
354
|
+
padding: 24px 16px;
|
|
355
|
+
text-align: center;
|
|
356
|
+
overflow-wrap: anywhere;
|
|
357
|
+
border: 2px dashed rgba(89, 56, 28, .45);
|
|
358
|
+
border-radius: 3px;
|
|
359
|
+
font-size: 12px;
|
|
360
|
+
line-height: 1.7;
|
|
361
|
+
color: ${PARCHMENT};
|
|
362
|
+
&& span { font-size: 10px; line-height: 1.8; color: #4a3220; text-shadow: none; }
|
|
363
|
+
${FLAT} { && { grid-column: 1 / -1; grid-row: auto; } }
|
|
364
|
+
${PHONE} { padding: 32px 12px; }
|
|
365
|
+
`;
|
|
366
|
+
const SmallButton = styled.button`
|
|
367
|
+
${plank}
|
|
368
|
+
`;
|
|
369
|
+
// Page turners share one fixed size so Previous and Next stay identical whatever the label length.
|
|
370
|
+
const PageTurn = styled(SmallButton)`
|
|
371
|
+
flex: none;
|
|
372
|
+
width: 124px;
|
|
373
|
+
${DESKTOP} { width: 108px; padding: 0 5px; }
|
|
374
|
+
${PHONE} { flex: 1 1 0; width: auto; min-width: 0; padding: 0 6px; gap: 6px; font-size: 9px; }
|
|
375
|
+
`;
|
|
376
|
+
// Same Previous / Page n of N / Next order as the shared Pagination; the base-value footnote takes the
|
|
377
|
+
// left page so the page turners sit together at the outer corner of the right page.
|
|
378
|
+
const Pager = styled.div`
|
|
379
|
+
display: flex;
|
|
380
|
+
align-items: center;
|
|
381
|
+
gap: 8px;
|
|
382
|
+
${FLAT} { justify-content: space-between; gap: 10px; }
|
|
383
|
+
`;
|
|
384
|
+
const Pagination = styled.nav`
|
|
385
|
+
${twoPages}
|
|
386
|
+
align-items: center;
|
|
387
|
+
margin-top: 10px;
|
|
388
|
+
font-size: 10px;
|
|
389
|
+
color: ${PARCHMENT};
|
|
390
|
+
> :first-child { justify-self: start; }
|
|
391
|
+
> :last-child { justify-self: end; }
|
|
392
|
+
${FLAT} {
|
|
393
|
+
display: flex;
|
|
394
|
+
flex-direction: column-reverse;
|
|
395
|
+
align-items: stretch;
|
|
396
|
+
gap: 10px;
|
|
397
|
+
padding-top: 14px;
|
|
398
|
+
border-top: 1px dashed rgba(89, 56, 28, .4);
|
|
399
|
+
}
|
|
400
|
+
${PHONE} { ${PageLabel} { font-size: 9px; } }
|
|
401
|
+
`;
|
|
402
|
+
const ShortcutArea = styled.div`
|
|
403
|
+
display: grid;
|
|
404
|
+
grid-template-columns: minmax(0, 1fr) auto;
|
|
405
|
+
align-items: center;
|
|
406
|
+
gap: 10px 20px;
|
|
407
|
+
margin-top: 10px;
|
|
408
|
+
padding: 10px 16px;
|
|
409
|
+
color: #f4e2bb;
|
|
410
|
+
background: linear-gradient(#4b2f19, #38220f);
|
|
411
|
+
border: 2px solid #1f130a;
|
|
412
|
+
border-radius: 4px;
|
|
413
|
+
box-shadow: inset 0 0 0 1px rgba(255, 224, 160, .18), 0 10px 18px rgba(0, 0, 0, .35);
|
|
414
|
+
${SmallButton}:focus-visible { outline-color: #ffd98a; }
|
|
415
|
+
> div:last-child { display: flex; align-items: center; gap: 12px; width: auto; }
|
|
416
|
+
&& p { flex: none; margin: 0; font-size: 10px; line-height: 1.7; color: #f4e2bb; text-shadow: none; }
|
|
417
|
+
#shortcuts_list { display: grid; grid-template-columns: repeat(12, 44px); gap: 6px; width: auto; padding: 0; }
|
|
418
|
+
#shortcuts_list button {
|
|
419
|
+
width: 44px;
|
|
420
|
+
height: 44px;
|
|
421
|
+
background-color: #ecc78f;
|
|
422
|
+
color: #2d1b0c;
|
|
423
|
+
font: inherit;
|
|
424
|
+
font-size: 11px;
|
|
425
|
+
}
|
|
426
|
+
#shortcuts_list button:hover:not(:disabled) { background-color: #fbe3b0; }
|
|
427
|
+
#shortcuts_list button:focus-visible { outline: 3px solid #ffd98a; outline-offset: 2px; }
|
|
428
|
+
#shortcuts_list button:disabled { opacity: 1; background-color: #5a3a20; color: #ecd6aa; filter: none; }
|
|
429
|
+
${DESKTOP} {
|
|
430
|
+
padding: 8px 14px;
|
|
431
|
+
#shortcuts_list { grid-template-columns: repeat(12, 36px); gap: 8px; }
|
|
432
|
+
#shortcuts_list button { position: relative; width: 36px; height: 36px; font-size: 10px; }
|
|
433
|
+
#shortcuts_list button::after { content: ''; position: absolute; top: -4px; right: -4px; bottom: -4px; left: -4px; }
|
|
434
|
+
}
|
|
435
|
+
${FLAT} { grid-template-columns: minmax(0, 1fr); padding: 12px 14px; }
|
|
436
|
+
${TRAY} { #shortcuts_list { grid-template-columns: repeat(6, 44px); } }
|
|
437
|
+
${PHONE} {
|
|
438
|
+
> div:last-child { flex-direction: column; align-items: stretch; gap: 8px; }
|
|
439
|
+
#shortcuts_list { justify-content: center; }
|
|
440
|
+
}
|
|
441
|
+
`;
|
|
442
|
+
const Assignment = styled.div`
|
|
443
|
+
display: flex;
|
|
444
|
+
justify-content: space-between;
|
|
445
|
+
align-items: center;
|
|
446
|
+
gap: 10px;
|
|
447
|
+
font-size: 9px;
|
|
448
|
+
line-height: 1.8;
|
|
449
|
+
&& span { font-size: inherit; line-height: inherit; color: inherit; text-shadow: none; }
|
|
154
450
|
`;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { cleanup, fireEvent, render, screen, act } from '@testing-library/react';
|
|
3
|
+
import { BasicAttribute, CharacterClass, ElementalType, LifeBringerRaces, UserAccountTypes } from '@rpg-engine/shared';
|
|
4
|
+
import { SpellInfo } from '../cards/SpellInfo';
|
|
5
|
+
import { MagicTooltip } from '../cards/SpellTooltip';
|
|
6
|
+
import { SpellInfoWrapper } from '../cards/SpellInfoWrapper';
|
|
7
|
+
import { mockSpells } from '../mockSpells';
|
|
8
|
+
|
|
9
|
+
jest.mock('../cards/MobileSpellTooltip', () => ({ MobileSpellTooltip: () => <div>Spell details</div> }));
|
|
10
|
+
jest.mock('../../Abstractions/ModalPortal', () => ({
|
|
11
|
+
__esModule: true, default: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
|
12
|
+
}));
|
|
13
|
+
afterEach(() => { cleanup(); jest.useRealTimers(); });
|
|
14
|
+
|
|
15
|
+
it('shows mobile details on hold without casting on release', () => {
|
|
16
|
+
jest.useFakeTimers();
|
|
17
|
+
const cast = jest.fn();
|
|
18
|
+
render(<SpellInfoWrapper spell={mockSpells[0]}><button onClick={cast}>Cast</button></SpellInfoWrapper>);
|
|
19
|
+
fireEvent.touchStart(screen.getByRole('button'));
|
|
20
|
+
act(() => { jest.advanceTimersByTime(501); });
|
|
21
|
+
expect(screen.getByText('Spell details')).toBeTruthy();
|
|
22
|
+
fireEvent.touchEnd(screen.getByRole('button'));
|
|
23
|
+
fireEvent.click(screen.getByRole('button'));
|
|
24
|
+
expect(cast).not.toHaveBeenCalled();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('cancels a pending hold when scrolling or unmounting', () => {
|
|
28
|
+
jest.useFakeTimers();
|
|
29
|
+
const { unmount } = render(<SpellInfoWrapper spell={mockSpells[0]}><button>Cast</button></SpellInfoWrapper>);
|
|
30
|
+
fireEvent.touchStart(screen.getByRole('button'));
|
|
31
|
+
fireEvent.touchMove(screen.getByRole('button'));
|
|
32
|
+
act(() => { jest.advanceTimersByTime(501); });
|
|
33
|
+
expect(screen.queryByText('Spell details')).toBeNull();
|
|
34
|
+
fireEvent.touchStart(screen.getByRole('button'));
|
|
35
|
+
unmount();
|
|
36
|
+
expect(jest.getTimerCount()).toBe(0);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const row = (label: string) => screen.getByText(label).nextSibling?.textContent;
|
|
40
|
+
|
|
41
|
+
it('tooltip lists payload metadata with readable labels beside the existing rows', () => {
|
|
42
|
+
render(<SpellInfo spell={{
|
|
43
|
+
...mockSpells[0], minLevelRequired: 12, attribute: BasicAttribute.MagicResistance, minSkillLevelRequired: 4,
|
|
44
|
+
elementalType: ElementalType.Fire, canSelfTarget: true, isPVPOnly: true,
|
|
45
|
+
characterClass: [CharacterClass.Druid, CharacterClass.Sorcerer], characterRace: [LifeBringerRaces.Elf],
|
|
46
|
+
onlyPremiumAccountType: [UserAccountTypes.Free, UserAccountTypes.PremiumGold],
|
|
47
|
+
}} />);
|
|
48
|
+
expect(row('Requires Level:')).toBe('12');
|
|
49
|
+
expect(row('Requires Magic Resistance Level:')).toBe('4');
|
|
50
|
+
expect(row('Element:')).toBe('Fire');
|
|
51
|
+
expect(row('Can target self:')).toBe('Yes');
|
|
52
|
+
expect(row('PvP only:')).toBe('Yes');
|
|
53
|
+
expect(row('Requires Class:')).toBe('Druid, Sorcerer');
|
|
54
|
+
expect(row('Requires Race:')).toBe('Elf');
|
|
55
|
+
expect(row('Eligible account types:')).toBe('Free, Gold');
|
|
56
|
+
expect(row('Casting Type:')).toBe('Ranged Casting');
|
|
57
|
+
expect(row('Max Distance Grid:')).toBe('2');
|
|
58
|
+
expect(screen.getByText(mockSpells[0].description)).toBeTruthy();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('tooltip never invents metadata the payload does not carry', () => {
|
|
62
|
+
render(<SpellInfo spell={mockSpells[0]} />);
|
|
63
|
+
expect(row('Requires Level:')).toBe('1');
|
|
64
|
+
expect(row('Requires Magic Level:')).toBe('3');
|
|
65
|
+
['Element:', 'Can target self:', 'PvP only:', 'Requires Class:', 'Requires Race:', 'Eligible account types:']
|
|
66
|
+
.forEach(label => expect(screen.queryByText(label)).toBeNull());
|
|
67
|
+
expect(screen.queryByText(/rarity|tier/i)).toBeNull();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('opens keyboard details and dismisses them without casting or closing the book', () => {
|
|
71
|
+
const cast = jest.fn();
|
|
72
|
+
const bookKey = jest.fn();
|
|
73
|
+
render(<div onKeyDown={bookKey}><SpellInfoWrapper spell={mockSpells[0]}>
|
|
74
|
+
<button onClick={cast}>Cast</button>
|
|
75
|
+
</SpellInfoWrapper></div>);
|
|
76
|
+
const button = screen.getByRole('button');
|
|
77
|
+
act(() => button.focus());
|
|
78
|
+
expect(screen.getByRole('tooltip').style.opacity).toBe('1');
|
|
79
|
+
fireEvent.keyDown(button, { key: 'Escape' });
|
|
80
|
+
expect(screen.queryByRole('tooltip')).toBeNull();
|
|
81
|
+
expect(bookKey).not.toHaveBeenCalled();
|
|
82
|
+
expect(cast).not.toHaveBeenCalled();
|
|
83
|
+
act(() => button.blur());
|
|
84
|
+
fireEvent.touchStart(button);
|
|
85
|
+
fireEvent.touchEnd(button);
|
|
86
|
+
act(() => button.focus());
|
|
87
|
+
expect(screen.getByRole('tooltip')).toBeTruthy();
|
|
88
|
+
act(() => button.blur());
|
|
89
|
+
expect(screen.queryByRole('tooltip')).toBeNull();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('positions focused details visibly without waiting for a mouse movement', () => {
|
|
93
|
+
const anchor = document.createElement('button');
|
|
94
|
+
jest.spyOn(anchor, 'getBoundingClientRect').mockReturnValue({
|
|
95
|
+
x: 100, y: 100, left: 100, right: 150, top: 100, bottom: 140,
|
|
96
|
+
width: 50, height: 40, toJSON: () => ({}),
|
|
97
|
+
});
|
|
98
|
+
render(<MagicTooltip spell={mockSpells[0]} anchor={anchor} />);
|
|
99
|
+
const tooltip = screen.getByRole('tooltip');
|
|
100
|
+
expect(tooltip.style.opacity).toBe('1');
|
|
101
|
+
expect(tooltip.style.transform).toBe('translate(170px, 120px)');
|
|
102
|
+
});
|