feat(GlobalCarousel/Jumpers): add EditSlates jumper

This commit is contained in:
Aminejv 2022-01-20 15:47:02 +01:00 committed by Martina
parent fc7d37a2f5
commit c4631b4d18
3 changed files with 763 additions and 529 deletions

View File

@ -1,523 +0,0 @@
import * as React from "react";
import * as Styles from "~/common/styles";
import * as System from "~/components/system";
import * as Jumper from "~/components/system/components/fragments/Jumper";
import * as SVG from "~/common/svg";
import * as Actions from "~/common/actions";
import * as UserBehaviors from "~/common/user-behaviors";
import * as Constants from "~/common/constants";
import * as MobileJumper from "~/components/system/components/fragments/MobileJumper";
import * as Strings from "~/common/strings";
import * as Validations from "~/common/validations";
import * as Events from "~/common/custom-events";
import * as RovingTabIndex from "~/components/core/RovingTabIndex";
import { Show } from "~/components/utility/Show";
import { css } from "@emotion/react";
import { AnimateSharedLayout, motion } from "framer-motion";
import { v4 as uuid } from "uuid";
import { useEventListener } from "~/common/hooks";
const STYLES_CHANNEL_BUTTON = (theme) => css`
position: relative;
padding: 5px 12px 7px;
border: 1px solid ${theme.semantic.borderGrayLight4};
border-radius: 12px;
color: ${theme.semantic.textBlack};
background-color: transparent;
transition: background-color 0.3 ease-in-out;
`;
const STYLES_CHANNEL_BUTTON_SELECTED = (theme) => css`
background-color: ${theme.semantic.bgGrayLight4};
`;
const ChannelButton = React.forwardRef(({ children, isSelected, css, ...props }, ref) => {
return (
<System.ButtonPrimitive
{...props}
ref={ref}
css={[STYLES_CHANNEL_BUTTON, isSelected && STYLES_CHANNEL_BUTTON_SELECTED, css]}
>
<System.P2 nbrOflines={1} as="span">
{children}
</System.P2>
</System.ButtonPrimitive>
);
});
/* -----------------------------------------------------------------------------------------------*/
const STYLES_RETURN_KEY = (theme) => css`
padding: 0px 2px;
border-radius: 6px;
background-color: ${theme.semantic.bgGrayLight};
`;
function ChannelKeyboardShortcut({ searchResults, searchQuery, onAddFileToChannel }) {
const [isFileAdded, setIsFileAdded] = React.useState(false);
React.useLayoutEffect(() => {
if (isFileAdded) {
setIsFileAdded(false);
}
}, [searchQuery]);
const { publicChannels, privateChannels } = searchResults;
const selectedChannel = [...publicChannels, ...privateChannels][0];
useEventListener({
type: "keydown",
handler: (e) => {
if (e.key === "Enter") {
onAddFileToChannel(selectedChannel, selectedChannel.doesContainFile);
setIsFileAdded(true);
}
},
});
// NOTE(amine): don't show the 'select channel ⏎' hint when the channel is created optimistically
if (isFileAdded || !selectedChannel?.ownerId) return null;
return (
<div css={Styles.HORIZONTAL_CONTAINER_CENTERED}>
<System.P3 color="textGray" style={{ display: "inline-flex" }}>
Select {selectedChannel.isPublic ? "public" : "private"} tag "
<System.P3 nbrOflines={1} as="span" style={{ maxWidth: 100 }}>
{selectedChannel.slatename}
</System.P3>
"
</System.P3>
<System.P3 css={STYLES_RETURN_KEY} style={{ marginLeft: 4 }}>
</System.P3>
</div>
);
}
const STYLES_SEARCH_TAGS_INPUT = (theme) => css`
background-color: transparent;
${theme.semantic.textGray};
box-shadow: none;
height: 52px;
padding: 0px;
::placeholder {
color: ${theme.semantic.textGray};
}
`;
const STYLES_SEARCH_TAGS_INPUT_WRAPPER = (theme) => css`
color: ${theme.semantic.textGray};
width: 100%;
margin: 1px;
`;
function ChannelInput({ value, searchResults, onChange, onAddFileToChannel, ...props }) {
const { publicChannels, privateChannels } = searchResults;
const [isShortcutVisible, setShortcutVisibility] = React.useState();
React.useEffect(() => {
if (value && publicChannels.length + privateChannels.length === 1) {
setShortcutVisibility(true);
} else {
setShortcutVisibility(false);
}
}, [value]);
return (
<div css={[STYLES_SEARCH_TAGS_INPUT_WRAPPER, Styles.CONTAINER_CENTERED]}>
<SVG.Hash width={16} />
<div style={{ position: "relative", width: "100%" }}>
<System.Input
full
value={value}
onChange={onChange}
name="search"
placeholder="Search or create a new tag"
inputCss={STYLES_SEARCH_TAGS_INPUT}
{...props}
/>
<div style={{ position: "absolute", top: "50%", transform: "translateY(-50%)", right: 20 }}>
{isShortcutVisible ? (
<ChannelKeyboardShortcut
searchQuery={value}
searchResults={searchResults}
onAddFileToChannel={onAddFileToChannel}
/>
) : null}
</div>
</div>
</div>
);
}
/* -----------------------------------------------------------------------------------------------*/
const STYLES_TAG = (theme) => css`
padding: 7px 12px 9px;
border-radius: 12px;
background-color: ${theme.semantic.bgGrayLight4};
`;
function ChannelsEmpty() {
return (
<div css={Styles.VERTICAL_CONTAINER_CENTERED}>
<System.P2 color="textGrayDark" style={{ textAlign: "center" }}>
You dont have any tags yet. <br /> Start typing above to create one.
</System.P2>
<div css={STYLES_TAG} style={{ marginTop: 19 }}>
<SVG.Hash width={16} height={16} style={{ display: "block" }} />
</div>
</div>
);
}
/* -----------------------------------------------------------------------------------------------*/
const STYLES_CHANNEL_BUTTONS_WRAPPER = css`
display: flex;
flex-wrap: wrap;
margin: calc(-8px + 6px) 0 0 -8px;
width: calc(100% + 8px);
& > * {
margin: 8px 0 0 8px !important;
}
`;
function Channels({
header,
isPublic,
searchQuery,
channels,
isCreatingChannel,
onAddFileToChannel,
onCreateChannel,
}) {
const showChannel = !isCreatingChannel && channels.length === 0;
return !showChannel ? (
<div>
<System.H6 as="h2" color="textGray">
{isCreatingChannel ? `Create ${header.toLowerCase()} tag` : header}
</System.H6>
<Show when={isCreatingChannel && isPublic}>
<System.P3 color="textGray" style={{ marginTop: 2 }}>
Objects with a public tag will show up on your public profile.
</System.P3>
</Show>
<AnimateSharedLayout>
<RovingTabIndex.Provider axis="horizontal">
<RovingTabIndex.List css={STYLES_CHANNEL_BUTTONS_WRAPPER}>
{channels.map((channel, index) => (
<motion.div layoutId={`jumper-${channel.id}`} initial={false} key={channel.id}>
<RovingTabIndex.Item index={index}>
<ChannelButton
isSelected={channel.doesContainFile}
onClick={() => onAddFileToChannel(channel, channel.doesContainFile)}
title={channel.slatename}
style={{ maxWidth: "48ch" }}
>
{channel.slatename}
</ChannelButton>
</RovingTabIndex.Item>
</motion.div>
))}
<Show when={isCreatingChannel}>
<motion.div initial={{ opacity: 0.5, y: 4 }} animate={{ opacity: 1, y: 0 }}>
<RovingTabIndex.Item index={channels.length}>
<ChannelButton
css={Styles.HORIZONTAL_CONTAINER_CENTERED}
onClick={(e) => (e.stopPropagation(), onCreateChannel(searchQuery))}
title={searchQuery}
>
<SVG.Plus
width={16}
height={16}
style={{
position: "relative",
top: -1,
verticalAlign: "middle",
pointerEvents: "none",
display: "inline",
}}
/>
<span style={{ marginLeft: 4 }}>{searchQuery}</span>
</ChannelButton>
</RovingTabIndex.Item>
</motion.div>
</Show>
</RovingTabIndex.List>
</RovingTabIndex.Provider>
</AnimateSharedLayout>
</div>
) : null;
}
/* -----------------------------------------------------------------------------------------------*/
const useChannels = ({ viewer, file }) => {
const [channels, setChannels] = React.useState(viewer.slates);
const handleAddFileToChannel = async (slate, isSelected) => {
const prevSlates = [...channels];
const resetViewerSlates = () => setChannels(prevSlates);
if (isSelected) {
const newChannels = channels.map((item) => {
if (slate.id === item.id) {
return { ...item, objects: item.objects.filter((object) => object.id !== file.id) };
}
return item;
});
setChannels(newChannels);
const response = await UserBehaviors.removeFromSlate({ slate, ids: [file.id] });
if (!response) resetViewerSlates();
return;
}
const newChannels = channels.map((item) => {
if (slate.id === item.id) return { ...item, objects: [...item.objects, file] };
return item;
});
setChannels(newChannels);
const response = await UserBehaviors.saveCopy({ slate, files: [file], showAlerts: false });
if (!response) resetViewerSlates();
};
const handleCreateChannel = (isPublic) => async (name) => {
const generatedId = uuid();
setChannels([...channels, { id: generatedId, slatename: name, isPublic, objects: [file] }]);
const response = await Actions.createSlate({
name: name,
isPublic,
hydrateViewer: false,
});
if (Events.hasError(response)) {
setChannels(channels.filter((channel) => channel.id !== generatedId));
return;
}
// NOTE(amine): replace generated id with response
const prevChannels = channels.filter((channel) => channel.id !== generatedId);
setChannels([...prevChannels, { ...response.slate, objects: [file] }]);
const saveResponse = await UserBehaviors.saveCopy({
slate: response.slate,
files: [file],
showAlerts: false,
});
if (Events.hasError(saveResponse)) {
setChannels([prevChannels, ...response.slate]);
}
};
return [channels, { handleCreateChannel, handleAddFileToChannel }];
};
const useGetPrivateAndPublicChannels = ({ slates, file }) =>
React.useMemo(() => {
const privateChannels = [];
const publicChannels = [];
slates.forEach((slate) => {
const doesContainFile = slate.objects.some((item) => item.id === file.id);
if (slate.isPublic) {
publicChannels.push({ ...slate, doesContainFile });
return;
}
privateChannels.push({ ...slate, doesContainFile });
});
privateChannels.sort((a, b) => a.createdAt - b.createdAt);
publicChannels.sort((a, b) => a.createdAt - b.createdAt);
return { privateChannels, publicChannels };
}, [slates, file.id]);
const useChannelsSearch = ({ privateChannels, publicChannels }) => {
const [query, setQuery] = React.useState("");
const { results, channelAlreadyExists } = React.useMemo(() => {
let channelAlreadyExists = false;
const results = { privateChannels: [], publicChannels: [] };
const searchRegex = new RegExp(query, "gi");
results.privateChannels = privateChannels.filter((channel) => {
if (channel.slatename === query) channelAlreadyExists = true;
return searchRegex.test(channel.slatename);
});
results.publicChannels = publicChannels.filter((channel) => {
if (channel.slatename === query) channelAlreadyExists = true;
return searchRegex.test(channel.slatename);
});
return { results, channelAlreadyExists };
}, [query, privateChannels, publicChannels]);
const handleQueryChange = (e) => {
const nextValue = e.target.value;
//NOTE(amine): allow input's value to be empty but keep other validations
if (Strings.isEmpty(nextValue) || Validations.slatename(nextValue)) {
setQuery(Strings.createSlug(nextValue, ""));
}
};
const clearQuery = () => setQuery("");
return [
{ searchQuery: query, searchResults: results, channelAlreadyExists },
{ handleQueryChange, clearQuery },
];
};
export function EditChannels({ file, viewer, isOpen, onClose, ...props }) {
const [channels, { handleAddFileToChannel, handleCreateChannel }] = useChannels({
viewer,
file,
});
const { privateChannels, publicChannels } = useGetPrivateAndPublicChannels({
slates: channels,
file,
});
const [{ searchQuery, searchResults, channelAlreadyExists }, { handleQueryChange, clearQuery }] =
useChannelsSearch({
privateChannels: privateChannels,
publicChannels: publicChannels,
});
const isSearching = searchQuery.length > 0;
const showEmptyState = !isSearching && channels.length === 0;
return (
<Jumper.AnimatePresence>
{isOpen ? (
<Jumper.Root onClose={() => (onClose(), clearQuery())} {...props}>
<Jumper.Header style={{ paddingTop: 0, paddingBottom: 0 }}>
<ChannelInput
value={searchQuery}
onChange={handleQueryChange}
searchResults={searchResults}
autoFocus
onAddFileToChannel={handleAddFileToChannel}
/>
<Jumper.Dismiss />
</Jumper.Header>
<Jumper.Divider />
<Jumper.ObjectInfo file={file} />
<Jumper.Divider />
{showEmptyState ? (
<Jumper.Item style={{ flexGrow: 1 }} css={Styles.CONTAINER_CENTERED}>
<ChannelsEmpty />
</Jumper.Item>
) : (
<Jumper.Item style={{ overflowY: "auto", flex: "1 0 0" }}>
<Channels
header="Private"
isCreatingChannel={isSearching && !channelAlreadyExists}
channels={isSearching ? searchResults.privateChannels : privateChannels}
searchQuery={searchQuery}
onAddFileToChannel={handleAddFileToChannel}
onCreateChannel={(query) => (handleCreateChannel(false)(query), clearQuery())}
file={file}
viewer={viewer}
/>
<div style={{ marginTop: 20 }}>
<Channels
header="Public"
isPublic
searchQuery={searchQuery}
isCreatingChannel={isSearching && !channelAlreadyExists}
channels={isSearching ? searchResults.publicChannels : publicChannels}
onAddFileToChannel={handleAddFileToChannel}
onCreateChannel={(query) => (handleCreateChannel(true)(query), clearQuery())}
/>
</div>
</Jumper.Item>
)}
</Jumper.Root>
) : null}
</Jumper.AnimatePresence>
);
}
export function EditChannelsMobile({ file, viewer, isOpen, onClose }) {
const [channels, { handleAddFileToChannel, handleCreateChannel }] = useChannels({
viewer,
file,
});
const { privateChannels, publicChannels } = useGetPrivateAndPublicChannels({
slates: channels,
file,
});
const [{ searchQuery, searchResults, channelAlreadyExists }, { handleQueryChange, clearQuery }] =
useChannelsSearch({
privateChannels: privateChannels,
publicChannels: publicChannels,
});
const isSearching = searchQuery.length > 0;
return (
<MobileJumper.AnimatePresence>
{isOpen ? (
<MobileJumper.Root onClose={onClose}>
<System.Divider height={1} color="borderGrayLight" />
<MobileJumper.ObjectInfo file={file} />
<System.Divider height={1} color="borderGrayLight" />
<MobileJumper.Header style={{ paddingTop: 0, paddingBottom: 0 }}>
<ChannelInput
value={searchQuery}
onChange={handleQueryChange}
searchResults={searchResults}
onAddFileToChannel={handleAddFileToChannel}
autoFocus
/>
</MobileJumper.Header>
<System.Divider height={1} color="borderGrayLight" />
<MobileJumper.Content style={{ paddingBottom: 60 }}>
<Channels
header="Private"
isCreatingChannel={isSearching && !channelAlreadyExists}
channels={isSearching ? searchResults.privateChannels : privateChannels}
searchQuery={searchQuery}
onAddFileToChannel={handleAddFileToChannel}
onCreateChannel={(query) => (handleCreateChannel(false)(query), clearQuery())}
/>
<div style={{ marginTop: 20 }}>
<Channels
header="Public"
isPublic
searchQuery={searchQuery}
isCreatingChannel={isSearching && !channelAlreadyExists}
channels={isSearching ? searchResults.publicChannels : publicChannels}
onAddFileToChannel={handleAddFileToChannel}
onCreateChannel={(query) => (handleCreateChannel(true)(query), clearQuery())}
/>
</div>
</MobileJumper.Content>
<MobileJumper.Footer css={Styles.HORIZONTAL_CONTAINER_CENTERED}>
<System.ButtonPrimitive type="button" onClick={() => (onClose(), clearQuery())}>
<SVG.Hash width={16} height={16} style={{ color: Constants.system.blue }} />
</System.ButtonPrimitive>
</MobileJumper.Footer>
</MobileJumper.Root>
) : null}
</MobileJumper.AnimatePresence>
);
}

