Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added docs/screenshots/ux/reorder-all.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 2 additions & 8 deletions src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,7 @@ import Gold from './components/Gold/Gold';
import Fame from './components/Fame/Fame';
import Calendar from './components/Calendar/Calendar';
import Notebook from './components/Notebook/Notebook';
import Journal from './components/Journal/Journal';
import Initiative from './components/Initiative/Initiative';
import Inventory from './components/Inventory/Inventory';
import StoryPoints from './components/StoryPoints/StoryPoints';
import Field from './components/Field/Field';
import GlobalSearch from './components/GlobalSearch/GlobalSearch';
import QuestLog from './components/QuestLog/QuestLog';
import KeyboardHelp from './components/KeyboardHelp/KeyboardHelp';
Expand Down Expand Up @@ -60,10 +57,7 @@ const App = () => {
</aside>
</div>
<Notebook />
<Initiative />
<Inventory />
<Journal />
<StoryPoints />
<Field />
</main>
<footer className="app__footer">
Track the party, the purse, and the tale — your tabletop campaign companion.
Expand Down
14 changes: 14 additions & 0 deletions src/appSections.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import Quests from './components/Quests/Quests';
import NPCs from './components/NPCs/NPCs';
import Locations from './components/Locations/Locations';
import Keywords from './components/Keywords/Keywords';
import Initiative from './components/Initiative/Initiative';
import Inventory from './components/Inventory/Inventory';
import Journal from './components/Journal/Journal';
import StoryPoints from './components/StoryPoints/StoryPoints';
import {
IconParty,
IconQuest,
Expand All @@ -24,6 +28,16 @@ export const NOTEBOOK_PANELS = [
{ key: 'keywords', label: 'Keywords', Component: Keywords },
];

// The full-width "field" panels below the notebook — combat, spoils and the
// written record — also rearrangeable, in their own persisted order. Keys
// match each panel's `collapsibleKey` (StoryPoints renders as the Chronicle).
export const FIELD_PANELS = [
{ key: 'initiative', label: 'Initiative', Component: Initiative },
{ key: 'inventory', label: 'Treasure', Component: Inventory },
{ key: 'journal', label: 'Journal', Component: Journal },
{ key: 'chronicle', label: 'The Chronicle', Component: StoryPoints },
];

// Every panel the section nav can jump to, in page order. Keys match the
// panels' `collapsibleKey` so a jump reuses the same reveal event the global
// search uses (uncollapse + scroll + flash). The small Gold/Fame/Calendar
Expand Down
31 changes: 31 additions & 0 deletions src/components/Field/Field.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { PanelReorderContext } from '../common/Panel/reorderContext';
import { usePanelReorder } from '../common/Panel/usePanelReorder';
import { FIELD_PANELS } from '../../appSections';
import styles from './Field.module.scss';

export const FIELD_ORDER_PREF_KEY = 'field-order';

// The full-width panels beneath the notebook (Initiative, Treasure, Journal,
// Chronicle), rearrangeable in a player-chosen order that persists per device.
// Shares the same drag engine and PanelReorderContext as the notebook grid.
const Field = () => {
const { order, panelsByKey, handles } = usePanelReorder(
FIELD_PANELS,
FIELD_ORDER_PREF_KEY
);

return (
<PanelReorderContext.Provider value={handles}>
<div className={styles.field}>
{order.map((key) => {
const panel = panelsByKey[key];
if (!panel) return null;
const { Component } = panel;
return <Component key={key} />;
})}
</div>
</PanelReorderContext.Provider>
);
};

export default Field;
7 changes: 7 additions & 0 deletions src/components/Field/Field.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
@use '../../common' as *;

.field {
display: flex;
flex-direction: column;
gap: $spacing-07;
}
71 changes: 71 additions & 0 deletions src/components/Field/Field.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import Field, { FIELD_ORDER_PREF_KEY } from './Field';
import { reconcilePanelOrder } from '../common/Panel/usePanelReorder';
import { prefSet, prefRemove } from '../../utils/localStorageUtil';

vi.mock('socket.io-client', () => ({
io: () => ({
on: () => {},
off: () => {},
emit: () => {},
disconnect: () => {},
id: 'test-socket',
}),
}));

beforeEach(() => {
prefRemove(FIELD_ORDER_PREF_KEY);
vi.stubGlobal(
'fetch',
vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(null) }))
);
});

describe('reconcilePanelOrder', () => {
it('appends missing keys and drops unknown ones', () => {
expect(reconcilePanelOrder(['journal', 'bogus'], ['a', 'journal'])).toEqual([
'journal',
'a',
]);
});
});

