Skip to content

Commit a835a48

Browse files
committed
feat(FileOrganizer): multi-directional movement and focusing
1 parent 2ca31b8 commit a835a48

File tree

9 files changed

+136
-46
lines changed

9 files changed

+136
-46
lines changed

.storybook/style.scss

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,25 @@ body {
5252
// Change appearance of inserted stories.
5353
#docs-root {
5454
.sbdocs-content {
55+
kbd {
56+
font-family: 'IBM Plex Mono', 'Operator Mono', 'Fira Code Retina',
57+
'Fira Code', 'FiraCode-Retina', 'Andale Mono', 'Lucida Console',
58+
Consolas, Monaco, monospace;
59+
60+
background-color: #eee;
61+
border-radius: 3px;
62+
border: 1px solid #b4b4b4;
63+
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2),
64+
0 2px 0 0 rgba(255, 255, 255, 0.7) inset;
65+
color: #333;
66+
display: inline-block;
67+
font-size: 0.75rem;
68+
font-weight: 700;
69+
line-height: 1;
70+
padding: 2px 4px;
71+
white-space: nowrap;
72+
}
73+
5574
.sbdocs-preview {
5675
// Remove `Show code` button for inserted stories.
5776
> div:nth-child(1) > div:nth-child(2) {

src/components/EditableText/EditableText.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,8 +139,8 @@ export const EditableText = forwardRef<HTMLDivElement, EditableTextProps>(
139139
const handleOnKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
140140
if (event.key === 'Escape') {
141141
handleOnCancel();
142-
event.stopPropagation();
143142
}
143+
event.stopPropagation();
144144
};
145145

146146
const [valueToDisplay, isPlaceholder] = useMemo(() => {

src/components/FileOrganizer/FileOrganizer.stories.tsx

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { FileOrganizer, FileOrganizerProps } from '../FileOrganizer';
99
import { Thumbnail } from '../Thumbnail';
1010
import { ThumbnailDragLayer } from '../ThumbnailDragLayer';
1111
import readme from './README.md';
12+
import close from '../Thumbnail/close-24px.svg';
1213

1314
export default {
1415
title: 'Components/FileOrganizer',
@@ -20,9 +21,12 @@ interface TemplateProps {
2021
onRenderDragLayer?: boolean;
2122
numFiles?: number;
2223
lazy?: boolean;
24+
editable?: boolean;
2325
}
2426

25-
const Template: FC<TemplateProps> = ({ onRenderDragLayer, numFiles = 2, lazy }) => {
27+
/* eslint-disable @typescript-eslint/no-empty-function */
28+
29+
const Template: FC<TemplateProps> = ({ onRenderDragLayer, numFiles = 2, lazy, editable }) => {
2630
// This is the index organizing function.
2731
const [files, setFiles] = useState<FakeFile[]>([]);
2832
const handleOnMove = useCallback<NonNullable<FileOrganizerProps<FakeFile>['onMove']>>((fromIndex, toIndex) => {
@@ -65,7 +69,23 @@ const Template: FC<TemplateProps> = ({ onRenderDragLayer, numFiles = 2, lazy })
6569
onDeselectAll={action('onDeselectAll')}
6670
onSelectAll={action('onSelectAll')}
6771
onRenderDragLayer={onRenderDragLayer ? () => <ThumbnailDragLayer /> : undefined}
68-
onRenderThumbnail={({ onRenderThumbnailProps }) => <Thumbnail {...onRenderThumbnailProps} />}
72+
onRenderThumbnail={({ onRenderThumbnailProps }) => (
73+
<Thumbnail
74+
{...onRenderThumbnailProps}
75+
onRename={editable ? () => {} : undefined}
76+
buttonProps={
77+
editable
78+
? [
79+
{
80+
children: <img src={close} alt={'close'} />,
81+
onClick: () => {},
82+
key: 0,
83+
},
84+
]
85+
: undefined
86+
}
87+
/>
88+
)}
6989
/>
7090
);
7191
};
@@ -74,6 +94,10 @@ export const Basic = () => {
7494
const numFiles = number('number of files', 2, { min: 0, max: 16, step: 1, range: true });
7595
return <Template numFiles={numFiles} />;
7696
};
97+
export const WithThumbnailButtonsAndEditableText = () => {
98+
const numFiles = number('number of files', 2, { min: 0, max: 16, step: 1, range: true });
99+
return <Template numFiles={numFiles} editable />;
100+
};
77101
export const WithCustomDragLayer = () => {
78102
const numFiles = number('number of files', 2, { min: 0, max: 16, step: 1, range: true });
79103
return <Template numFiles={numFiles} onRenderDragLayer />;

src/components/FileOrganizer/FileOrganizer.tsx

Lines changed: 55 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,14 @@ import React, {
1515
useState,
1616
} from 'react';
1717
import { FixedSizeGrid as Grid } from 'react-window';
18-
import { getRowAndColumnIndex, getSibling, isScrolledIntoView, ObjectWithId, THUMBNAIL_WIDTH } from '../../utils';
18+
import {
19+
getRowAndColumnIndex,
20+
getSibling,
21+
isScrolledIntoView,
22+
ObjectWithId,
23+
THUMBNAIL_WIDTH,
24+
focusableElementDomString,
25+
} from '../../utils';
1926
import { DndMultiProvider } from '../DndMultiProvider';
2027
import { Draggable } from '../Draggable';
2128
import { DragLayer, DragLayerProps } from '../DragLayer';
@@ -185,19 +192,59 @@ export function FileOrganizer<F extends ObjectWithId>({
185192

186193
const handleItemKeyDown = useCallback(
187194
(event: KeyboardEvent<HTMLDivElement>, index: number, _file: F, draggableRef: RefObject<HTMLDivElement>) => {
188-
if (preventArrowsToMove || disableMove || editingId !== undefined || !onMove) return;
189-
if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
195+
let indexDiff = 0;
190196

191-
const indexDiff = event.key === 'ArrowLeft' ? -1 : 1;
197+
switch (event.key) {
198+
case 'ArrowLeft':
199+
indexDiff = -1;
200+
break;
201+
case 'ArrowRight':
202+
indexDiff = 1;
203+
break;
204+
case 'ArrowUp':
205+
indexDiff = -1 * columnCount;
206+
break;
207+
case 'ArrowDown':
208+
indexDiff = columnCount;
209+
break;
210+
default:
211+
return; // Return if not one of above keys
212+
}
192213

193-
onMove(index, index + indexDiff);
194214
event.preventDefault();
195215

216+
let hasMoved = false;
217+
218+
// If meta key was pressed, move to new location.
219+
if (
220+
!preventArrowsToMove &&
221+
(event.metaKey || event.ctrlKey) &&
222+
!disableMove &&
223+
editingId === undefined &&
224+
onMove
225+
) {
226+
hasMoved = true;
227+
onMove(index, index + indexDiff);
228+
}
229+
196230
if (!gridRef.current) return;
197231

198-
const newLocation = getSibling(draggableRef.current, indexDiff);
199-
const { isVisible } = isScrolledIntoView(newLocation, fileOrganizerRef.current);
232+
const siblingAtLocation = getSibling(draggableRef.current, indexDiff);
233+
234+
// If no meta key was pressed, focus item in direction of keys.
235+
if (siblingAtLocation && !(event.metaKey || event.ctrlKey)) {
236+
const focusable = siblingAtLocation.querySelector<HTMLElement>(focusableElementDomString);
237+
if (focusable) {
238+
hasMoved = true;
239+
requestAnimationFrame(() => {
240+
focusable.focus();
241+
});
242+
}
243+
}
244+
245+
if (!hasMoved) return;
200246

247+
const { isVisible } = isScrolledIntoView(siblingAtLocation, fileOrganizerRef.current);
201248
if (isVisible) return;
202249

203250
// Use react-window scrollToItem api for virtualized items.
@@ -281,7 +328,7 @@ export function FileOrganizer<F extends ObjectWithId>({
281328
event => {
282329
onKeyDown?.(event);
283330
if (event.key === 'Escape') return onDeselectAll?.();
284-
if (event.key === 'a' && event.metaKey) {
331+
if (event.key === 'a' && (event.metaKey || event.ctrlKey)) {
285332
onSelectAll?.();
286333
event.preventDefault();
287334
}

src/components/FileOrganizer/README.md

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
11
The file organizer allows you to view multiple page files and use drag-and-drop
2-
to re-order them. Unless `preventArrowsToMove` is set to true, you can also use
3-
the left and right arrows while focusing any part of the file thumbnail in order
4-
to fire `onMove`. The `FileOrganizer` is virtualized, so there will be no
2+
to re-order them. You can also use the arrow keys while focusing any part of the
3+
file thumbnail in order to focus on a new file, or tab to move through them one
4+
by one (tab will also focus internal elements, such as the editable text within
5+
`Thumbnail`). The `FileOrganizer` is virtualized, so there will be no
56
performance issues even with thousands of thumbnails (see the stress test
67
playground).
78

9+
## Moving files
10+
11+
You can click and drag to move items. If you are using `useManagedFiles` hook,
12+
you can hold <kbd>Shift</kbd> to multi-select items and then move them together.
13+
Unless `preventArrowsToMove` is set to true, you can hold the <kbd>⌘
14+
Command</kbd> key on macOS, or the <kbd>Ctrl</kbd> key on Windows and use the
15+
arrow keys, `onMove` will be fired.
16+
817
## Rendering Files
918

1019
The easiest way to get started would be to use the `Thumbnail` element as the

src/components/Thumbnail/Thumbnail.tsx

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import classnames from 'classnames';
2-
import React, { MouseEvent, ReactNode, ReactText, useEffect, useRef } from 'react';
2+
import React, { MouseEvent, ReactNode, ReactText, useRef } from 'react';
33
import { FileLike } from '../../data';
44
import { useFile, useFocus } from '../../hooks';
55
import { ClickableDiv, ClickableDivProps } from '../ClickableDiv';
@@ -90,12 +90,6 @@ export function Thumbnail<F extends FileLike>({
9090

9191
const { focused, handleOnFocus, handleOnBlur } = useFocus(onFocus, onBlur);
9292

93-
useEffect(() => {
94-
if (!selected && focused && thumbnailRef.current) {
95-
thumbnailRef.current.focus();
96-
}
97-
}, [focused, selected]);
98-
9993
const file = useFile(_file, isShownOnLoad ? 0 : throttle);
10094

10195
const handleOnSave = (newName: string) => {
@@ -142,11 +136,7 @@ export function Thumbnail<F extends FileLike>({
142136
</div>
143137
<div className="ui__thumbnail__controls">
144138
{buttonProps?.map(buttonPropObject => (
145-
<ToolButton
146-
disabled={disabled}
147-
onClick={e => buttonPropObject.onClick(e, file.file)}
148-
tabIndex={selected ? undefined : -1}
149-
>
139+
<ToolButton disabled={disabled} onClick={e => buttonPropObject.onClick(e, file.file)}>
150140
{buttonPropObject.children}
151141
</ToolButton>
152142
))}
@@ -161,7 +151,6 @@ export function Thumbnail<F extends FileLike>({
161151
onSave={handleOnSave}
162152
onCancel={handleOnCancel}
163153
onEdit={handleOnEdit}
164-
tabIndex={selected ? undefined : -1}
165154
/>
166155
</ClickableDiv>
167156
);

src/components/Thumbnail/_Thumbnail.scss

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ $thumbnail-border-size: 2px;
3535
}
3636

3737
&:hover,
38-
&--focused.ui__thumbnail--selected:not(.ui__thumbnail--disabled) {
38+
&--focused:not(.ui__thumbnail--disabled) {
3939
// Do not style for hover or focus if anything is dragging
4040
&:not(.ui__thumbnail--dragging):not(.ui__thumbnail--otherDragging) {
4141
.ui__thumbnail__controls {
@@ -47,10 +47,8 @@ $thumbnail-border-size: 2px;
4747
&:hover,
4848
&--focused:not(.ui__thumbnail--disabled) {
4949
// Do not style for hover or focus if anything is dragging
50-
&:not(.ui__thumbnail--dragging):not(.ui__thumbnail--otherDragging) {
51-
&:not(.ui__thumbnail--selected) {
52-
background-color: $color-gray-3;
53-
}
50+
&:not(.ui__thumbnail--dragging):not(.ui__thumbnail--otherDragging):not(.ui__thumbnail--selected) {
51+
background-color: $color-gray-3;
5452
}
5553
}
5654

src/hooks/useFocusTrap.ts

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { RefObject, useCallback, useEffect, useRef } from 'react';
2+
import { focusableElementDomString } from '../utils';
23

34
export interface UseFocusTrapOptions {
45
/**
@@ -7,20 +8,6 @@ export interface UseFocusTrapOptions {
78
focusLastOnUnlock?: boolean;
89
}
910

10-
const focusableElementDomString = [
11-
'a[href]',
12-
'area[href]',
13-
'input:not([disabled]):not([type="hidden"]):not([aria-hidden])',
14-
'select:not([disabled]):not([aria-hidden])',
15-
'textarea:not([disabled]):not([aria-hidden])',
16-
'button:not([disabled]):not([aria-hidden])',
17-
'iframe',
18-
'object',
19-
'embed',
20-
'[contenteditable]',
21-
'[tabindex]:not([tabindex^="-"])',
22-
].join(',');
23-
2411
function findFocusableIndex(elements: NodeListOf<HTMLElement>, toFind: Element | EventTarget | null) {
2512
let index = -1;
2613
if (!toFind) return index;

src/utils/domUtils.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,20 @@ export function generateClickEventFromKeyboardEvent(keyboardEvent: KeyboardEvent
5959
});
6060
return clickEvent;
6161
}
62+
63+
/**
64+
* A string for querying all focusable elements.
65+
*/
66+
export const focusableElementDomString = [
67+
'a[href]',
68+
'area[href]',
69+
'input:not([disabled]):not([type="hidden"]):not([aria-hidden])',
70+
'select:not([disabled]):not([aria-hidden])',
71+
'textarea:not([disabled]):not([aria-hidden])',
72+
'button:not([disabled]):not([aria-hidden])',
73+
'iframe',
74+
'object',
75+
'embed',
76+
'[contenteditable]',
77+
'[tabindex]:not([tabindex^="-"])',
78+
].join(',');

0 commit comments

Comments
 (0)