@inkra/react 0.6.0 → 0.9.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/README.md +166 -1
- package/dist/Button.js +1 -1
- package/dist/PencilIcon.d.ts +14 -1
- package/dist/PencilIcon.js +88 -52
- package/dist/PencilText.js +8 -0
- package/dist/form/Textarea.d.ts +4 -0
- package/dist/form/Textarea.js +14 -8
- package/dist/form/index.d.ts +1 -1
- package/dist/form/textareaSizing.d.ts +29 -0
- package/dist/form/textareaSizing.js +153 -0
- package/dist/guide/index.d.ts +77 -0
- package/dist/guide/index.js +379 -0
- package/dist/i18n/catalog.d.ts +1 -1
- package/dist/i18n/catalog.js +15 -0
- package/dist/image-viewer/index.d.ts +2 -0
- package/dist/image-viewer/index.js +28 -28
- package/dist/index.d.ts +6 -0
- package/dist/index.js +5 -0
- package/dist/infinite-scroll/gesture.d.ts +19 -0
- package/dist/infinite-scroll/gesture.js +218 -0
- package/dist/infinite-scroll/index.d.ts +55 -0
- package/dist/infinite-scroll/index.js +543 -0
- package/dist/infinite-scroll/model.d.ts +33 -0
- package/dist/infinite-scroll/model.js +168 -0
- package/dist/internal/iconFill.d.ts +16 -0
- package/dist/internal/iconFill.js +275 -0
- package/dist/inventory-grid/index.d.ts +35 -0
- package/dist/inventory-grid/index.js +215 -0
- package/dist/inventory-grid/model.d.ts +56 -0
- package/dist/inventory-grid/model.js +93 -0
- package/dist/inventory-grid/outline.d.ts +9 -0
- package/dist/inventory-grid/outline.js +54 -0
- package/dist/layout/GridBoard.d.ts +22 -0
- package/dist/layout/GridBoard.js +113 -0
- package/dist/layout/GridResizable.d.ts +31 -0
- package/dist/layout/GridResizable.js +35 -0
- package/dist/layout/index.d.ts +19 -4
- package/dist/layout/index.js +45 -9
- package/dist/layout/swipeNavigation.d.ts +15 -0
- package/dist/layout/swipeNavigation.js +121 -0
- package/dist/loading/index.d.ts +58 -0
- package/dist/loading/index.js +188 -0
- package/dist/loading/locales.d.ts +15 -0
- package/dist/loading/locales.js +34 -0
- package/dist/loading/model.d.ts +6 -0
- package/dist/loading/model.js +29 -0
- package/dist/masonry/index.d.ts +25 -0
- package/dist/masonry/index.js +227 -0
- package/dist/masonry/layout.d.ts +35 -0
- package/dist/masonry/layout.js +67 -0
- package/dist/number-ticker/RollingValue.d.ts +6 -0
- package/dist/number-ticker/RollingValue.js +10 -0
- package/dist/number-ticker/index.d.ts +4 -0
- package/dist/number-ticker/index.js +55 -7
- package/dist/number-ticker/rolling.d.ts +35 -0
- package/dist/number-ticker/rolling.js +87 -0
- package/dist/pagination/index.d.ts +17 -0
- package/dist/pagination/index.js +30 -6
- package/dist/styles/base.css +9 -1
- package/dist/styles/form.css +10 -0
- package/dist/styles/guide.css +202 -0
- package/dist/styles/image-viewer.css +20 -0
- package/dist/styles/infinite-scroll.css +171 -0
- package/dist/styles/inventory-grid.css +43 -0
- package/dist/styles/layout.css +85 -0
- package/dist/styles/loading.css +348 -0
- package/dist/styles/masonry.css +68 -0
- package/dist/styles/number-ticker.css +46 -0
- package/dist/styles/pagination.css +10 -0
- package/dist/styles.css +1162 -146
- package/dist/toast/manager.d.ts +4 -4
- package/package.json +33 -1
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { DndContext, DragOverlay, KeyboardSensor, MouseSensor, TouchSensor, pointerWithin, rectIntersection, useDraggable, useDroppable, useSensor, useSensors } from '@dnd-kit/core';
|
|
3
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
4
|
+
import { createPortal } from 'react-dom';
|
|
5
|
+
import { __iconData as rotateIconData } from 'lucide-react/dist/esm/icons/rotate-cw.mjs';
|
|
6
|
+
import { Button } from '../Button.js';
|
|
7
|
+
import { PencilIcon } from '../PencilIcon.js';
|
|
8
|
+
import { PencilLine } from '../PencilLine.js';
|
|
9
|
+
import { PencilSurface } from '../PencilSurface.js';
|
|
10
|
+
import { PencilText } from '../PencilText.js';
|
|
11
|
+
import { useInkraLocale } from '../i18n/index.js';
|
|
12
|
+
import { PortalThemeBridge, PortalThemeProvider, usePortalTriggerRef } from '../overlay/themeBridge.js';
|
|
13
|
+
import { assertInventoryContainers, projectInventoryMove, rotateInventoryCells, validateInventoryPlacement } from './model.js';
|
|
14
|
+
import { inventoryOutline } from './outline.js';
|
|
15
|
+
export { assertInventoryContainers, projectInventoryMove, rotateInventoryCells, validateInventoryPlacement } from './model.js';
|
|
16
|
+
const rotateIcon = rotateIconData.node.map(([, attributes]) => ({ d: String(attributes.d) }));
|
|
17
|
+
const boardId = (id) => `inkra-inventory-board:${id}`;
|
|
18
|
+
const itemId = (id) => `inkra-inventory-item:${id}`;
|
|
19
|
+
const bounds = (cells) => ({ width: Math.max(...cells.map(cell => cell.x)) + 1, height: Math.max(...cells.map(cell => cell.y)) + 1 });
|
|
20
|
+
const collisionDetection = args => args.pointerCoordinates ? pointerWithin(args) : rectIntersection(args);
|
|
21
|
+
function Shape({ cells, size, seed, children }) {
|
|
22
|
+
const outline = useMemo(() => inventoryOutline(cells, size), [cells, size]);
|
|
23
|
+
const box = bounds(cells);
|
|
24
|
+
return _jsxs(_Fragment, { children: [_jsxs("span", { className: "inkra-inventory-grid__shape", "aria-hidden": "true", style: { width: box.width * size, height: box.height * size }, children: [_jsx(PencilSurface, { as: "span", variant: "full", seed: seed, className: "inkra-inventory-grid__shape-fill", style: { clipPath: `path("${outline.path}")` }, children: _jsx(PencilSurface, { as: "span", variant: "selection", seed: `${seed}-wash`, className: "inkra-inventory-grid__shape-wash" }) }), _jsx("svg", { className: "inkra-inventory-grid__shape-fallback", width: "100%", height: "100%", children: _jsx("path", { d: outline.path, fill: "none", stroke: "currentColor" }) }), outline.segments.map(({ start, end }, index) => {
|
|
25
|
+
const vertical = start.x === end.x;
|
|
26
|
+
const style = { left: Math.min(start.x, end.x) - (vertical ? 4 : 1),
|
|
27
|
+
top: Math.min(start.y, end.y) - (vertical ? 1 : 4),
|
|
28
|
+
width: vertical ? 8 : Math.abs(end.x - start.x) + 2,
|
|
29
|
+
height: vertical ? Math.abs(end.y - start.y) + 2 : 8 };
|
|
30
|
+
return _jsx(PencilLine, { orientation: vertical ? 'vertical' : 'horizontal', seed: `${seed}-edge-${index}`, className: "inkra-inventory-grid__shape-edge", style: style }, index);
|
|
31
|
+
})] }), cells.map((cell, index) => _jsx("span", { className: "inkra-inventory-grid__cell", style: { left: cell.x * size, top: cell.y * size, width: size, height: size }, children: index === 0 && _jsx("span", { className: "inkra-inventory-grid__item-content", children: children }) }, `${cell.x}:${cell.y}`))] });
|
|
32
|
+
}
|
|
33
|
+
function ItemView({ item, container, size, disabled, selected, onSelect, onRotate, renderItem, getItemLabel, allowRotation }) {
|
|
34
|
+
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ id: itemId(item.id), disabled,
|
|
35
|
+
data: { itemId: item.id, containerId: container.id } });
|
|
36
|
+
const cells = rotateInventoryCells(item.cells, item.rotation);
|
|
37
|
+
const box = bounds(cells);
|
|
38
|
+
return _jsx("div", { ...attributes, ...listeners, ref: setNodeRef, role: "button", tabIndex: disabled ? -1 : 0, "aria-label": getItemLabel(item), title: getItemLabel(item), "aria-disabled": disabled || undefined, className: "inkra-inventory-grid__item", "data-inventory-item": item.id, "data-selected": selected || undefined, "data-dragging": isDragging || undefined, "data-disabled": disabled || undefined, "data-rotation": item.rotation ?? 0, style: { left: item.position.x * size, top: item.position.y * size, width: box.width * size, height: box.height * size }, onFocus: () => onSelect(item.id), onClick: () => { if (!disabled)
|
|
39
|
+
onSelect(item.id); }, onKeyDown: event => {
|
|
40
|
+
if (!isDragging && allowRotation && !disabled && event.key.toLowerCase() === 'r') {
|
|
41
|
+
event.preventDefault();
|
|
42
|
+
onRotate(item.id);
|
|
43
|
+
}
|
|
44
|
+
else
|
|
45
|
+
listeners?.onKeyDown?.(event);
|
|
46
|
+
}, children: _jsx(Shape, { cells: cells, size: size, seed: item.id, children: renderItem(item, { selected, dragging: isDragging, rotation: item.rotation ?? 0, overlay: false }) }) });
|
|
47
|
+
}
|
|
48
|
+
function ContainerView({ container, size, disabled, selectedId, onSelect, onRotate, renderItem, getItemLabel, allowRotation, projection, activeItem, valid, rotateLabel }) {
|
|
49
|
+
const { setNodeRef } = useDroppable({ id: boardId(container.id), disabled: disabled || container.disabled,
|
|
50
|
+
data: { containerId: container.id, columns: container.columns, rows: container.rows } });
|
|
51
|
+
const selected = container.items.find(item => item.id === selectedId);
|
|
52
|
+
const projectedCells = projection && activeItem ? rotateInventoryCells(activeItem.cells, projection.rotation) : [];
|
|
53
|
+
return _jsxs("section", { className: "inkra-inventory-grid__container", "aria-label": container.label ?? container.id, "data-disabled": container.disabled || undefined, children: [_jsxs("div", { className: "inkra-inventory-grid__header", children: [_jsx(PencilText, { children: container.label ?? container.id }), allowRotation && _jsx(Button, { variant: "ghost", size: "icon", "aria-label": rotateLabel, title: rotateLabel, disabled: disabled || container.disabled || !selected || selected.disabled || selected.rotatable === false, onClick: () => { if (selected)
|
|
54
|
+
onRotate(selected.id); }, children: _jsx(PencilIcon, { paths: rotateIcon, size: 24 }) })] }), _jsx("div", { className: "inkra-inventory-grid__scroll", tabIndex: 0, role: "region", "aria-label": container.label ?? container.id, children: _jsxs("div", { ref: setNodeRef, className: "inkra-inventory-grid__board", "data-inventory-container": container.id, style: { width: container.columns * size, height: container.rows * size }, children: [_jsxs("div", { className: "inkra-inventory-grid__lines", "aria-hidden": "true", children: [Array.from({ length: container.columns + 1 }, (_, index) => _jsx(PencilLine, { orientation: "vertical", seed: `${container.id}-x${index}`, style: { left: index * size - 4, top: -1, width: 8, height: container.rows * size + 2 } }, `x${index}`)), Array.from({ length: container.rows + 1 }, (_, index) => _jsx(PencilLine, { seed: `${container.id}-y${index}`, style: { top: index * size - 4, left: -1, height: 8, width: container.columns * size + 2 } }, `y${index}`))] }), container.disabledCells?.map(cell => _jsx("span", { "aria-hidden": "true", className: "inkra-inventory-grid__blocked", style: { left: cell.x * size, top: cell.y * size, width: size, height: size }, children: _jsx(PencilIcon, { size: 24, paths: [{ d: 'M5 5l14 14M19 5L5 19' }] }) }, `${cell.x}:${cell.y}`)), container.items.map(item => _jsx(ItemView, { item: item, container: container, size: size, disabled: disabled || Boolean(container.disabled) || Boolean(item.disabled), selected: selectedId === item.id, onSelect: onSelect, onRotate: onRotate, renderItem: renderItem, getItemLabel: getItemLabel, allowRotation: allowRotation }, item.id)), projection && projection.containerId === container.id && _jsx("div", { "aria-hidden": "true", className: "inkra-inventory-grid__preview", "data-valid": valid, style: { left: projection.position.x * size, top: projection.position.y * size }, children: _jsx(Shape, { cells: projectedCells, size: size, seed: "inventory-preview" }) })] }) })] });
|
|
55
|
+
}
|
|
56
|
+
export function InventoryGrid(props) {
|
|
57
|
+
return _jsx(PortalThemeProvider, { children: _jsx(InventoryGridContent, { ...props }) });
|
|
58
|
+
}
|
|
59
|
+
function InventoryGridContent({ containers, onChange, canPlace, renderItem = item => _jsx(PencilText, { children: item.label ?? item.id }), getItemLabel = item => item.label ?? item.id, cellSize = 48, disabled = false, readOnly = false, allowRotation = true, labels: suppliedLabels, className, 'aria-label': ariaLabel }) {
|
|
60
|
+
useMemo(() => assertInventoryContainers(containers), [containers]);
|
|
61
|
+
const { t } = useInkraLocale();
|
|
62
|
+
const labels = {
|
|
63
|
+
instructions: t('inventoryGrid.instructions'), rotate: t('inventoryGrid.rotate'),
|
|
64
|
+
pickedUp: (item) => t('inventoryGrid.pickedUp', { item }),
|
|
65
|
+
position: (item, container, column, row, rotation) => t('inventoryGrid.position', { item, container, column, row, rotation }),
|
|
66
|
+
invalid: t('inventoryGrid.invalid'), requested: (item) => t('inventoryGrid.requested', { item }),
|
|
67
|
+
cancelled: (item) => t('inventoryGrid.cancelled', { item }), ...suppliedLabels,
|
|
68
|
+
};
|
|
69
|
+
const size = Number.isFinite(cellSize) ? Math.max(44, Math.min(128, cellSize)) : 48;
|
|
70
|
+
const [activeId, setActiveId] = useState(null);
|
|
71
|
+
const [selectedId, setSelectedId] = useState(null);
|
|
72
|
+
const [projection, setProjection] = useState(null);
|
|
73
|
+
const [announcement, setAnnouncement] = useState('');
|
|
74
|
+
const rotationRef = useRef(0);
|
|
75
|
+
const eventRef = useRef(null);
|
|
76
|
+
const projectionRef = useRef(null);
|
|
77
|
+
const rootRef = useRef(null);
|
|
78
|
+
const captureRoot = usePortalTriggerRef(rootRef);
|
|
79
|
+
const overlayScale = useRef({ x: 1, y: 1 });
|
|
80
|
+
const activeItem = containers.flatMap(container => container.items).find(item => item.id === activeId);
|
|
81
|
+
const unavailable = disabled || readOnly;
|
|
82
|
+
const valid = projection !== null && !unavailable && validateInventoryPlacement(containers, projection, canPlace).valid;
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
if (activeId && (unavailable || !activeItem || activeItem.disabled || containers.find(container => container.items.some(item => item.id === activeId))?.disabled)) {
|
|
85
|
+
// End the mature sensor through its cancellation event before hiding the drag.
|
|
86
|
+
// Unmounting DndContext alone does not detach an active document sensor.
|
|
87
|
+
const document = rootRef.current?.ownerDocument;
|
|
88
|
+
const KeyboardEvent = document?.defaultView?.KeyboardEvent;
|
|
89
|
+
if (document && KeyboardEvent)
|
|
90
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', code: 'Escape', cancelable: true }));
|
|
91
|
+
reset();
|
|
92
|
+
}
|
|
93
|
+
if (selectedId && !containers.some(container => container.items.some(item => item.id === selectedId)))
|
|
94
|
+
setSelectedId(null);
|
|
95
|
+
}, [activeId, activeItem, containers, selectedId, unavailable]);
|
|
96
|
+
const coordinates = (event, { currentCoordinates, context }) => {
|
|
97
|
+
const target = context.over?.data.current;
|
|
98
|
+
const rectangle = context.over?.rect;
|
|
99
|
+
const stepX = rectangle && target?.columns ? rectangle.width / target.columns : size;
|
|
100
|
+
const stepY = rectangle && target?.rows ? rectangle.height / target.rows : size;
|
|
101
|
+
const directions = {
|
|
102
|
+
ArrowRight: { x: stepX, y: 0 }, ArrowLeft: { x: -stepX, y: 0 }, ArrowDown: { x: 0, y: stepY }, ArrowUp: { x: 0, y: -stepY },
|
|
103
|
+
};
|
|
104
|
+
if (directions[event.code]) {
|
|
105
|
+
event.preventDefault();
|
|
106
|
+
return { x: currentCoordinates.x + directions[event.code].x, y: currentCoordinates.y + directions[event.code].y };
|
|
107
|
+
}
|
|
108
|
+
if (event.code === 'PageDown' || event.code === 'PageUp') {
|
|
109
|
+
event.preventDefault();
|
|
110
|
+
const available = containers.filter(container => !container.disabled);
|
|
111
|
+
const index = available.findIndex(container => container.id === target?.containerId);
|
|
112
|
+
const next = available[(index + (event.code === 'PageDown' ? 1 : -1) + available.length) % available.length];
|
|
113
|
+
const nextRect = next && context.droppableRects.get(boardId(next.id));
|
|
114
|
+
const currentRect = context.collisionRect;
|
|
115
|
+
if (nextRect && currentRect)
|
|
116
|
+
return { x: currentCoordinates.x + nextRect.left - currentRect.left, y: currentCoordinates.y + nextRect.top - currentRect.top };
|
|
117
|
+
}
|
|
118
|
+
return undefined;
|
|
119
|
+
};
|
|
120
|
+
const sensors = useSensors(useSensor(MouseSensor, { activationConstraint: { distance: 6 } }), useSensor(TouchSensor, { activationConstraint: { delay: 180, tolerance: 8 } }), useSensor(KeyboardSensor, { coordinateGetter: coordinates }));
|
|
121
|
+
function updateProjection(event) {
|
|
122
|
+
eventRef.current = event;
|
|
123
|
+
const targetId = event.over?.data.current?.containerId;
|
|
124
|
+
const target = containers.find(container => container.id === targetId);
|
|
125
|
+
const initial = event.active.rect.current.initial;
|
|
126
|
+
const id = event.active.data.current?.itemId;
|
|
127
|
+
if (!target || !initial || !id || !event.over) {
|
|
128
|
+
projectionRef.current = null;
|
|
129
|
+
setProjection(null);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const next = { itemId: id, containerId: target.id, rotation: rotationRef.current,
|
|
133
|
+
position: { x: Math.round((initial.left + event.delta.x - event.over.rect.left) / (event.over.rect.width / target.columns)),
|
|
134
|
+
y: Math.round((initial.top + event.delta.y - event.over.rect.top) / (event.over.rect.height / target.rows)) } };
|
|
135
|
+
projectionRef.current = next;
|
|
136
|
+
setProjection(next);
|
|
137
|
+
const item = containers.flatMap(container => container.items).find(entry => entry.id === id);
|
|
138
|
+
if (item)
|
|
139
|
+
setAnnouncement(`${labels.position(getItemLabel(item), target.label ?? target.id, next.position.x + 1, next.position.y + 1, next.rotation)}${validateInventoryPlacement(containers, next, canPlace).valid ? '' : ` ${labels.invalid}`}`);
|
|
140
|
+
}
|
|
141
|
+
function reset() { setActiveId(null); setProjection(null); projectionRef.current = null; eventRef.current = null; }
|
|
142
|
+
function commit(next) {
|
|
143
|
+
if (unavailable)
|
|
144
|
+
return;
|
|
145
|
+
const source = containers.find(container => container.items.some(item => item.id === next.itemId));
|
|
146
|
+
const item = source?.items.find(entry => entry.id === next.itemId);
|
|
147
|
+
const updated = projectInventoryMove(containers, next, canPlace);
|
|
148
|
+
if (!source || !item || !validateInventoryPlacement(containers, next, canPlace).valid) {
|
|
149
|
+
setAnnouncement(labels.invalid);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (updated === containers) {
|
|
153
|
+
setAnnouncement(labels.position(getItemLabel(item), source.label ?? source.id, item.position.x + 1, item.position.y + 1, item.rotation ?? 0));
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const from = { itemId: item.id, containerId: source.id, position: { ...item.position }, rotation: item.rotation ?? 0 };
|
|
157
|
+
onChange(updated, { from, to: next, type: from.containerId !== next.containerId ? 'transfer' : from.rotation !== next.rotation ? 'rotate' : 'move' });
|
|
158
|
+
setAnnouncement(labels.requested(getItemLabel(item)));
|
|
159
|
+
requestAnimationFrame(() => {
|
|
160
|
+
const node = Array.from(rootRef.current?.querySelectorAll('[data-inventory-item]') ?? []).find(element => element.dataset.inventoryItem === item.id);
|
|
161
|
+
node?.focus({ preventScroll: true });
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
function rotate(id) {
|
|
165
|
+
if (!allowRotation || unavailable)
|
|
166
|
+
return;
|
|
167
|
+
const source = containers.find(container => container.items.some(item => item.id === id));
|
|
168
|
+
const item = source?.items.find(entry => entry.id === id);
|
|
169
|
+
if (!item || !source || item.disabled || item.rotatable === false || source.disabled)
|
|
170
|
+
return;
|
|
171
|
+
const rotation = (((activeId === id ? rotationRef.current : item.rotation ?? 0) + 90) % 360);
|
|
172
|
+
if (activeId === id) {
|
|
173
|
+
rotationRef.current = rotation;
|
|
174
|
+
if (eventRef.current)
|
|
175
|
+
updateProjection(eventRef.current);
|
|
176
|
+
else {
|
|
177
|
+
const next = { ...(projectionRef.current ?? { itemId: id, containerId: source.id, position: item.position }), rotation };
|
|
178
|
+
projectionRef.current = next;
|
|
179
|
+
setProjection(next);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
else
|
|
183
|
+
commit({ itemId: id, containerId: source.id, position: item.position, rotation });
|
|
184
|
+
}
|
|
185
|
+
return _jsxs(DndContext, { sensors: sensors, collisionDetection: collisionDetection, accessibility: { screenReaderInstructions: { draggable: labels.instructions }, announcements: {
|
|
186
|
+
onDragStart: () => undefined, onDragOver: () => undefined, onDragEnd: () => undefined, onDragCancel: () => undefined,
|
|
187
|
+
} }, onDragStart: event => {
|
|
188
|
+
const id = event.active.data.current?.itemId;
|
|
189
|
+
const item = containers.flatMap(container => container.items).find(entry => entry.id === id);
|
|
190
|
+
if (!item || unavailable)
|
|
191
|
+
return;
|
|
192
|
+
const node = Array.from(rootRef.current?.querySelectorAll('[data-inventory-item]') ?? []).find(element => element.dataset.inventoryItem === id);
|
|
193
|
+
const rect = node?.getBoundingClientRect();
|
|
194
|
+
// The portal is outside any ancestor transform; keep the pickup's visual size.
|
|
195
|
+
overlayScale.current = { x: rect && node?.offsetWidth ? rect.width / node.offsetWidth : 1,
|
|
196
|
+
y: rect && node?.offsetHeight ? rect.height / node.offsetHeight : 1 };
|
|
197
|
+
rotationRef.current = item.rotation ?? 0;
|
|
198
|
+
setActiveId(id);
|
|
199
|
+
setSelectedId(id);
|
|
200
|
+
setAnnouncement(labels.pickedUp(getItemLabel(item)));
|
|
201
|
+
const container = containers.find(entry => entry.items.some(value => value.id === id));
|
|
202
|
+
const initial = { itemId: id, containerId: container.id, position: item.position, rotation: item.rotation ?? 0 };
|
|
203
|
+
projectionRef.current = initial;
|
|
204
|
+
setProjection(initial);
|
|
205
|
+
}, onDragMove: updateProjection, onDragOver: updateProjection, onDragEnd: event => { updateProjection(event); const next = projectionRef.current; if (next)
|
|
206
|
+
commit(next);
|
|
207
|
+
else if (activeItem)
|
|
208
|
+
setAnnouncement(labels.cancelled(getItemLabel(activeItem))); reset(); }, onDragCancel: () => { if (activeItem)
|
|
209
|
+
setAnnouncement(labels.cancelled(getItemLabel(activeItem))); reset(); }, children: [_jsxs("div", { className: ['inkra-inventory-grid', className].filter(Boolean).join(' '), ref: captureRoot, role: "group", "aria-label": ariaLabel, "data-readonly": readOnly || undefined, "data-disabled": disabled || undefined, onKeyDownCapture: event => { if (activeId && event.key.toLowerCase() === 'r') {
|
|
210
|
+
event.preventDefault();
|
|
211
|
+
event.stopPropagation();
|
|
212
|
+
rotate(activeId);
|
|
213
|
+
} }, children: [containers.map(container => _jsx(ContainerView, { container: container, size: size, disabled: unavailable, selectedId: selectedId, onSelect: setSelectedId, onRotate: rotate, renderItem: renderItem, getItemLabel: getItemLabel, allowRotation: allowRotation, projection: projection, activeItem: activeItem, valid: valid, rotateLabel: labels.rotate }, container.id)), _jsx("span", { className: "inkra-inventory-grid__announcement", role: "status", "aria-live": "polite", "aria-atomic": "true", children: announcement })] }), rootRef.current && createPortal(_jsx(PortalThemeBridge, { children: _jsx(DragOverlay, { dropAnimation: null, children: activeItem && _jsx("div", { className: "inkra-inventory-grid__overlay", "aria-hidden": "true", style: { width: bounds(rotateInventoryCells(activeItem.cells, rotationRef.current)).width * size, height: bounds(rotateInventoryCells(activeItem.cells, rotationRef.current)).height * size,
|
|
214
|
+
transform: `scale(${overlayScale.current.x}, ${overlayScale.current.y})`, transformOrigin: '0 0' }, children: _jsx(Shape, { cells: rotateInventoryCells(activeItem.cells, rotationRef.current), size: size, seed: activeItem.id, children: renderItem(activeItem, { selected: true, dragging: true, rotation: rotationRef.current, overlay: true }) }) }) }) }), rootRef.current.ownerDocument.body)] });
|
|
215
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
export interface InventoryCell {
|
|
2
|
+
x: number;
|
|
3
|
+
y: number;
|
|
4
|
+
}
|
|
5
|
+
export type InventoryRotation = 0 | 90 | 180 | 270;
|
|
6
|
+
export interface InventoryItem {
|
|
7
|
+
/** Unique across all containers. */
|
|
8
|
+
id: string;
|
|
9
|
+
label?: string;
|
|
10
|
+
cells: readonly InventoryCell[];
|
|
11
|
+
position: InventoryCell;
|
|
12
|
+
rotation?: InventoryRotation;
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
rotatable?: boolean;
|
|
15
|
+
}
|
|
16
|
+
export interface InventoryContainer<Item extends InventoryItem = InventoryItem> {
|
|
17
|
+
id: string;
|
|
18
|
+
label?: string;
|
|
19
|
+
columns: number;
|
|
20
|
+
rows: number;
|
|
21
|
+
items: readonly Item[];
|
|
22
|
+
disabledCells?: readonly InventoryCell[];
|
|
23
|
+
disabled?: boolean;
|
|
24
|
+
}
|
|
25
|
+
export interface InventoryPlacement {
|
|
26
|
+
itemId: string;
|
|
27
|
+
containerId: string;
|
|
28
|
+
position: InventoryCell;
|
|
29
|
+
rotation: InventoryRotation;
|
|
30
|
+
}
|
|
31
|
+
export interface InventoryPlacementContext<Item extends InventoryItem = InventoryItem> {
|
|
32
|
+
item: Item;
|
|
33
|
+
source: InventoryContainer<Item>;
|
|
34
|
+
target: InventoryContainer<Item>;
|
|
35
|
+
placement: InventoryPlacement;
|
|
36
|
+
cells: readonly InventoryCell[];
|
|
37
|
+
containers: readonly InventoryContainer<Item>[];
|
|
38
|
+
}
|
|
39
|
+
export type InventoryPlacementRule<Item extends InventoryItem = InventoryItem> = (context: InventoryPlacementContext<Item>) => boolean;
|
|
40
|
+
export type InventoryPlacementResult = {
|
|
41
|
+
valid: true;
|
|
42
|
+
} | {
|
|
43
|
+
valid: false;
|
|
44
|
+
reason: 'missing' | 'disabled' | 'shape' | 'bounds' | 'blocked' | 'occupied' | 'rule';
|
|
45
|
+
};
|
|
46
|
+
export interface InventoryChangeDetails {
|
|
47
|
+
type: 'move' | 'transfer' | 'rotate';
|
|
48
|
+
from: InventoryPlacement;
|
|
49
|
+
to: InventoryPlacement;
|
|
50
|
+
}
|
|
51
|
+
/** Rotations retain the top-left occupied bounding-box anchor. */
|
|
52
|
+
export declare function rotateInventoryCells(cells: readonly InventoryCell[], rotation?: InventoryRotation): InventoryCell[];
|
|
53
|
+
export declare function assertInventoryContainers<Item extends InventoryItem>(containers: readonly InventoryContainer<Item>[]): void;
|
|
54
|
+
export declare function validateInventoryPlacement<Item extends InventoryItem>(containers: readonly InventoryContainer<Item>[], placement: InventoryPlacement, canPlace?: InventoryPlacementRule<Item>): InventoryPlacementResult;
|
|
55
|
+
/** A rejected move preserves the caller's reference and never removes an item. */
|
|
56
|
+
export declare function projectInventoryMove<Item extends InventoryItem>(containers: readonly InventoryContainer<Item>[], placement: InventoryPlacement, canPlace?: InventoryPlacementRule<Item>): readonly InventoryContainer<Item>[];
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
const key = ({ x, y }) => `${x},${y}`;
|
|
2
|
+
const isCell = ({ x, y }) => Number.isSafeInteger(x) && Number.isSafeInteger(y);
|
|
3
|
+
/** Rotations retain the top-left occupied bounding-box anchor. */
|
|
4
|
+
export function rotateInventoryCells(cells, rotation = 0) {
|
|
5
|
+
if (!cells.length || cells.length > 4096 || !cells.every(isCell) || ![0, 90, 180, 270].includes(rotation))
|
|
6
|
+
return [];
|
|
7
|
+
const rotated = cells.map(({ x, y }) => rotation === 90 ? { x: -y, y: x }
|
|
8
|
+
: rotation === 180 ? { x: -x, y: -y } : rotation === 270 ? { x: y, y: -x } : { x, y });
|
|
9
|
+
const minX = Math.min(...rotated.map(cell => cell.x));
|
|
10
|
+
const minY = Math.min(...rotated.map(cell => cell.y));
|
|
11
|
+
return [...new Map(rotated.map(cell => {
|
|
12
|
+
const normalized = { x: cell.x - minX, y: cell.y - minY };
|
|
13
|
+
return [key(normalized), normalized];
|
|
14
|
+
})).values()].sort((a, b) => a.y - b.y || a.x - b.x);
|
|
15
|
+
}
|
|
16
|
+
export function assertInventoryContainers(containers) {
|
|
17
|
+
const containerIds = new Set();
|
|
18
|
+
const itemIds = new Set();
|
|
19
|
+
for (const container of containers) {
|
|
20
|
+
if (!container.id.trim())
|
|
21
|
+
throw new Error('InventoryGrid: container ids must not be empty.');
|
|
22
|
+
if (containerIds.has(container.id))
|
|
23
|
+
throw new Error(`InventoryGrid: duplicate container id "${container.id}".`);
|
|
24
|
+
containerIds.add(container.id);
|
|
25
|
+
if (!Number.isSafeInteger(container.columns) || !Number.isSafeInteger(container.rows)
|
|
26
|
+
|| container.columns < 1 || container.rows < 1 || container.columns * container.rows > 4096) {
|
|
27
|
+
throw new Error('InventoryGrid: containers require positive integer dimensions and at most 4096 cells.');
|
|
28
|
+
}
|
|
29
|
+
const occupied = new Set((container.disabledCells ?? []).map(cell => {
|
|
30
|
+
if (!isCell(cell) || cell.x < 0 || cell.y < 0 || cell.x >= container.columns || cell.y >= container.rows) {
|
|
31
|
+
throw new Error('InventoryGrid: disabled cells must be inside their container.');
|
|
32
|
+
}
|
|
33
|
+
return key(cell);
|
|
34
|
+
}));
|
|
35
|
+
for (const item of container.items) {
|
|
36
|
+
if (!item.id.trim())
|
|
37
|
+
throw new Error('InventoryGrid: item ids must not be empty.');
|
|
38
|
+
if (itemIds.has(item.id))
|
|
39
|
+
throw new Error(`InventoryGrid: duplicate item id "${item.id}".`);
|
|
40
|
+
itemIds.add(item.id);
|
|
41
|
+
const cells = rotateInventoryCells(item.cells, item.rotation);
|
|
42
|
+
if (!cells.length || !isCell(item.position))
|
|
43
|
+
throw new Error(`InventoryGrid: invalid shape or position for "${item.id}".`);
|
|
44
|
+
for (const cell of cells) {
|
|
45
|
+
const x = cell.x + item.position.x;
|
|
46
|
+
const y = cell.y + item.position.y;
|
|
47
|
+
if (x < 0 || y < 0 || x >= container.columns || y >= container.rows || occupied.has(key({ x, y }))) {
|
|
48
|
+
throw new Error(`InventoryGrid: overlapping, blocked or out-of-bounds item "${item.id}".`);
|
|
49
|
+
}
|
|
50
|
+
occupied.add(key({ x, y }));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export function validateInventoryPlacement(containers, placement, canPlace) {
|
|
56
|
+
const source = containers.find(container => container.items.some(item => item.id === placement.itemId));
|
|
57
|
+
const item = source?.items.find(entry => entry.id === placement.itemId);
|
|
58
|
+
const target = containers.find(container => container.id === placement.containerId);
|
|
59
|
+
if (!source || !item || !target)
|
|
60
|
+
return { valid: false, reason: 'missing' };
|
|
61
|
+
if (source.disabled || target.disabled || item.disabled || (item.rotatable === false && placement.rotation !== (item.rotation ?? 0)))
|
|
62
|
+
return { valid: false, reason: 'disabled' };
|
|
63
|
+
const shape = rotateInventoryCells(item.cells, placement.rotation);
|
|
64
|
+
if (!shape.length || !isCell(placement.position))
|
|
65
|
+
return { valid: false, reason: 'shape' };
|
|
66
|
+
const cells = shape.map(cell => ({ x: cell.x + placement.position.x, y: cell.y + placement.position.y }));
|
|
67
|
+
if (cells.some(cell => cell.x < 0 || cell.y < 0 || cell.x >= target.columns || cell.y >= target.rows))
|
|
68
|
+
return { valid: false, reason: 'bounds' };
|
|
69
|
+
const blocked = new Set(target.disabledCells?.map(key));
|
|
70
|
+
if (cells.some(cell => blocked.has(key(cell))))
|
|
71
|
+
return { valid: false, reason: 'blocked' };
|
|
72
|
+
const occupied = new Set(target.items.filter(entry => entry.id !== item.id).flatMap(entry => rotateInventoryCells(entry.cells, entry.rotation).map(cell => key({ x: cell.x + entry.position.x, y: cell.y + entry.position.y }))));
|
|
73
|
+
if (cells.some(cell => occupied.has(key(cell))))
|
|
74
|
+
return { valid: false, reason: 'occupied' };
|
|
75
|
+
if (canPlace && !canPlace({ item, source, target, placement, cells, containers }))
|
|
76
|
+
return { valid: false, reason: 'rule' };
|
|
77
|
+
return { valid: true };
|
|
78
|
+
}
|
|
79
|
+
/** A rejected move preserves the caller's reference and never removes an item. */
|
|
80
|
+
export function projectInventoryMove(containers, placement, canPlace) {
|
|
81
|
+
if (!validateInventoryPlacement(containers, placement, canPlace).valid)
|
|
82
|
+
return containers;
|
|
83
|
+
const source = containers.find(container => container.items.some(item => item.id === placement.itemId));
|
|
84
|
+
const item = source.items.find(entry => entry.id === placement.itemId);
|
|
85
|
+
if (source.id === placement.containerId && item.position.x === placement.position.x && item.position.y === placement.position.y
|
|
86
|
+
&& (item.rotation ?? 0) === placement.rotation)
|
|
87
|
+
return containers;
|
|
88
|
+
const moved = { ...item, position: { ...placement.position }, rotation: placement.rotation };
|
|
89
|
+
return containers.map(container => container.id === source.id && container.id === placement.containerId
|
|
90
|
+
? { ...container, items: container.items.map(entry => entry.id === item.id ? moved : entry) }
|
|
91
|
+
: container.id === source.id ? { ...container, items: container.items.filter(entry => entry.id !== item.id) }
|
|
92
|
+
: container.id === placement.containerId ? { ...container, items: [...container.items, moved] } : container);
|
|
93
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { InventoryCell } from './model.js';
|
|
2
|
+
/** Trace occupied-cell boundaries, keeping holes and disconnected islands separate. */
|
|
3
|
+
export declare function inventoryOutline(cells: readonly InventoryCell[], size: number, inset?: number): {
|
|
4
|
+
path: string;
|
|
5
|
+
segments: {
|
|
6
|
+
start: InventoryCell;
|
|
7
|
+
end: InventoryCell;
|
|
8
|
+
}[];
|
|
9
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
const key = ({ x, y }) => `${x}:${y}`;
|
|
2
|
+
/** Trace occupied-cell boundaries, keeping holes and disconnected islands separate. */
|
|
3
|
+
export function inventoryOutline(cells, size, inset = 3) {
|
|
4
|
+
const occupied = new Set(cells.map(key));
|
|
5
|
+
const outgoing = new Map();
|
|
6
|
+
const remaining = new Set();
|
|
7
|
+
function edge(start, end, direction) {
|
|
8
|
+
const value = { start, end, direction };
|
|
9
|
+
outgoing.set(key(start), [...(outgoing.get(key(start)) ?? []), value]);
|
|
10
|
+
remaining.add(value);
|
|
11
|
+
}
|
|
12
|
+
for (const { x, y } of cells) {
|
|
13
|
+
if (!occupied.has(key({ x, y: y - 1 })))
|
|
14
|
+
edge({ x, y }, { x: x + 1, y }, 0);
|
|
15
|
+
if (!occupied.has(key({ x: x + 1, y })))
|
|
16
|
+
edge({ x: x + 1, y }, { x: x + 1, y: y + 1 }, 1);
|
|
17
|
+
if (!occupied.has(key({ x, y: y + 1 })))
|
|
18
|
+
edge({ x: x + 1, y: y + 1 }, { x, y: y + 1 }, 2);
|
|
19
|
+
if (!occupied.has(key({ x: x - 1, y })))
|
|
20
|
+
edge({ x, y: y + 1 }, { x, y }, 3);
|
|
21
|
+
}
|
|
22
|
+
const contours = [];
|
|
23
|
+
const turnPriority = [1, 0, 3, 2];
|
|
24
|
+
while (remaining.size) {
|
|
25
|
+
const first = remaining.values().next().value;
|
|
26
|
+
const loop = [];
|
|
27
|
+
let current = first;
|
|
28
|
+
while (current) {
|
|
29
|
+
loop.push(current);
|
|
30
|
+
remaining.delete(current);
|
|
31
|
+
if (key(current.end) === key(first.start))
|
|
32
|
+
break;
|
|
33
|
+
const direction = current.direction;
|
|
34
|
+
// At a diagonal contact, follow the same occupied island instead of bridging it.
|
|
35
|
+
current = (outgoing.get(key(current.end)) ?? [])
|
|
36
|
+
.filter(candidate => remaining.has(candidate))
|
|
37
|
+
.sort((a, b) => turnPriority.indexOf((a.direction - direction + 4) % 4)
|
|
38
|
+
- turnPriority.indexOf((b.direction - direction + 4) % 4))[0];
|
|
39
|
+
}
|
|
40
|
+
const corners = loop.filter((value, index) => value.direction !== loop[(index + loop.length - 1) % loop.length].direction);
|
|
41
|
+
contours.push(corners.map((value, index) => {
|
|
42
|
+
const previous = corners[(index + corners.length - 1) % corners.length];
|
|
43
|
+
const normal = (direction) => [{ x: 0, y: 1 }, { x: -1, y: 0 }, { x: 0, y: -1 }, { x: 1, y: 0 }][direction];
|
|
44
|
+
const before = normal(previous.direction);
|
|
45
|
+
const after = normal(value.direction);
|
|
46
|
+
return { x: value.start.x * size + (before.x + after.x) * inset,
|
|
47
|
+
y: value.start.y * size + (before.y + after.y) * inset };
|
|
48
|
+
}));
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
path: contours.map(points => `M${points.map(point => `${point.x},${point.y}`).join('L')}Z`).join(''),
|
|
52
|
+
segments: contours.flatMap(points => points.map((start, index) => ({ start, end: points[(index + 1) % points.length] }))),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { HTMLAttributes, ReactNode } from 'react';
|
|
2
|
+
import type { Layout, LayoutItem } from 'react-grid-layout';
|
|
3
|
+
export type GridBoardLayout = Layout;
|
|
4
|
+
export type GridBoardLayoutItem = LayoutItem;
|
|
5
|
+
export interface GridBoardProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'onChange'> {
|
|
6
|
+
layout?: GridBoardLayout;
|
|
7
|
+
defaultLayout?: GridBoardLayout;
|
|
8
|
+
onLayoutChange?: (layout: GridBoardLayout) => void;
|
|
9
|
+
columns?: number;
|
|
10
|
+
rowHeight?: number;
|
|
11
|
+
gap?: number;
|
|
12
|
+
draggable?: boolean;
|
|
13
|
+
resizable?: boolean;
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
/** Container width below which cards become a non-editable single column. 0 disables stacking. */
|
|
16
|
+
stackAt?: number;
|
|
17
|
+
compact?: 'vertical' | 'horizontal' | null;
|
|
18
|
+
getItemLabel?: (id: string) => string;
|
|
19
|
+
getActionLabel?: (action: 'move' | 'resize', id: string) => string;
|
|
20
|
+
children: (item: GridBoardLayoutItem) => ReactNode;
|
|
21
|
+
}
|
|
22
|
+
export declare function GridBoard({ layout, defaultLayout, onLayoutChange, columns, rowHeight, gap, draggable, resizable, disabled, stackAt, compact, getItemLabel, getActionLabel, children, className, style, ...props }: GridBoardProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
3
|
+
import { GridItem, useContainerWidth, useGridLayout } from 'react-grid-layout';
|
|
4
|
+
import { cloneLayout, correctBounds, getCompactor, moveElement } from 'react-grid-layout/core';
|
|
5
|
+
import { __iconData as moveIcon } from 'lucide-react/dist/esm/icons/move.mjs';
|
|
6
|
+
import { __iconData as resizeIcon } from 'lucide-react/dist/esm/icons/move-diagonal-2.mjs';
|
|
7
|
+
import { PencilIcon } from '../PencilIcon.js';
|
|
8
|
+
import { PencilSurface } from '../PencilSurface.js';
|
|
9
|
+
import { PencilText } from '../PencilText.js';
|
|
10
|
+
import { useInkraLocale } from '../i18n/index.js';
|
|
11
|
+
const paths = (icon) => icon.node.flatMap(([tag, attrs]) => tag === 'path' && typeof attrs.d === 'string' ? [{ d: attrs.d }] : []);
|
|
12
|
+
const movePaths = paths(moveIcon);
|
|
13
|
+
const resizePaths = paths(resizeIcon);
|
|
14
|
+
const same = (a, b) => a.length === b.length && a.every((item, index) => {
|
|
15
|
+
const other = b[index];
|
|
16
|
+
return other && item.i === other.i && item.x === other.x && item.y === other.y && item.w === other.w && item.h === other.h;
|
|
17
|
+
});
|
|
18
|
+
const sameConfiguration = (a, b) => same(a, b) && a.every((item, index) => {
|
|
19
|
+
const other = b[index];
|
|
20
|
+
return ['minW', 'minH', 'maxW', 'maxH', 'isDraggable', 'isResizable', 'isBounded'].every(key => item[key] === other[key])
|
|
21
|
+
&& Boolean(item.static) === Boolean(other.static)
|
|
22
|
+
&& item.constraints === other.constraints
|
|
23
|
+
&& (item.resizeHandles ?? []).join(',') === (other.resizeHandles ?? []).join(',');
|
|
24
|
+
});
|
|
25
|
+
function validateBoardLayout(value) {
|
|
26
|
+
if (!Array.isArray(value))
|
|
27
|
+
throw new TypeError('Grid.Board layout must be an array.');
|
|
28
|
+
const ids = new Set();
|
|
29
|
+
for (const item of value) {
|
|
30
|
+
if (!item || typeof item.i !== 'string' || !item.i.trim() || ids.has(item.i)) {
|
|
31
|
+
throw new TypeError('Grid.Board layout items must have unique, non-empty string ids (i).');
|
|
32
|
+
}
|
|
33
|
+
ids.add(item.i);
|
|
34
|
+
for (const key of ['x', 'y', 'w', 'h']) {
|
|
35
|
+
const minimum = key === 'w' || key === 'h' ? 1 : 0;
|
|
36
|
+
if (!Number.isSafeInteger(item[key]) || item[key] < minimum) {
|
|
37
|
+
throw new RangeError(`Grid.Board item "${item.i}" ${key} must be a finite integer >= ${minimum}.`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
for (const key of ['minW', 'minH', 'maxW', 'maxH']) {
|
|
41
|
+
const size = item[key];
|
|
42
|
+
if (size !== undefined && !(key.startsWith('max') && size === Infinity) && (!Number.isSafeInteger(size) || size < 1)) {
|
|
43
|
+
throw new RangeError(`Grid.Board item "${item.i}" ${key} must be a positive integer (or Infinity for a maximum).`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if ((item.minW ?? 1) > (item.maxW ?? Infinity) || (item.minH ?? 1) > (item.maxH ?? Infinity)) {
|
|
47
|
+
throw new RangeError(`Grid.Board item "${item.i}" minimum size cannot exceed its maximum.`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export function GridBoard({ layout, defaultLayout = [], onLayoutChange, columns = 12, rowHeight = 48, gap = 12, draggable = true, resizable = true, disabled = false, stackAt = 480, compact = 'vertical', getItemLabel = id => id, getActionLabel, children, className, style, ...props }) {
|
|
52
|
+
const { t } = useInkraLocale();
|
|
53
|
+
const [local, setLocal] = useState(defaultLayout);
|
|
54
|
+
const current = layout ?? local;
|
|
55
|
+
const cols = Number.isFinite(columns) ? Math.max(1, Math.min(48, Math.trunc(columns) || 12)) : 12;
|
|
56
|
+
const height = Number.isFinite(rowHeight) ? Math.max(24, rowHeight) : 48;
|
|
57
|
+
const spacing = Number.isFinite(gap) ? Math.max(0, gap) : 12;
|
|
58
|
+
const { width, mounted, containerRef } = useContainerWidth({ initialWidth: 800 });
|
|
59
|
+
const stacked = mounted && stackAt > 0 && width < stackAt;
|
|
60
|
+
const compactor = getCompactor(compact);
|
|
61
|
+
const resolved = useMemo(() => {
|
|
62
|
+
validateBoardLayout(current);
|
|
63
|
+
return compactor.compact(correctBounds(cloneLayout(current), { cols }), cols);
|
|
64
|
+
}, [current, cols, compactor]);
|
|
65
|
+
const engine = useGridLayout({ layout: resolved, cols, compactor });
|
|
66
|
+
const pending = useRef(false);
|
|
67
|
+
const latest = useRef({ current, layout, onLayoutChange });
|
|
68
|
+
latest.current = { current: resolved, layout, onLayoutChange };
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (engine.isInteracting || !pending.current)
|
|
71
|
+
return;
|
|
72
|
+
pending.current = false;
|
|
73
|
+
const state = latest.current;
|
|
74
|
+
if (!same(engine.layout, state.current)) {
|
|
75
|
+
const next = cloneLayout(engine.layout);
|
|
76
|
+
if (state.layout === undefined)
|
|
77
|
+
setLocal(next);
|
|
78
|
+
state.onLayoutChange?.(next);
|
|
79
|
+
}
|
|
80
|
+
// Controlled values are requests, including when the owner rejects an update.
|
|
81
|
+
if (state.layout !== undefined)
|
|
82
|
+
engine.setLayout(state.current);
|
|
83
|
+
}, [engine.layout, engine.isInteracting, engine.setLayout]);
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
if (!engine.isInteracting && !pending.current && !sameConfiguration(resolved, engine.layout))
|
|
86
|
+
engine.setLayout(resolved);
|
|
87
|
+
}, [resolved, engine.layout, engine.isInteracting, engine.setLayout]);
|
|
88
|
+
const actionLabel = (action, id) => getActionLabel?.(action, id)
|
|
89
|
+
?? `${t(action === 'move' ? 'sortable.handle' : 'resizable.label')}: ${getItemLabel(id)}`;
|
|
90
|
+
const keyAdjust = (event, item, resize) => {
|
|
91
|
+
const delta = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[event.key];
|
|
92
|
+
if (!delta || disabled || stacked || item.static || (resize ? !resizable || item.isResizable === false : !draggable || item.isDraggable === false))
|
|
93
|
+
return;
|
|
94
|
+
event.preventDefault();
|
|
95
|
+
pending.current = true;
|
|
96
|
+
const [dx, dy] = delta;
|
|
97
|
+
if (resize) {
|
|
98
|
+
const w = Math.min(item.maxW ?? cols, cols - item.x, Math.max(item.minW ?? 1, item.w + dx));
|
|
99
|
+
const h = Math.min(item.maxH ?? Infinity, Math.max(item.minH ?? 1, item.h + dy));
|
|
100
|
+
engine.onResizeStop(item.i, w, h);
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
const next = cloneLayout(engine.layout);
|
|
104
|
+
const target = next.find(value => value.i === item.i);
|
|
105
|
+
engine.setLayout(moveElement(next, target, Math.max(0, Math.min(cols - item.w, item.x + dx)), Math.max(0, item.y + dy), true, false, compact, cols));
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
return _jsx("div", { ...props, ref: containerRef, className: ['inkra-grid-board', className].filter(Boolean).join(' '), "data-stacked": stacked || undefined, style: { '--inkra-grid-board-gap': `${spacing}px`, ...style }, children: _jsx("div", { className: "inkra-grid-board__canvas", style: { height: stacked ? undefined : Math.max(0, engine.containerHeight * (height + spacing) - spacing) }, children: engine.layout.map(item => {
|
|
109
|
+
const canMove = draggable && !disabled && !stacked && !item.static && item.isDraggable !== false;
|
|
110
|
+
const canResize = resizable && !disabled && !stacked && !item.static && item.isResizable !== false;
|
|
111
|
+
return _jsx(GridItem, { ...item, cols: cols, containerWidth: width, rowHeight: height, margin: [spacing, spacing], containerPadding: [0, 0], maxRows: Infinity, isBounded: false, isDraggable: canMove, isResizable: canResize, allowMobileScroll: true, dragThreshold: 4, handle: ".inkra-grid-board__move", cancel: ".inkra-grid-board__content", resizeHandles: ['se'], className: "inkra-grid-board__item", usePercentages: !mounted, useCSSTransforms: mounted, onDragStart: (id, x, y) => { pending.current = true; engine.onDragStart(id, x, y); }, onDrag: engine.onDrag, onDragStop: engine.onDragStop, onResizeStart: id => { pending.current = true; engine.onResizeStart(id); }, onResize: (id, w, h) => engine.onResize(id, w, h), onResizeStop: engine.onResizeStop, resizeHandle: (axis, ref) => _jsxs("button", { ref: ref, type: "button", className: `react-resizable-handle react-resizable-handle-${axis} inkra-grid-board__resize`, disabled: !canResize, tabIndex: canResize ? 0 : -1, "aria-label": actionLabel('resize', item.i), onKeyDown: event => keyAdjust(event, item, true), children: [_jsx(PencilSurface, { as: "span", variant: "outline", "aria-hidden": "true", className: "inkra-grid-board__focus" }), _jsx(PencilIcon, { paths: resizePaths, size: 18 })] }), children: _jsx("div", { "data-grid-item": item.i, children: _jsxs(PencilSurface, { variant: "paper", className: "inkra-grid-board__surface", children: [_jsxs("header", { className: "inkra-grid-board__header", children: [_jsx(PencilText, { className: "inkra-grid-board__title", children: getItemLabel(item.i) }), canMove && _jsxs("button", { type: "button", className: "inkra-grid-board__move", "aria-label": actionLabel('move', item.i), onKeyDown: event => keyAdjust(event, item, false), children: [_jsx(PencilSurface, { as: "span", variant: "outline", "aria-hidden": "true", className: "inkra-grid-board__focus" }), _jsx(PencilIcon, { paths: movePaths, size: 18 })] })] }), _jsx("div", { className: "inkra-grid-board__content", children: children(item) })] }) }) }, item.i);
|
|
112
|
+
}) }) });
|
|
113
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { HTMLAttributes, ReactNode } from 'react';
|
|
2
|
+
import type { ResizableLayout, ResizablePanelProps } from '../resizable/index.js';
|
|
3
|
+
export interface GridTrack {
|
|
4
|
+
id: string;
|
|
5
|
+
minSize?: ResizablePanelProps['minSize'];
|
|
6
|
+
maxSize?: ResizablePanelProps['maxSize'];
|
|
7
|
+
}
|
|
8
|
+
export interface GridResizableProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {
|
|
9
|
+
columns: readonly GridTrack[];
|
|
10
|
+
rows: readonly GridTrack[];
|
|
11
|
+
columnSizes?: ResizableLayout;
|
|
12
|
+
defaultColumnSizes?: ResizableLayout;
|
|
13
|
+
rowSizes?: ResizableLayout;
|
|
14
|
+
defaultRowSizes?: ResizableLayout;
|
|
15
|
+
onColumnSizesChange?: (sizes: ResizableLayout) => void;
|
|
16
|
+
onRowSizesChange?: (sizes: ResizableLayout) => void;
|
|
17
|
+
onColumnSizesCommit?: (sizes: ResizableLayout) => void;
|
|
18
|
+
onRowSizesCommit?: (sizes: ResizableLayout) => void;
|
|
19
|
+
resizeColumns?: boolean;
|
|
20
|
+
resizeRows?: boolean;
|
|
21
|
+
disabled?: boolean;
|
|
22
|
+
getHandleLabel?: (axis: 'column' | 'row', before: string, after: string) => string;
|
|
23
|
+
children: (cell: {
|
|
24
|
+
row: string;
|
|
25
|
+
column: string;
|
|
26
|
+
rowIndex: number;
|
|
27
|
+
columnIndex: number;
|
|
28
|
+
}) => ReactNode;
|
|
29
|
+
}
|
|
30
|
+
/** Shared column sizes keep every row aligned; the existing Resizable engine owns all gestures. */
|
|
31
|
+
export declare const GridResizable: import("react").ForwardRefExoticComponent<GridResizableProps & import("react").RefAttributes<HTMLDivElement>>;
|