Skip to content

Commit 96e2abf

Browse files
authored
feat(admin-ui): IconPicker component (#4519)
1 parent 5607b46 commit 96e2abf

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

67 files changed

+1645
-1098
lines changed

packages/admin-ui/package.json

+6
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
"description": "The UI component library for Webiny's Admin app.",
1010
"license": "MIT",
1111
"dependencies": {
12+
"@fortawesome/fontawesome-svg-core": "^1.3.0",
13+
"@fortawesome/react-fontawesome": "^0.1.17",
1214
"@material-design-icons/svg": "^0.14.13",
1315
"@radix-ui/react-accessible-icon": "^1.1.0",
1416
"@radix-ui/react-avatar": "^1.1.0",
@@ -34,21 +36,25 @@
3436
"class-variance-authority": "^0.7.0",
3537
"clsx": "^2.1.1",
3638
"cmdk": "^1.0.4",
39+
"lodash": "^4.17.21",
3740
"mobx": "^6.9.0",
3841
"react": "18.2.0",
3942
"react-ace": "^13.0.0",
43+
"react-virtualized": "^9.22.5",
4044
"tailwind-merge": "^2.4.0",
4145
"tailwindcss": "^3.4.6",
4246
"tailwindcss-animate": "^1.0.7"
4347
},
4448
"devDependencies": {
49+
"@fortawesome/free-solid-svg-icons": "^6.0.0",
4550
"@storybook/addon-a11y": "7.6.20",
4651
"@storybook/addon-essentials": "7.6.20",
4752
"@storybook/react": "7.6.20",
4853
"@storybook/react-webpack5": "7.6.20",
4954
"@storybook/theming": "7.6.20",
5055
"@svgr/webpack": "^6.1.1",
5156
"@types/react": "18.2.79",
57+
"@types/react-virtualized": "^9.22.0",
5258
"@webiny/cli": "0.0.0",
5359
"@webiny/project-utils": "0.0.0",
5460
"chalk": "^4.1.2",

packages/admin-ui/src/Command/components/Empty.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ type EmptyProps = React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>;
55

66
const Empty = (props: EmptyProps) => (
77
<CommandPrimitive.Empty
8-
className="wby-bg-neutral-base wby-text-neutral-strong wby-fill-neutral-xstrong wby-rounded-sm wby-p-sm wby-mx-sm wby-text-md outline-none"
8+
className="wby-bg-neutral-base wby-text-neutral-strong wby-fill-neutral-xstrong wby-rounded-sm wby-p-sm wby-mx-sm wby-text-md wby-outline-none"
99
{...props}
1010
/>
1111
);

packages/admin-ui/src/Command/components/Input.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ type InputProps = Omit<React.ComponentPropsWithoutRef<typeof CommandPrimitive.In
1010
const Input = ({ inputElement, size, ...props }: InputProps) => {
1111
return (
1212
<CommandPrimitive.Input asChild {...props}>
13-
{inputElement ?? <InputPrimitive size={size} />}
13+
{inputElement ?? <InputPrimitive size={size} forwardEventOnChange={true} />}
1414
</CommandPrimitive.Input>
1515
);
1616
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import React, { useEffect, useRef, useState } from "react";
2+
import isEqual from "lodash/isEqual";
3+
4+
const emptyFunction = (): undefined => {
5+
return undefined;
6+
};
7+
8+
export interface ApplyValueCb<TValue> {
9+
(value: TValue): void;
10+
}
11+
12+
/**
13+
* This component is used to wrap Input and Textarea components to optimize form re-render.
14+
* These 2 are the only components that trigger form model change on each character input.
15+
* This means, whenever you type a letter an entire form re-renders.
16+
* On complex forms you will feel and see a significant delay if this component is not used.
17+
*
18+
* The logic behind this component is to serve as a middleware between Form and Input/Textarea, and only notify form of a change when
19+
* a user stops typing for given period of time (400ms by default).
20+
*/
21+
22+
export interface OnChangeCallable<TValue = any> {
23+
(value: TValue, cb?: ApplyValueCb<TValue>): void;
24+
}
25+
26+
interface OnBlurCallable {
27+
(ev: React.SyntheticEvent): void;
28+
}
29+
30+
interface OnKeyDownCallable {
31+
(ev: React.KeyboardEvent<HTMLInputElement>): void;
32+
}
33+
34+
interface ChildrenCallableParams<TValue> {
35+
value: TValue;
36+
onChange: OnChangeCallable<TValue>;
37+
}
38+
39+
interface ChildrenCallable<TValue> {
40+
(params: ChildrenCallableParams<TValue>): React.ReactElement;
41+
}
42+
43+
export interface DelayedOnChangeProps<TValue> {
44+
value?: TValue;
45+
delay?: number;
46+
onChange?: OnChangeCallable<TValue>;
47+
onBlur?: OnBlurCallable;
48+
onKeyDown?: OnKeyDownCallable;
49+
children: React.ReactNode | ChildrenCallable<TValue | undefined>;
50+
}
51+
52+
export const DelayedOnChange = <TValue = any>({
53+
children,
54+
...other
55+
}: DelayedOnChangeProps<TValue>) => {
56+
const firstMount = useRef(true);
57+
const { onChange, delay = 400, value: initialValue } = other;
58+
const [value, setValue] = useState<TValue | undefined>(initialValue);
59+
// Sync state and props
60+
useEffect(() => {
61+
// Do not update local state, if the incoming value is the same as the local state.
62+
// This is primarily an optimization for non-scalar values (objects).
63+
if (isEqual(initialValue, value)) {
64+
return;
65+
}
66+
67+
setValue(initialValue);
68+
}, [initialValue]);
69+
70+
const localTimeout = React.useRef<number | null>(null);
71+
72+
const applyValue = (value: TValue | undefined) => {
73+
localTimeout.current && clearTimeout(localTimeout.current);
74+
localTimeout.current = null;
75+
if (!onChange) {
76+
return;
77+
}
78+
onChange(value as NonNullable<TValue>);
79+
};
80+
81+
const onChangeLocal = React.useCallback((value: TValue | undefined) => {
82+
setValue(value);
83+
}, []);
84+
85+
// this is fired upon change value state
86+
const onValueStateChanged = (nextValue: TValue | undefined) => {
87+
// We don't want to execute callbacks, if the value hasn't changed.
88+
if (isEqual(nextValue, initialValue)) {
89+
return;
90+
}
91+
92+
localTimeout.current && clearTimeout(localTimeout.current);
93+
localTimeout.current = null;
94+
localTimeout.current = setTimeout(() => applyValue(nextValue), delay) as unknown as number;
95+
};
96+
97+
// need to clear the timeout when unmounting the component
98+
useEffect(() => {
99+
return () => {
100+
if (!localTimeout.current) {
101+
return;
102+
}
103+
clearTimeout(localTimeout.current);
104+
localTimeout.current = null;
105+
};
106+
}, []);
107+
108+
useEffect(() => {
109+
if (firstMount.current) {
110+
firstMount.current = false;
111+
return;
112+
}
113+
114+
onValueStateChanged(value);
115+
}, [value]);
116+
117+
const newProps = {
118+
...other,
119+
value: value,
120+
onChange: onChangeLocal
121+
};
122+
123+
const renderProp =
124+
typeof children === "function" ? (children as ChildrenCallable<TValue | undefined>) : null;
125+
const child = renderProp
126+
? renderProp(newProps)
127+
: React.cloneElement(children as unknown as React.ReactElement, newProps);
128+
129+
const props = { ...child.props };
130+
const realOnKeyDown = props.onKeyDown || emptyFunction;
131+
const realOnBlur = props.onBlur || emptyFunction;
132+
133+
// Need to apply value if input lost focus
134+
const onBlur: OnBlurCallable = ev => {
135+
if (!ev["persist"]) {
136+
return;
137+
}
138+
ev.persist();
139+
applyValue((ev.target as HTMLInputElement).value as any as TValue);
140+
realOnBlur(ev);
141+
};
142+
143+
// Need to listen for TAB key to apply new value immediately, without delay. Otherwise validation will be triggered with old value.
144+
const onKeyDown: OnKeyDownCallable = ev => {
145+
ev.persist();
146+
if (ev.key === "Tab") {
147+
applyValue((ev.target as HTMLInputElement).value as any as TValue);
148+
realOnKeyDown(ev);
149+
} else if (ev.key === "Enter") {
150+
applyValue((ev.target as HTMLInputElement).value as any as TValue);
151+
realOnKeyDown(ev);
152+
} else {
153+
realOnKeyDown(ev);
154+
}
155+
};
156+
157+
return React.cloneElement(child, { ...props, onBlur, onKeyDown });
158+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from "./DelayedOnChange";

packages/admin-ui/src/Icon/Icon.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ const IconBase = React.forwardRef<HTMLOrSVGElement, IconProps>((props, ref) => {
3939
<AccessibleIcon.Root label={label}>
4040
{React.cloneElement(icon, {
4141
...rest,
42-
className: cn(iconVariants({ color, size, className })),
42+
className: cn(iconVariants({ color, size }), className),
4343
ref
4444
})}
4545
</AccessibleIcon.Root>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import React, { useState } from "react";
2+
import type { Meta, StoryObj } from "@storybook/react";
3+
import { library } from "@fortawesome/fontawesome-svg-core";
4+
import { fas } from "@fortawesome/free-solid-svg-icons";
5+
import { IconPicker } from "~/IconPicker";
6+
7+
const meta: Meta<typeof IconPicker> = {
8+
title: "Components/Form/IconPicker",
9+
component: IconPicker,
10+
tags: ["autodocs"],
11+
argTypes: {
12+
onChange: { action: "onChange" }
13+
},
14+
parameters: {
15+
layout: "padded"
16+
},
17+
render: args => {
18+
const [value, setValue] = useState(args.value);
19+
return <IconPicker {...args} value={value} onChange={setValue} />;
20+
}
21+
};
22+
23+
// @ts-expect-error
24+
library.add(fas);
25+
26+
export default meta;
27+
type Story = StoryObj<typeof IconPicker>;
28+
29+
export const Default: Story = {
30+
args: {
31+
icons: [
32+
{ prefix: "fas", name: "trash-restore-alt" },
33+
{ prefix: "fas", name: "trash-can-arrow-up" },
34+
{ prefix: "fas", name: "naira-sign" },
35+
{ prefix: "fas", name: "cart-arrow-down" },
36+
{ prefix: "fas", name: "walkie-talkie" },
37+
{ prefix: "fas", name: "file-edit" },
38+
{ prefix: "fas", name: "file-pen" },
39+
{ prefix: "fas", name: "receipt" },
40+
{ prefix: "fas", name: "pen-square" },
41+
{ prefix: "fas", name: "pencil-square" },
42+
{ prefix: "fas", name: "square-pen" },
43+
{ prefix: "fas", name: "suitcase-rolling" },
44+
{ prefix: "fas", name: "person-circle-exclamation" },
45+
{ prefix: "fas", name: "chevron-down" },
46+
{ prefix: "fas", name: "battery" },
47+
{ prefix: "fas", name: "battery-5" },
48+
{ prefix: "fas", name: "battery-full" },
49+
{ prefix: "fas", name: "skull-crossbones" },
50+
{ prefix: "fas", name: "code-compare" },
51+
{ prefix: "fas", name: "list-dots" },
52+
{ prefix: "fas", name: "list-ul" },
53+
{ prefix: "fas", name: "school-lock" },
54+
{ prefix: "fas", name: "tower-cell" },
55+
{ prefix: "fas", name: "long-arrow-alt-down" },
56+
{ prefix: "fas", name: "down-long" },
57+
{ prefix: "fas", name: "ranking-star" },
58+
{ prefix: "fas", name: "chess-king" },
59+
{ prefix: "fas", name: "person-harassing" },
60+
{ prefix: "fas", name: "brazilian-real-sign" },
61+
{ prefix: "fas", name: "landmark-alt" },
62+
{ prefix: "fas", name: "landmark-dome" },
63+
{ prefix: "fas", name: "arrow-up" },
64+
{ prefix: "fas", name: "television" },
65+
{ prefix: "fas", name: "tv-alt" },
66+
{ prefix: "fas", name: "tv" },
67+
{ prefix: "fas", name: "shrimp" },
68+
{ prefix: "fas", name: "tasks" },
69+
{ prefix: "fas", name: "list-check" },
70+
{ prefix: "fas", name: "jug-detergent" },
71+
{ prefix: "fas", name: "user-circle" },
72+
{ prefix: "fas", name: "circle-user" },
73+
{ prefix: "fas", name: "user-shield" },
74+
{ prefix: "fas", name: "wind" },
75+
{ prefix: "fas", name: "car-crash" },
76+
{ prefix: "fas", name: "car-burst" },
77+
{ prefix: "fas", name: "y" },
78+
{ prefix: "fas", name: "snowboarding" },
79+
{ prefix: "fas", name: "person-snowboarding" },
80+
{ prefix: "fas", name: "shipping-fast" },
81+
{ prefix: "fas", name: "truck-fast" }
82+
]
83+
}
84+
};
85+
86+
export const WithLabel: Story = {
87+
args: {
88+
...Default.args,
89+
label: "Any field label"
90+
}
91+
};
92+
93+
export const WithLabelRequired: Story = {
94+
args: {
95+
...Default.args,
96+
label: "Any field label",
97+
required: true
98+
}
99+
};
100+
101+
export const WithDescription: Story = {
102+
args: {
103+
...Default.args,
104+
description: "Provide the required information for processing your request."
105+
}
106+
};
107+
108+
export const WithNotes: Story = {
109+
args: {
110+
...Default.args,
111+
note: "Note: Ensure your selection or input is accurate before proceeding."
112+
}
113+
};
114+
115+
export const WithErrors: Story = {
116+
args: {
117+
...Default.args,
118+
validation: {
119+
isValid: false,
120+
message: "This field is required."
121+
}
122+
}
123+
};
124+
125+
export const Disabled: Story = {
126+
args: {
127+
...Default.args,
128+
label: "Any field label",
129+
disabled: true
130+
}
131+
};
132+
133+
export const FullExample: Story = {
134+
args: {
135+
...Default.args,
136+
label: "Any field label",
137+
required: true,
138+
description: "Provide the required information for processing your request.",
139+
note: "Note: Ensure your selection or input is accurate before proceeding.",
140+
validation: {
141+
isValid: false,
142+
message: "This field is required."
143+
}
144+
}
145+
};

0 commit comments

Comments
 (0)