describe('Field', () => {
it('renders each field panel with a drag handle', async () => {
render(<Field />);
expect(
await screen.findByRole('button', {
name: 'Reorder Initiative (drag, or press arrow keys)',
})
).toBeInTheDocument();
expect(
screen.getByRole('button', {
name: 'Reorder The Chronicle (drag, or press arrow keys)',
})
).toBeInTheDocument();
});

it('renders panels in the persisted order', async () => {
prefSet(FIELD_ORDER_PREF_KEY, [
'chronicle',
'journal',
'inventory',
'initiative',
]);
render(<Field />);

await screen.findByRole('button', {
name: 'Reorder The Chronicle (drag, or press arrow keys)',
});
const handles = screen
.getAllByRole('button', { name: /Reorder/ })
.map((node) => node.getAttribute('aria-label'));
expect(handles).toEqual([
'Reorder The Chronicle (drag, or press arrow keys)',
'Reorder Journal (drag, or press arrow keys)',
'Reorder Treasure (drag, or press arrow keys)',
'Reorder Initiative (drag, or press arrow keys)',
]);
});
});
58 changes: 13 additions & 45 deletions src/components/Notebook/Notebook.jsx
Original file line number Diff line number Diff line change
@@ -1,61 +1,29 @@
import { useMemo, useState } from 'react';
import { useSortable } from '../../hooks/useSortable';
import { prefGet, prefSet } from '../../utils/localStorageUtil';
import { PanelReorderContext } from '../common/Panel/reorderContext';
import {
usePanelReorder,
reconcilePanelOrder,
} from '../common/Panel/usePanelReorder';
import { NOTEBOOK_PANELS } from '../../appSections';
import styles from './Notebook.module.scss';

export const ORDER_PREF_KEY = 'notebook-order';

const VALID_KEYS = NOTEBOOK_PANELS.map((panel) => panel.key);

// Reconciles a stored order against the current registry: keeps known keys in
// the saved order, drops stale ones, and appends any panels added since the
// order was last saved. Always returns the full set exactly once.
export const reconcileOrder = (stored) => {
const known = Array.isArray(stored)
? stored.filter((key) => VALID_KEYS.includes(key))
: [];
const missing = VALID_KEYS.filter((key) => !known.includes(key));
return [...known, ...missing];
};
// Kept for backward-compatible imports/tests; delegates to the shared engine.
export const reconcileOrder = (stored) =>
reconcilePanelOrder(
stored,
NOTEBOOK_PANELS.map((panel) => panel.key)
);

// The notebook grid of story ledgers, rendered in a player-chosen order that
// persists per device. Each panel grows its own drag handle (supplied through
// PanelReorderContext) so the panels themselves stay unaware of reordering.
const Notebook = () => {
const [order, setOrder] = useState(() =>
reconcileOrder(prefGet(ORDER_PREF_KEY))
);

const panelsByKey = useMemo(
() => Object.fromEntries(NOTEBOOK_PANELS.map((panel) => [panel.key, panel])),
[]
const { order, panelsByKey, handles } = usePanelReorder(
NOTEBOOK_PANELS,
ORDER_PREF_KEY
);

const reorder = (next) => {
setOrder(next);
prefSet(ORDER_PREF_KEY, next);
};

const sortable = useSortable(order, reorder);

// Publish per-key drag props the matching Panel will pick up from context.
const handles = useMemo(() => {
const map = {};
order.forEach((key, index) => {
const panel = panelsByKey[key];
if (!panel) return;
map[key] = {
itemProps: sortable.getItemProps(index),
handleProps: sortable.getHandleProps(index, panel.label),
isDragging: sortable.dragIndex === index,
isOver: sortable.overIndex === index && sortable.dragIndex !== index,
};
});
return map;
}, [order, panelsByKey, sortable]);

return (
<PanelReorderContext.Provider value={handles}>
<div className={styles.notebook}>
Expand Down
54 changes: 54 additions & 0 deletions src/components/common/Panel/usePanelReorder.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { useMemo, useState } from 'react';
import { useSortable } from '../../../hooks/useSortable';
import { prefGet, prefSet } from '../../../utils/localStorageUtil';

// Reconciles a stored order against a registry: keeps known keys in the saved
// order, drops stale ones, and appends any panels added since the order was
// last saved. Always returns the full set exactly once.
export const reconcilePanelOrder = (stored, validKeys) => {
const known = Array.isArray(stored)
? stored.filter((key) => validKeys.includes(key))
: [];
const missing = validKeys.filter((key) => !known.includes(key));
return [...known, ...missing];
};

// Shared engine for a reorderable panel group: owns the persisted order and
// turns it into the per-key drag props each Panel picks up from
// PanelReorderContext. Used by every group (the notebook grid, the field
// stack) so the wiring lives in one place.
export const usePanelReorder = (panels, prefKey) => {
const validKeys = useMemo(() => panels.map((panel) => panel.key), [panels]);
const panelsByKey = useMemo(
() => Object.fromEntries(panels.map((panel) => [panel.key, panel])),
[panels]
);

const [order, setOrder] = useState(() =>
reconcilePanelOrder(prefGet(prefKey), validKeys)
);

const reorder = (next) => {
setOrder(next);
prefSet(prefKey, next);
};

const sortable = useSortable(order, reorder);

const handles = useMemo(() => {
const map = {};
order.forEach((key, index) => {
const panel = panelsByKey[key];
if (!panel) return;
map[key] = {
itemProps: sortable.getItemProps(index),
handleProps: sortable.getHandleProps(index, panel.label),
isDragging: sortable.dragIndex === index,
isOver: sortable.overIndex === index && sortable.dragIndex !== index,
};
});
return map;
}, [order, panelsByKey, sortable]);

return { order, panelsByKey, handles };
};
Loading