feat: raw slider for deals on mobile

This commit is contained in:
2025-08-10 19:29:02 +04:00
parent 54cf883a3c
commit 7815f99fa4
12 changed files with 271 additions and 227 deletions

View File

@ -34,8 +34,10 @@
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-imask": "^7.6.1", "react-imask": "^7.6.1",
"react-redux": "^9.2.0", "react-redux": "^9.2.0",
"react-slick": "^0.31.0",
"redux-persist": "^6.0.0", "redux-persist": "^6.0.0",
"sharp": "^0.34.3", "sharp": "^0.34.3",
"slick-carousel": "^1.8.1",
"zod": "^4.0.14" "zod": "^4.0.14"
}, },
"devDependencies": { "devDependencies": {
@ -57,7 +59,9 @@
"@types/node": "^22.13.11", "@types/node": "^22.13.11",
"@types/react": "19.1.8", "@types/react": "19.1.8",
"@types/react-redux": "^7.1.34", "@types/react-redux": "^7.1.34",
"@types/react-slick": "^0",
"@types/redux-persist": "^4.3.1", "@types/redux-persist": "^4.3.1",
"@types/slick-carousel": "^1",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"babel-loader": "^10.0.0", "babel-loader": "^10.0.0",
"eslint": "^9.29.0", "eslint": "^9.29.0",

View File

@ -6,16 +6,14 @@ import { DealSchema } from "@/lib/client";
type Props = { type Props = {
deal: DealSchema; deal: DealSchema;
disabled?: boolean;
}; };
const DealContainer: FC<Props> = ({ deal, disabled = false }) => { const DealContainer: FC<Props> = ({ deal }) => {
const dealBody = useMemo(() => <DealCard deal={deal} />, [deal]); const dealBody = useMemo(() => <DealCard deal={deal} />, [deal]);
return ( return (
<Box> <Box>
<SortableItem <SortableItem
disabled={disabled}
dragHandleStyle={{ cursor: "pointer" }} dragHandleStyle={{ cursor: "pointer" }}
id={deal.id} id={deal.id}
renderItem={() => dealBody} renderItem={() => dealBody}

View File

@ -25,23 +25,15 @@ const Funnel: FC = () => {
activeDeal, activeDeal,
} = useDealsAndStatusesDnd(); } = useDealsAndStatusesDnd();
return ( const renderFunnelDnd = () => (
<ScrollArea <>
offsetScrollbars={"x"}
scrollbarSize={"0.5rem"}>
<Group
align={"start"}
wrap={"nowrap"}
gap={"sm"}>
<FunnelDnd <FunnelDnd
containers={sortedStatuses} containers={sortedStatuses}
items={deals} items={deals}
onDragStart={handleDragStart} onDragStart={handleDragStart}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
getContainerId={(status: StatusSchema) => getContainerId={(status: StatusSchema) => `${status.id}-status`}
`${status.id}-status`
}
getItemsByContainer={( getItemsByContainer={(
status: StatusSchema, status: StatusSchema,
items: DealSchema[] items: DealSchema[]
@ -64,7 +56,6 @@ const Funnel: FC = () => {
<DealContainer <DealContainer
key={deal.id} key={deal.id}
deal={deal} deal={deal}
disabled={isMobile}
/> />
)} )}
activeContainer={activeStatus} activeContainer={activeStatus}
@ -72,19 +63,30 @@ const Funnel: FC = () => {
renderItemOverlay={(deal: DealSchema) => ( renderItemOverlay={(deal: DealSchema) => (
<DealCard deal={deal} /> <DealCard deal={deal} />
)} )}
renderContainerOverlay={( renderContainerOverlay={(status: StatusSchema, children) => (
status: StatusSchema,
children
) => (
<StatusColumnWrapper <StatusColumnWrapper
status={status} status={status}
isDragging> isDragging>
{children} {children}
</StatusColumnWrapper> </StatusColumnWrapper>
)} )}
disabled={isMobile} disabledColumns={isMobile}
/> />
<CreateStatusButton /> {!isMobile && <CreateStatusButton />}
</>
);
if (isMobile) return renderFunnelDnd();
return (
<ScrollArea
offsetScrollbars={"x"}
scrollbarSize={"0.5rem"}>
<Group
align={"start"}
wrap={"nowrap"}
gap={"sm"}>
{renderFunnelDnd()}
</Group> </Group>
</ScrollArea> </ScrollArea>
); );

View File

@ -1,5 +1,7 @@
import "@mantine/core/styles.css"; import "@mantine/core/styles.css";
import "@mantine/notifications/styles.css"; import "@mantine/notifications/styles.css";
import "slick-carousel/slick/slick.css";
import "slick-carousel/slick/slick-theme.css";
import { ReactNode } from "react"; import { ReactNode } from "react";
import { import {
ColorSchemeScript, ColorSchemeScript,

View File

@ -1,30 +0,0 @@
import React, { FC, ReactNode } from "react";
import { useDraggable } from "@dnd-kit/core";
type Props = {
children: ReactNode;
};
const Draggable: FC<Props> = props => {
const { attributes, listeners, setNodeRef, transform } = useDraggable({
id: "draggable",
});
const style = transform
? {
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
}
: undefined;
return (
<div
ref={setNodeRef}
style={style}
{...listeners}
{...attributes}>
{props.children}
</div>
);
};
export default Draggable;

View File

@ -1,25 +0,0 @@
import React, { FC, ReactNode } from "react";
import { useDroppable } from "@dnd-kit/core";
type Props = {
children: ReactNode;
}
const Droppable: FC<Props> = ({ children }) => {
const { isOver, setNodeRef } = useDroppable({
id: "droppable",
});
const style = {
color: isOver ? "green" : undefined,
};
return (
<div
ref={setNodeRef}
style={style}>
{children}
</div>
);
}
export default Droppable;

View File

@ -12,7 +12,6 @@ type Props<TItem> = {
items: TItem[]; items: TItem[];
renderItem: (item: TItem) => ReactNode; renderItem: (item: TItem) => ReactNode;
children?: ReactNode; children?: ReactNode;
disabled?: boolean;
}; };
const FunnelColumn = <TItem extends BaseDraggable>({ const FunnelColumn = <TItem extends BaseDraggable>({
@ -20,7 +19,6 @@ const FunnelColumn = <TItem extends BaseDraggable>({
items, items,
renderItem, renderItem,
children, children,
disabled = false,
}: Props<TItem>) => { }: Props<TItem>) => {
const { setNodeRef } = useDroppable({ id }); const { setNodeRef } = useDroppable({ id });
@ -28,7 +26,6 @@ const FunnelColumn = <TItem extends BaseDraggable>({
<> <>
{children} {children}
<SortableContext <SortableContext
disabled={disabled}
id={id} id={id}
items={items} items={items}
strategy={verticalListSortingStrategy}> strategy={verticalListSortingStrategy}>

View File

@ -12,12 +12,15 @@ import {
horizontalListSortingStrategy, horizontalListSortingStrategy,
SortableContext, SortableContext,
} from "@dnd-kit/sortable"; } from "@dnd-kit/sortable";
import { isMobile } from "react-device-detect";
import Slider from "react-slick";
import { Group } from "@mantine/core"; import { Group } from "@mantine/core";
import CreateStatusButton from "@/app/deals/components/CreateStatusButton/CreateStatusButton";
import useDndSensors from "@/app/deals/hooks/useSensors"; import useDndSensors from "@/app/deals/hooks/useSensors";
import SortableItem from "@/components/dnd/SortableItem"; import FunnelColumn from "@/components/dnd/FunnelDnd/FunnelColumn";
import FunnelOverlay from "@/components/dnd/FunnelDnd/FunnelOverlay";
import { BaseDraggable } from "@/components/dnd/types/types"; import { BaseDraggable } from "@/components/dnd/types/types";
import FunnelColumn from "./FunnelColumn"; import SortableItem from "../SortableItem";
import FunnelOverlay from "./FunnelOverlay";
type Props<TContainer, TItem> = { type Props<TContainer, TItem> = {
containers: TContainer[]; containers: TContainer[];
@ -36,7 +39,7 @@ type Props<TContainer, TItem> = {
getItemsByContainer: (container: TContainer, items: TItem[]) => TItem[]; getItemsByContainer: (container: TContainer, items: TItem[]) => TItem[];
activeContainer: TContainer | null; activeContainer: TContainer | null;
activeItem: TItem | null; activeItem: TItem | null;
disabled?: boolean; disabledColumns?: boolean;
}; };
const FunnelDnd = < const FunnelDnd = <
@ -56,35 +59,30 @@ const FunnelDnd = <
getItemsByContainer, getItemsByContainer,
activeContainer, activeContainer,
activeItem, activeItem,
disabled = false, disabledColumns = false,
}: Props<TContainer, TItem>) => { }: Props<TContainer, TItem>) => {
const sensors = useDndSensors(); const sensors = useDndSensors();
return ( const settings = {
<DndContext dots: true,
sensors={sensors} infinite: false,
collisionDetection={closestCorners} speed: 500,
onDragStart={onDragStart} slidesToShow: 1,
onDragOver={onDragOver} slidesToScroll: 1,
onDragEnd={onDragEnd}> draggable: !activeItem && !activeContainer,
<SortableContext swipe: !activeItem && !activeContainer,
items={containers.map(getContainerId)} arrows: false,
strategy={horizontalListSortingStrategy}> };
<Group
gap="xs" const renderContainers = () =>
wrap="nowrap" containers.map(container => {
align="start"> const containerItems = getItemsByContainer(container, items);
{containers.map(container => {
const containerItems = getItemsByContainer(
container,
items
);
const containerId = getContainerId(container); const containerId = getContainerId(container);
return ( return (
<SortableItem <SortableItem
key={containerId} key={containerId}
id={containerId} id={containerId}
disabled={disabled} disabled={disabledColumns}
renderItem={() => renderItem={() =>
renderContainer( renderContainer(
container, container,
@ -97,7 +95,18 @@ const FunnelDnd = <
} }
/> />
); );
})} });
const renderBody = () => (
<>
{isMobile ? (
<Slider {...settings}>
{renderContainers()}
<CreateStatusButton />
</Slider>
) : (
renderContainers()
)}
<FunnelOverlay <FunnelOverlay
activeContainer={activeContainer} activeContainer={activeContainer}
activeItem={activeItem} activeItem={activeItem}
@ -113,13 +122,34 @@ const FunnelDnd = <
id={containerId} id={containerId}
items={containerItems} items={containerItems}
renderItem={renderItem} renderItem={renderItem}
disabled={disabled}
/> />
); );
}} }}
renderItem={renderItemOverlay} renderItem={renderItemOverlay}
/> />
</>
);
return (
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={onDragStart}
onDragOver={onDragOver}
onDragEnd={onDragEnd}>
<SortableContext
items={containers.map(getContainerId)}
strategy={horizontalListSortingStrategy}>
{isMobile ? (
renderBody()
) : (
<Group
gap="xs"
wrap="nowrap"
align="start">
{renderBody()}
</Group> </Group>
)}
</SortableContext> </SortableContext>
</DndContext> </DndContext>
); );

View File

@ -1,24 +1,32 @@
import React, { CSSProperties, ReactNode, useContext } from "react"; import React, { CSSProperties, ReactNode } from "react";
import SortableItemContext from "@/components/dnd/SortableItem/SortableItemContext"; import { useDraggable } from "@dnd-kit/core";
type Props = { type Props = {
id: number | string;
children: ReactNode; children: ReactNode;
style?: CSSProperties; style?: CSSProperties;
disabled?: boolean;
}; };
const DragHandle = ({ children, style }: Props) => { const DragHandle = ({ id, children, style, disabled }: Props) => {
const { attributes, listeners, ref } = useContext(SortableItemContext); const { attributes, listeners, setNodeRef } = useDraggable({
id,
disabled,
});
return ( return (
<div <div
{...attributes} {...attributes}
{...listeners} {...listeners}
onTouchStart={e => !disabled && e.stopPropagation()}
onTouchMove={e => !disabled && e.stopPropagation()}
style={{ style={{
width: "100%", width: "100wv",
cursor: "grab", cursor: disabled ? "default" : "grab",
touchAction: "none",
...style, ...style,
}} }}
ref={ref}> ref={setNodeRef}>
{children} {children}
</div> </div>
); );

View File

@ -1,8 +1,7 @@
import React, { CSSProperties, ReactNode, useMemo } from "react"; import React, { CSSProperties, ReactNode } from "react";
import { useSortable } from "@dnd-kit/sortable"; import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
import DragHandle from "./DragHandle"; import DragHandle from "./DragHandle";
import SortableItemContext from "./SortableItemContext";
type Props = { type Props = {
id: number | string; id: number | string;
@ -17,26 +16,12 @@ const SortableItem = ({
dragHandleStyle, dragHandleStyle,
renderDraggable, renderDraggable,
id, id,
disabled, disabled = false,
}: Props) => { }: Props) => {
const { const { isDragging, setNodeRef, transform, transition } = useSortable({
attributes, id,
isDragging, animateLayoutChanges: () => false,
listeners, });
setNodeRef,
setActivatorNodeRef,
transform,
transition,
} = useSortable({ id, disabled, animateLayoutChanges: () => false });
const context = useMemo(
() => ({
attributes,
listeners,
ref: setActivatorNodeRef,
}),
[attributes, listeners, setActivatorNodeRef]
);
const style: CSSProperties = { const style: CSSProperties = {
opacity: isDragging ? 0.4 : undefined, opacity: isDragging ? 0.4 : undefined,
@ -45,25 +30,29 @@ const SortableItem = ({
}; };
const renderDragHandle = () => ( const renderDragHandle = () => (
<DragHandle style={dragHandleStyle}> <DragHandle
id={id}
style={dragHandleStyle}
disabled={disabled}>
{renderDraggable && renderDraggable()} {renderDraggable && renderDraggable()}
</DragHandle> </DragHandle>
); );
return ( return (
<SortableItemContext.Provider value={context}>
<div <div
ref={setNodeRef} ref={setNodeRef}
style={style}> style={style}>
{renderDraggable ? ( {renderDraggable ? (
renderItem(renderDragHandle) renderItem(renderDragHandle)
) : ( ) : (
<DragHandle style={dragHandleStyle}> <DragHandle
id={id}
style={dragHandleStyle}
disabled={disabled}>
{renderItem()} {renderItem()}
</DragHandle> </DragHandle>
)} )}
</div> </div>
</SortableItemContext.Provider>
); );
}; };

View File

@ -1,16 +0,0 @@
import type { DraggableSyntheticListeners } from "@dnd-kit/core";
import { createContext } from "react";
interface Context {
attributes: Record<string, any>;
listeners: DraggableSyntheticListeners;
ref: (node: HTMLElement | null) => void;
}
const SortableItemContext = createContext<Context>({
attributes: {},
listeners: undefined,
ref() {},
});
export default SortableItemContext;

View File

@ -3944,6 +3944,15 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@types/jquery@npm:*":
version: 3.5.32
resolution: "@types/jquery@npm:3.5.32"
dependencies:
"@types/sizzle": "npm:*"
checksum: 10c0/4a17ad6819b89026c21323656ab01b0b263f9d470910a87c8740920ff98319d503c7352b85b50134a39724ecbfccabc73aa4c741dfdd460cf8bbe714f9259054
languageName: node
linkType: hard
"@types/jsdom@npm:^21.1.7": "@types/jsdom@npm:^21.1.7":
version: 21.1.7 version: 21.1.7
resolution: "@types/jsdom@npm:21.1.7" resolution: "@types/jsdom@npm:21.1.7"
@ -4006,6 +4015,15 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@types/react-slick@npm:^0":
version: 0.23.13
resolution: "@types/react-slick@npm:0.23.13"
dependencies:
"@types/react": "npm:*"
checksum: 10c0/35dd72dfbac234b8db019fb8af250ac3891e564d13001642353aa0b7c37f6ed1ff1f5e8fa8b034706993c4d27ac1977c031942636cfd05414ee4667d45c8aea6
languageName: node
linkType: hard
"@types/react@npm:*, @types/react@npm:19.1.8": "@types/react@npm:*, @types/react@npm:19.1.8":
version: 19.1.8 version: 19.1.8
resolution: "@types/react@npm:19.1.8" resolution: "@types/react@npm:19.1.8"
@ -4038,6 +4056,22 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@types/sizzle@npm:*":
version: 2.3.9
resolution: "@types/sizzle@npm:2.3.9"
checksum: 10c0/db0277ff62e8ebe6cdae2020fd045fd7fd19f29a3a2ce13c555b14fb00e105e79004883732118b9f2e8b943cb302645e9eddb4e7bdeef1a171da679cd4c32b72
languageName: node
linkType: hard
"@types/slick-carousel@npm:^1":
version: 1.6.40
resolution: "@types/slick-carousel@npm:1.6.40"
dependencies:
"@types/jquery": "npm:*"
checksum: 10c0/3e7e9ad518ec19e5afd3b34b8546fe5f190262d8a29640c7aad19ddd1aad1d6a71dcc11a9528bafb9034e9d4031e2b3cc186c1baf5ed95d13a541e4231463d51
languageName: node
linkType: hard
"@types/stack-utils@npm:^2.0.0, @types/stack-utils@npm:^2.0.3": "@types/stack-utils@npm:^2.0.0, @types/stack-utils@npm:^2.0.3":
version: 2.0.3 version: 2.0.3
resolution: "@types/stack-utils@npm:2.0.3" resolution: "@types/stack-utils@npm:2.0.3"
@ -5731,7 +5765,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"classnames@npm:^2.5.1": "classnames@npm:^2.2.5, classnames@npm:^2.5.1":
version: 2.5.1 version: 2.5.1
resolution: "classnames@npm:2.5.1" resolution: "classnames@npm:2.5.1"
checksum: 10c0/afff4f77e62cea2d79c39962980bf316bacb0d7c49e13a21adaadb9221e1c6b9d3cdb829d8bb1b23c406f4e740507f37e1dcf506f7e3b7113d17c5bab787aa69 checksum: 10c0/afff4f77e62cea2d79c39962980bf316bacb0d7c49e13a21adaadb9221e1c6b9d3cdb829d8bb1b23c406f4e740507f37e1dcf506f7e3b7113d17c5bab787aa69
@ -6081,7 +6115,9 @@ __metadata:
"@types/node": "npm:^22.13.11" "@types/node": "npm:^22.13.11"
"@types/react": "npm:19.1.8" "@types/react": "npm:19.1.8"
"@types/react-redux": "npm:^7.1.34" "@types/react-redux": "npm:^7.1.34"
"@types/react-slick": "npm:^0"
"@types/redux-persist": "npm:^4.3.1" "@types/redux-persist": "npm:^4.3.1"
"@types/slick-carousel": "npm:^1"
autoprefixer: "npm:^10.4.21" autoprefixer: "npm:^10.4.21"
axios: "npm:^1.11.0" axios: "npm:^1.11.0"
babel-loader: "npm:^10.0.0" babel-loader: "npm:^10.0.0"
@ -6107,8 +6143,10 @@ __metadata:
react-dom: "npm:19.1.0" react-dom: "npm:19.1.0"
react-imask: "npm:^7.6.1" react-imask: "npm:^7.6.1"
react-redux: "npm:^9.2.0" react-redux: "npm:^9.2.0"
react-slick: "npm:^0.31.0"
redux-persist: "npm:^6.0.0" redux-persist: "npm:^6.0.0"
sharp: "npm:^0.34.3" sharp: "npm:^0.34.3"
slick-carousel: "npm:^1.8.1"
storybook: "npm:^8.6.8" storybook: "npm:^8.6.8"
storybook-dark-mode: "npm:^4.0.2" storybook-dark-mode: "npm:^4.0.2"
stylelint: "npm:^16.20.0" stylelint: "npm:^16.20.0"
@ -9592,6 +9630,15 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"json2mq@npm:^0.2.0":
version: 0.2.0
resolution: "json2mq@npm:0.2.0"
dependencies:
string-convert: "npm:^0.2.0"
checksum: 10c0/fc9e2f2306572522d3e61d246afdf70b56ca9ea32f4ad5924c30949867851ab59c926bd0ffc821ebb54d32f3e82e95225f3906eacdb3e54c1ad49acdadf7e0c7
languageName: node
linkType: hard
"json5@npm:^2.1.2, json5@npm:^2.2.2, json5@npm:^2.2.3": "json5@npm:^2.1.2, json5@npm:^2.2.2, json5@npm:^2.2.3":
version: 2.2.3 version: 2.2.3
resolution: "json5@npm:2.2.3" resolution: "json5@npm:2.2.3"
@ -11698,6 +11745,21 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"react-slick@npm:^0.31.0":
version: 0.31.0
resolution: "react-slick@npm:0.31.0"
dependencies:
classnames: "npm:^2.2.5"
json2mq: "npm:^0.2.0"
lodash.debounce: "npm:^4.0.8"
resize-observer-polyfill: "npm:^1.5.0"
peerDependencies:
react: ^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
checksum: 10c0/ac006104323c61af6d8e208edb49038485458756b72c9e15622beef2700085bcdb1d21f028be1b3f199ff15616eab9d61a45aa09650c9ae1d72bf2a23be48616
languageName: node
linkType: hard
"react-style-singleton@npm:^2.2.2, react-style-singleton@npm:^2.2.3": "react-style-singleton@npm:^2.2.2, react-style-singleton@npm:^2.2.3":
version: 2.2.3 version: 2.2.3
resolution: "react-style-singleton@npm:2.2.3" resolution: "react-style-singleton@npm:2.2.3"
@ -11987,6 +12049,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"resize-observer-polyfill@npm:^1.5.0":
version: 1.5.1
resolution: "resize-observer-polyfill@npm:1.5.1"
checksum: 10c0/5e882475067f0b97dc07e0f37c3e335ac5bc3520d463f777cec7e894bb273eddbfecb857ae668e6fb6881fd6f6bb7148246967172139302da50fa12ea3a15d95
languageName: node
linkType: hard
"resolve-cwd@npm:^3.0.0": "resolve-cwd@npm:^3.0.0":
version: 3.0.0 version: 3.0.0
resolution: "resolve-cwd@npm:3.0.0" resolution: "resolve-cwd@npm:3.0.0"
@ -12610,6 +12679,15 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"slick-carousel@npm:^1.8.1":
version: 1.8.1
resolution: "slick-carousel@npm:1.8.1"
peerDependencies:
jquery: ">=1.8.0"
checksum: 10c0/e8c9c9a454c107cfee88689477b453449ed66d5343cf495d3135ec25ea736a0df862f625a9cdc8abd6262629ecef3343c5de686694831f99b13dfe837a1aa587
languageName: node
linkType: hard
"smart-buffer@npm:^4.2.0": "smart-buffer@npm:^4.2.0":
version: 4.2.0 version: 4.2.0
resolution: "smart-buffer@npm:4.2.0" resolution: "smart-buffer@npm:4.2.0"
@ -12791,6 +12869,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"string-convert@npm:^0.2.0":
version: 0.2.1
resolution: "string-convert@npm:0.2.1"
checksum: 10c0/00673ed8a3106137395436537ace7d3672c91a3290da73466055daa0134331dc84bc58c54ba2d2ea40711adc5744426d3c8239dbfc30290438fa3e9ff65db528
languageName: node
linkType: hard
"string-length@npm:^4.0.2": "string-length@npm:^4.0.2":
version: 4.0.2 version: 4.0.2
resolution: "string-length@npm:4.0.2" resolution: "string-length@npm:4.0.2"