View File

@ -0,0 +1,757 @@
import * as React from "react";
import * as Styles from "~/common/styles";
import * as System from "~/components/system";
import * as Jumper from "~/components/system/components/fragments/Jumper";
import * as SVG from "~/common/svg";
import * as Actions from "~/common/actions";
import * as UserBehaviors from "~/common/user-behaviors";
import * as MobileJumper from "~/components/system/components/fragments/MobileJumper";
import * as Events from "~/common/custom-events";
import * as RovingTabIndex from "~/components/core/RovingTabIndex";
import * as Strings from "~/common/strings";
import * as Validations from "~/common/validations";
import { v4 as uuid } from "uuid";
import { mergeEvents } from "~/common/utilities";
import { css } from "@emotion/react";
import { useEventListener } from "~/common/hooks";
/* -------------------------------------------------------------------------------------------------
* Combobox
* used internally by EditSlates jumper
* -----------------------------------------------------------------------------------------------*/
const comboboxContext = React.createContext({});
const useComboboxContext = () => React.useContext(comboboxContext);
function ComboboxProvider({ children, isMobile = false, onItemSelect }) {
const menuItemsRef = React.useRef({});
const menuElementRef = React.useRef({});
const initialIndex = 0;
const [selectedIdx, setSelectedIdx] = React.useState(initialIndex);
const registerMenuRef = (node) => {
if (isMobile) return;
menuElementRef.current = node;
};
const registerMenuItem = ({ index, onSelect, ref }) => {
if (isMobile) return;
menuItemsRef.current[index] = { index, onSelect, ref };
};
const cleanupMenuItem = (index) => {
if (isMobile) return;
if (index === selectedIdx) setSelectedIdx(initialIndex);
delete menuItemsRef.current[index];
};
const isNavigatingViaKeyboard = React.useRef(true);
const moveSelectionOnArrowUp = () => {
isNavigatingViaKeyboard.current = true;
if (isMobile) return;
const prevIndex = selectedIdx - 1;
let prevFocusedIndex = null;
if (prevIndex >= initialIndex) {
prevFocusedIndex = prevIndex;
} else {
prevFocusedIndex = Math.max(...Object.keys(menuItemsRef.current));
}
setSelectedIdx(prevFocusedIndex);
};
const moveSelectionOnArrowDown = () => {
isNavigatingViaKeyboard.current = true;
if (isMobile) return;
const nextIndex = selectedIdx + 1;
const elementExists = menuItemsRef.current[nextIndex];
const nextFocusedIndex = elementExists ? nextIndex : initialIndex;
setSelectedIdx(nextFocusedIndex);
};
const moveSelectionOnHover = (index) => {
isNavigatingViaKeyboard.current = false;
const elementExists = menuItemsRef.current[index];
if (!elementExists) {
console.warn("Combobox: The element you're trying to select doesn't exist");
return;
}
setSelectedIdx(index);
};
const applySelectedElement = () => {
if (isMobile) return;
menuItemsRef.current[selectedIdx].onSelect(), onItemSelect?.();
};
React.useLayoutEffect(() => {
if (isMobile) return;
//NOTE(amine): don't scroll automatically when the user is navigating using a mouse
if (!isNavigatingViaKeyboard.current) return;
const menuNode = menuElementRef.current;
const selectedNode = menuItemsRef.current[selectedIdx]?.ref?.current;
if (!menuNode || !selectedNode) return;
const menuTop = menuNode.scrollTop;
const menuBottom = menuTop + menuNode.offsetHeight;
const selectedNodeTop = selectedNode.offsetTop;
const selectedNodeBottom = selectedNodeTop + selectedNode.offsetHeight;
if (selectedNodeTop <= menuTop) {
menuNode.scrollTo({ top: selectedNodeTop - selectedNode.offsetHeight });
}
if (selectedNodeBottom >= menuBottom) {
menuNode.scrollTo({
top: selectedNodeBottom - menuNode.offsetHeight + selectedNode.offsetHeight,
});
}
}, [selectedIdx, isMobile]);
const contextValue = React.useMemo(
() => [
{ selectedIdx, isMobile, isSelectedViaKeyboard: isNavigatingViaKeyboard.current },
{
onItemSelect,
registerMenuItem,
cleanupMenuItem,
moveSelectionOnArrowUp,
moveSelectionOnArrowDown,
moveSelectionOnHover,
applySelectedElement,
registerMenuRef,
},
],
[selectedIdx, isMobile]
);
return <comboboxContext.Provider value={contextValue}>{children}</comboboxContext.Provider>;
}
/* -----------------------------------------------------------------------------------------------*/
const ComboboxInput = React.forwardRef(({ onKeyDown, ...props }, ref) => {
const [, { moveSelectionOnArrowUp, moveSelectionOnArrowDown, applySelectedElement }] =
useComboboxContext();
const keyDownHandler = (e) => {
switch (e.key) {
case "ArrowUp":
e.preventDefault();
e.stopPropagation();
moveSelectionOnArrowUp();
break;
case "ArrowDown":
e.preventDefault();
e.stopPropagation();
moveSelectionOnArrowDown();
break;
case "ArrowLeft":
e.preventDefault();
break;
case "ArrowRight":
e.preventDefault();
break;
case "Enter":
e.preventDefault();
e.stopPropagation();
applySelectedElement();
break;
}
};
return <System.Input onKeyDown={mergeEvents(keyDownHandler, onKeyDown)} {...props} ref={ref} />;
});
/* -----------------------------------------------------------------------------------------------*/
function ComboboxMenuButton({ children, index, onSelect, onMouseDown, onClick, css, ...props }) {
const [
{ selectedIdx },
{ registerMenuItem, cleanupMenuItem, moveSelectionOnHover, onItemSelect },
] = useComboboxContext();
const handleMouseDown = (e) => e.preventDefault();
const handleClick = () => (onSelect?.(), onItemSelect?.());
const ref = React.useRef();
React.useEffect(() => {
registerMenuItem({ index, onSelect, ref });
return () => cleanupMenuItem(index);
}, [index, onSelect]);
const onMouseMoveHandler = () => {
if (selectedIdx !== index) moveSelectionOnHover(index);
};
useEventListener(
{ type: "mousemove", handler: onMouseMoveHandler, ref, options: { once: true } },
[selectedIdx]
);
return (
<li>
<button
ref={ref}
tabIndex={-1}
onMouseDown={mergeEvents(handleMouseDown, onMouseDown)}
onClick={mergeEvents(handleClick, onClick)}
css={[Styles.BUTTON_RESET, css]}
{...props}
>
{children}
</button>
</li>
);
}
/* -----------------------------------------------------------------------------------------------*/
const STYLES_COMBOBOX_MENU = css`
position: relative;
overflow-y: auto;
`;
function ComboboxMenu({ children, css, ...props }) {
const [, { registerMenuRef }] = useComboboxContext();
return (
<ul ref={registerMenuRef} css={[STYLES_COMBOBOX_MENU, css]} {...props}>
{children}
</ul>
);
}
/* -----------------------------------------------------------------------------------------------*/
const Combobox = {
Provider: ComboboxProvider,
Input: ComboboxInput,
Menu: ComboboxMenu,
MenuButton: ComboboxMenuButton,
};
const useCombobox = () => {
const [{ selectedIdx, isSelectedViaKeyboard, isMobile }] = useComboboxContext();
const checkIfIndexSelected = (index) => {
if (isMobile) return false;
return selectedIdx === index;
};
return { checkIfIndexSelected, isSelectedViaKeyboard };
};
/* -------------------------------------------------------------------------------------------------
* EditSlates Internals
* -----------------------------------------------------------------------------------------------*/
const STYLES_APPLIED_SLATE_BUTTON = (theme) => css`
${Styles.HORIZONTAL_CONTAINER_CENTERED};
height: 32px;
padding: 5px 12px 7px;
border-radius: 12px;
background-color: ${theme.semantic.bgWhite};
border: 1px solid ${theme.semantic.borderGrayLight};
box-shadow: ${theme.shadow.lightSmall};
`;
const STYLES_APPLIED_COLOR_TEXTBLACK = (theme) => css`
color: ${theme.semantic.textBlack};
`;
const STYLES_APPLIED_COLOR_TEXTGRAY = (theme) => css`
color: ${theme.semantic.textGray};
`;
const AppliedSlateButton = React.forwardRef(({ hasPublicIcon, children, ...props }, ref) => {
return (
<System.ButtonPrimitive css={STYLES_APPLIED_SLATE_BUTTON} ref={ref} {...props}>
{hasPublicIcon && (
<SVG.Users
width={16}
height={16}
css={STYLES_APPLIED_COLOR_TEXTBLACK}
style={{ marginRight: 4 }}
/>
)}
<System.H5
as="span"
style={{ maxWidth: "35ch" }}
nbrOflines={1}
title={children}
color="textBlack"
>
{children}
</System.H5>
<SVG.Dismiss width={16} style={{ marginLeft: 4 }} css={STYLES_APPLIED_COLOR_TEXTGRAY} />
</System.ButtonPrimitive>
);
});
const STYLES_SLATES_INPUT = (theme) => css`
background-color: transparent;
${theme.semantic.textGray};
box-shadow: none;
height: 24px;
padding: 0px;
border-radius: 0px;
::placeholder {
color: ${theme.semantic.textGray};
}
`;
const STYLES_SLATES_INPUT_WRAPPER = css`
display: flex;
flex-wrap: wrap;
margin: calc(-8px + 6px) 0 0 -8px;
width: calc(100% + 8px);
height: 52px;
max-height: 96px;
overflow-y: auto;
padding-bottom: 12px;
& > * {
margin: 8px 0 0 8px !important;
}
`;
const STYLES_SEARCH_SLATES_COLOR = (theme) => css`
color: ${theme.semantic.textGrayDark};
`;
function ComboboxSlatesInput({ appliedSlates, removeFileFromSlate, ...props }) {
return (
<div css={[Styles.HORIZONTAL_CONTAINER, STYLES_SEARCH_SLATES_COLOR]} style={{ width: "100%" }}>
<SVG.Hash width={16} style={{ marginTop: 20, marginBottom: 16 }} />
<RovingTabIndex.Provider>
<RovingTabIndex.List
css={STYLES_SLATES_INPUT_WRAPPER}
style={{ marginLeft: 6, marginTop: 6, paddingRight: 20 }}
>
{appliedSlates.map((slate, idx) => (
<RovingTabIndex.Item key={slate.id} index={idx}>
<AppliedSlateButton
hasPublicIcon={slate.isPublic}
onClick={() => removeFileFromSlate(slate)}
>
{slate.slatename}
</AppliedSlateButton>
</RovingTabIndex.Item>
))}
<RovingTabIndex.Item index={appliedSlates.length}>
<Combobox.Input
name="search"
placeholder="Search or create a new tag"
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
inputCss={STYLES_SLATES_INPUT}
containerStyle={{ flexGrow: 1, paddingTop: 3, height: 32 }}
{...props}
/>
</RovingTabIndex.Item>
</RovingTabIndex.List>
</RovingTabIndex.Provider>
</div>
);
}
/* -----------------------------------------------------------------------------------------------*/
const STYLES_SLATES_MENU_WRAPPER = (theme) => css`
max-height: 275px;
padding: 12px;
@media (max-width: ${theme.sizes.mobile}px) {
padding: 8px;
max-height: none;
}
`;
const STYLES_RETURN_KEY = (theme) => css`
padding: 0px 2px;
border-radius: 6px;
background-color: ${theme.semantic.bgBlurLightOP};
`;
const STYLES_SLATES_MENU_BUTTON_SELECTED = (theme) => css`
background-color: ${theme.semantic.bgGrayLight4};
`;
const STYLES_SLATES_MENU_BUTTON = css`
${Styles.HORIZONTAL_CONTAINER_CENTERED};
justify-content: space-between;
position: relative;
padding: 9px 8px 11px;
width: 100%;
overflow: hidden;
border-radius: 8px;
:hover {
color: inherit;
}
`;
const STYLES_CHECKBOX_CIRCLE = (theme) => css`
padding: 4px;
border-radius: 50%;
background-color: ${theme.system.green};
color: ${theme.semantic.textWhite};
`;
const ComboboxSlatesMenuButton = ({
hasPublicIcon,
isCreateAction,
isSlateApplied,
children,
index,
...props
}) => {
const { checkIfIndexSelected, isSelectedViaKeyboard } = useCombobox();
const isSelected = checkIfIndexSelected(index);
return (
<Combobox.MenuButton
css={[
STYLES_SLATES_MENU_BUTTON,
isSelected && STYLES_SLATES_MENU_BUTTON_SELECTED,
(theme) => css({ color: isCreateAction ? theme.system.blue : theme.system.textBlack }),
]}
index={index}
{...props}
>
<div>
{hasPublicIcon && (
<div style={{ position: "absolute", left: "8px", top: "9x" }}>
<SVG.Users width={20} height={20} />
</div>
)}
<System.H5
as="span"
nbrOflines={isCreateAction ? 2 : 1}
title={children}
style={{ marginLeft: 32, maxWidth: "50ch" }}
>
{children}
</System.H5>
</div>
{!isCreateAction && isSelected && isSelectedViaKeyboard && (
<div css={Styles.HORIZONTAL_CONTAINER_CENTERED} style={{ marginLeft: "auto" }}>
<System.P3 color="textGrayDark">{isSlateApplied ? "remove tag" : "apply tag"}</System.P3>
<System.P3 css={STYLES_RETURN_KEY} color="textGray" style={{ marginLeft: 4 }}>
</System.P3>
</div>
)}
{isSlateApplied && (
<div css={STYLES_CHECKBOX_CIRCLE} style={{ marginLeft: 12 }}>
<SVG.Check width={12} height={12} />
</div>
)}
</Combobox.MenuButton>
);
};
function ComboboxSlatesMenu({
filterValue,
filteredSlates,
createSlate,
addFileToSlate,
removeFileFromSlate,
}) {
const { canCreateSlate, suggestions } = React.useMemo(() => {
let canCreateSlate = true;
const filterRegex = new RegExp(filterValue, "gi");
const filterAndSortSlates = (slates) =>
slates
.filter((slate) => {
if (slate.slatename === filterValue) canCreateSlate = false;
return filterRegex.test(slate.slatename);
})
.sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt));
return {
suggestions: {
applied: filterAndSortSlates(filteredSlates.applied),
unapplied: filterAndSortSlates(filteredSlates.unapplied),
},
canCreateSlate,
};
}, [filterValue, filteredSlates]);
const isFilteredView = !!filterValue;
const createPublicState = React.useCallback(() => {
createSlate({ name: filterValue, isPublic: true });
}, [filterValue]);
const createPrivateState = React.useCallback(() => {
createSlate({ name: filterValue, isPublic: false });
}, [filterValue]);
if (isFilteredView) {
return (
<Combobox.Menu css={STYLES_SLATES_MENU_WRAPPER}>
{suggestions.unapplied.map((slate, i) => (
<ComboboxSlatesMenuButton
key={slate.id}
hasPublicIcon={slate.isPublic}
css={STYLES_SLATES_MENU_BUTTON}
index={i}
onSelect={() => addFileToSlate(slate)}
>
{slate.slatename}
</ComboboxSlatesMenuButton>
))}
{canCreateSlate ? (
<>
<ComboboxSlatesMenuButton
isCreateAction
index={suggestions.unapplied.length}
onSelect={createPrivateState}
>
create new private tag {filterValue}
</ComboboxSlatesMenuButton>
<ComboboxSlatesMenuButton
isCreateAction
hasPublicIcon
index={suggestions.unapplied.length + 1}
onSelect={createPublicState}
>
create new shared tag {filterValue} (shows on your profile)
</ComboboxSlatesMenuButton>
</>
) : null}
{suggestions.applied.map((slate, i) => (
<ComboboxSlatesMenuButton
isSlateApplied
hasPublicIcon={slate.isPublic}
index={
canCreateSlate
? suggestions.unapplied.length + i + 2
: suggestions.unapplied.length + i
}
key={slate.id}
onSelect={() => removeFileFromSlate(slate)}
>
{slate.slatename}
</ComboboxSlatesMenuButton>
))}
</Combobox.Menu>
);
}
return (
<Combobox.Menu css={STYLES_SLATES_MENU_WRAPPER}>
{suggestions.unapplied.map((slate, i) => (
<ComboboxSlatesMenuButton
hasPublicIcon={slate.isPublic}
key={slate.id}
index={i}
onSelect={() => addFileToSlate(slate)}
>
{slate.slatename}
</ComboboxSlatesMenuButton>
))}
{suggestions.applied.map((slate, i) => (
<ComboboxSlatesMenuButton
isSlateApplied
hasPublicIcon={slate.isPublic}
key={slate.id}
index={suggestions.unapplied.length + i}
onSelect={() => removeFileFromSlate(slate)}
>
{slate.slatename}
</ComboboxSlatesMenuButton>
))}
</Combobox.Menu>
);
}
/* -------------------------------------------------------------------------------------------------
* EditSlates Jumper
* -----------------------------------------------------------------------------------------------*/
const useSlates = ({ viewer, object }) => {
const [slates, setSlates] = React.useState(viewer.slates);
const filteredSlates = React.useMemo(() => {
let applied = [];
let unapplied = [];
slates.forEach((slate) => {
if (slate.objects.some((item) => item.id === object.id)) {
applied.push(slate);
} else {
unapplied.push(slate);
}
});
return { applied, unapplied };
}, [slates, object]);
const createSlate = async ({ name, isPublic }) => {
const generatedId = uuid();
setSlates([
...slates,
{
id: generatedId,
slatename: name,
isPublic,
objects: [object],
updatedAt: new Date().toString(),
},
]);
const response = await Actions.createSlate({
name: name,
isPublic,
hydrateViewer: false,
});
if (Events.hasError(response)) {
setSlates(slates.filter((slate) => slate.id !== generatedId));
return;
}
// NOTE(amine): replace generated id with response
const prevSlates = slates.filter((slate) => slate.id !== generatedId);
setSlates([...prevSlates, { ...response.slate, objects: [object] }]);
const saveResponse = await UserBehaviors.saveCopy({
slate: response.slate,
files: [object],
showAlerts: false,
});
if (Events.hasError(saveResponse)) {
setSlates([prevSlates, ...response.slate]);
}
};
const addFileToSlate = async (slate) => {
const prevSlates = [...slates];
const resetViewerSlates = () => setSlates(prevSlates);
const nextSlates = slates.map((item) => {
if (slate.id === item.id) return { ...item, objects: [...item.objects, object] };
return item;
});
setSlates(nextSlates);
const response = await UserBehaviors.saveCopy({ slate, files: [object], showAlerts: false });
if (!response) resetViewerSlates();
};
const removeFileFromSlate = async (slate) => {
const prevSlates = [...slates];
const resetViewerSlates = () => setSlates(prevSlates);
const nextSlates = slates.map((item) => {
if (slate.id === item.id) {
return { ...item, objects: item.objects.filter((object) => object.id !== object.id) };
}
return item;
});
setSlates(nextSlates);
const response = await UserBehaviors.removeFromSlate({ slate, ids: [object.id] });
if (!response) resetViewerSlates();
};
return {
slates,
filteredSlates,
createSlate,
addFileToSlate,
removeFileFromSlate,
};
};
const useInput = () => {
const [value, setValue] = React.useState("");
const handleInputChange = (e) => {
const nextValue = e.target.value;
//NOTE(amine): allow input's value to be empty but keep other validations
if (Strings.isEmpty(nextValue) || Validations.slatename(nextValue)) {
setValue(Strings.createSlug(nextValue, ""));
}
};
const clearInputValue = () => setValue("");
return [value, { handleInputChange, clearInputValue }];
};
/* -----------------------------------------------------------------------------------------------*/
export function EditSlates({ file, viewer, onClose, ...props }) {
const { filteredSlates, createSlate, addFileToSlate, removeFileFromSlate } = useSlates({
viewer,
object: file,
});
const [value, { handleInputChange, clearInputValue }] = useInput();
return (
<Jumper.Root onClose={onClose} {...props}>
<Combobox.Provider onItemSelect={clearInputValue}>
<Jumper.Header style={{ paddingTop: 0, paddingBottom: 0, paddingRight: 0 }}>
<ComboboxSlatesInput
value={value}
appliedSlates={filteredSlates.applied}
removeFileFromSlate={removeFileFromSlate}
onChange={handleInputChange}
/>
</Jumper.Header>
<Jumper.Divider />
<Jumper.ObjectInfo file={file} />
<Jumper.Divider />
<Jumper.Item style={{ padding: 0 }}>
<ComboboxSlatesMenu
filterValue={value}
filteredSlates={filteredSlates}
createSlate={createSlate}
addFileToSlate={addFileToSlate}
removeFileFromSlate={removeFileFromSlate}
/>
</Jumper.Item>
</Combobox.Provider>
</Jumper.Root>
);
}
/* -----------------------------------------------------------------------------------------------*/
export function EditSlatesMobile({ file, viewer, onClose }) {
const { filteredSlates, createSlate, addFileToSlate, removeFileFromSlate } = useSlates({
viewer,
object: file,
});
const [value, { handleInputChange, clearInputValue }] = useInput();
return (
<MobileJumper.Root onClose={onClose}>
<Combobox.Provider onItemSelect={clearInputValue} isMobile>
<System.Divider height={1} color="borderGrayLight" />
<MobileJumper.ObjectInfo file={file} />
<System.Divider height={1} color="borderGrayLight" />
<MobileJumper.Header style={{ paddingTop: 0, paddingBottom: 0, paddingRight: 0 }}>
<ComboboxSlatesInput
value={value}
appliedSlates={filteredSlates.applied}
removeFileFromSlate={removeFileFromSlate}
onChange={handleInputChange}
/>
</MobileJumper.Header>
<System.Divider height={1} color="borderGrayLight" />
<MobileJumper.Content style={{ padding: 0, paddingBottom: 60 }}>
<ComboboxSlatesMenu
filterValue={value}
filteredSlates={filteredSlates}
createSlate={createSlate}
addFileToSlate={addFileToSlate}
removeFileFromSlate={removeFileFromSlate}
/>
</MobileJumper.Content>
</Combobox.Provider>
</MobileJumper.Root>
);
}

View File

@ -13,9 +13,9 @@ import {
import { Share, ShareMobile } from "~/components/system/components/GlobalCarousel/jumpers/Share";
import {
EditChannels,
EditChannelsMobile,
} from "~/components/system/components/GlobalCarousel/jumpers/EditChannels";
EditSlates,
EditSlatesMobile,
} from "~/components/system/components/GlobalCarousel/jumpers/EditSlates";
export {
//NOTE(amine): FileDescription jumper
@ -29,7 +29,7 @@ export {
//NOTE(amine): Share jumper
Share,
ShareMobile,
//NOTE(amine): EditChannels jumper
EditChannels,
EditChannelsMobile,
//NOTE(amine): EditSlates jumper
EditSlates,
EditSlatesMobile,
};