From 5ba1a3b8f9a46bd9e6a49d020d1b841bdc3df9a6 Mon Sep 17 00:00:00 2001 From: BerkieBb <82737367+BerkieBb@users.noreply.github.com> Date: Sat, 29 Oct 2022 12:23:15 +0200 Subject: [PATCH 1/3] feat(interface/menu): checkbox feat(package/interface/menu): checkbox feat(web/menu/list): checkbox feat(web/menu/list): checkbox option for ListItem feat(web/menu/list): custom checkbox --- package/client/resource/interface/menu.ts | 4 +- resource/interface/client/menu.lua | 29 +++++-- web/src/features/menu/list/CustomCheckbox.tsx | 36 +++++++++ web/src/features/menu/list/ListItem.tsx | 11 ++- web/src/features/menu/list/index.tsx | 75 ++++++++++++++----- 5 files changed, 125 insertions(+), 30 deletions(-) create mode 100644 web/src/features/menu/list/CustomCheckbox.tsx diff --git a/package/client/resource/interface/menu.ts b/package/client/resource/interface/menu.ts index 258baa5..383598a 100644 --- a/package/client/resource/interface/menu.ts +++ b/package/client/resource/interface/menu.ts @@ -1,11 +1,12 @@ import { IconName, IconPrefix } from '@fortawesome/fontawesome-common-types'; type MenuPosition = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; -type ChangeFunction = (selected: number, scrollIndex: number | null, args: any | null) => void; +type ChangeFunction = (selected: number, scrollIndex?: number, args?: any, checked?: boolean) => void; interface MenuOptions { label: string; icon?: IconName | [IconPrefix, IconName]; + checked?: boolean; values?: Array; description?: string; defaultIndex?: number; @@ -23,6 +24,7 @@ interface MenuProps { onClose?: (keyPressed?: 'Escape' | 'Backspace') => void; onSelected?: ChangeFunction; onSideScroll?: ChangeFunction; + onChecked?: ChangeFunction; cb?: ChangeFunction; } diff --git a/resource/interface/client/menu.lua b/resource/interface/client/menu.lua index 7948cb9..70cbd09 100644 --- a/resource/interface/client/menu.lua +++ b/resource/interface/client/menu.lua @@ -4,12 +4,13 @@ local registeredMenus = {} local openMenu = nil local keepInput = IsNuiFocusKeepingInput() ----@alias MenuPosition 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; ----@alias MenuChangeFunction fun(selected: number, scrollIndex?: number, args?: any) +---@alias MenuPosition 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' +---@alias MenuChangeFunction fun(selected: number, scrollIndex?: number, args?: any, checked?: boolean) ---@class MenuOptions ---@field label string ---@field icon? string +---@field checked? boolean ---@field values? Array ---@field description? string ---@field defaultIndex? number @@ -26,6 +27,7 @@ local keepInput = IsNuiFocusKeepingInput() ---@field onClose? fun(keyPressed?: 'Escape' | 'Backspace') ---@field onSelected? MenuChangeFunction ---@field onSideScroll? MenuChangeFunction +---@field onCheck? MenuChangeFunction ---@field cb? MenuChangeFunction ---@param data MenuProps @@ -132,13 +134,13 @@ RegisterNUICallback('confirmSelected', function(data, cb) end if menu.cb then - menu.cb(data[1], data[2], menu.options[data[1]].args) + menu.cb(data[1], data[2], menu.options[data[1]].args, data[3]) end end) RegisterNUICallback('changeIndex', function(data, cb) cb(1) - if not openMenu.onSideScroll then return end + if not openMenu?.onSideScroll then return end data[1] += 1 -- selected @@ -146,12 +148,12 @@ RegisterNUICallback('changeIndex', function(data, cb) data[2] += 1 -- scrollIndex end - openMenu.onSideScroll(data[1], data[2], openMenu.options[data[1]].args) + openMenu.onSideScroll(data[1], data[2], openMenu.options[data[1]].args, data[3]) end) RegisterNUICallback('changeSelected', function(data, cb) cb(1) - if not openMenu.onSelected then return end + if not openMenu?.onSelected then return end data[1] += 1 -- selected @@ -159,7 +161,20 @@ RegisterNUICallback('changeSelected', function(data, cb) data[2] += 1 -- scrollIndex end - openMenu.onSelected(data[1], data[2], openMenu.options[data[1]].args) + openMenu.onSelected(data[1], data[2], openMenu.options[data[1]].args, data[3]) +end) + +RegisterNUICallback('changeChecked', function(data, cb) + cb(1) + if not openMenu?.onCheck then return end + + data[1] += 1 -- selected + + if data[2] then + data[2] += 1 -- scrollIndex + end + + openMenu.onCheck(data[1], data[2], openMenu.options[data[1]].args, data[3]) end) RegisterNUICallback('closeMenu', function(data, cb) diff --git a/web/src/features/menu/list/CustomCheckbox.tsx b/web/src/features/menu/list/CustomCheckbox.tsx new file mode 100644 index 0000000..a2af255 --- /dev/null +++ b/web/src/features/menu/list/CustomCheckbox.tsx @@ -0,0 +1,36 @@ +import { Box, Flex, useCheckbox, chakra, CheckboxIcon } from '@chakra-ui/react'; + +const CustomCheckbox: React.FC<{ checked: boolean }> = ({ checked }) => { + const { getCheckboxProps, getInputProps, htmlProps } = useCheckbox(); + return ( + + + + {checked && ( + + + + )} + + + ) +}; + +export default CustomCheckbox; diff --git a/web/src/features/menu/list/ListItem.tsx b/web/src/features/menu/list/ListItem.tsx index 4f57bfd..8c74345 100644 --- a/web/src/features/menu/list/ListItem.tsx +++ b/web/src/features/menu/list/ListItem.tsx @@ -1,15 +1,17 @@ -import { Box, Flex, Stack, Spacer, Text, IconProps } from '@chakra-ui/react'; +import { Box, Flex, Stack, Text } from '@chakra-ui/react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { forwardRef } from 'react'; +import CustomCheckbox from './CustomCheckbox'; import type { MenuItem } from './index'; interface Props { item: MenuItem; index: number; scrollIndex: number; + checked: boolean; } -const ListItem = forwardRef, Props>(({ item, index, scrollIndex }, ref) => { +const ListItem = forwardRef, Props>(({ item, index, scrollIndex, checked }, ref) => { return ( , Props>(({ item, index, + ) : item.checked !== undefined ? ( + + {item.label} + + ) : ( {item.label} )} diff --git a/web/src/features/menu/list/index.tsx b/web/src/features/menu/list/index.tsx index fd5d1f1..c46b7d7 100644 --- a/web/src/features/menu/list/index.tsx +++ b/web/src/features/menu/list/index.tsx @@ -11,6 +11,7 @@ import React from 'react'; export interface MenuItem { label: string; + checked?: boolean; values?: Array; description?: string; icon?: IconProp; @@ -35,6 +36,7 @@ const ListMenu: React.FC = () => { const [selected, setSelected] = useState(0); const [visible, setVisible] = useState(false); const [indexStates, setIndexStates] = useState>({}); + const [checkedStates, setCheckedStates] = useState>({}); const listRefs = useRef>([]); const firstRenderRef = useRef(false); @@ -59,33 +61,63 @@ const ListMenu: React.FC = () => { }); break; case 'ArrowRight': - if (!Array.isArray(menu.items[selected].values)) return; - setIndexStates({ - ...indexStates, - [selected]: - indexStates[selected] + 1 <= menu.items[selected].values?.length! - 1 - ? indexStates[selected] + 1 - : (indexStates[selected] = 0), - }); + if (Array.isArray(menu.items[selected].values)) { + setIndexStates({ + ...indexStates, + [selected]: + indexStates[selected] + 1 <= menu.items[selected].values?.length! - 1 + ? indexStates[selected] + 1 + : 0 + }); + } else if (menu.items[selected].checked !== undefined) { + setCheckedStates({ + ...checkedStates, + [selected]: !checkedStates[selected] + }); + } break; case 'ArrowLeft': - if (!Array.isArray(menu.items[selected].values)) return; - setIndexStates({ - ...indexStates, - [selected]: - indexStates[selected] - 1 >= 0 - ? indexStates[selected] - 1 - : (indexStates[selected] = menu.items[selected].values?.length! - 1), - }); + if (Array.isArray(menu.items[selected].values)) { + setIndexStates({ + ...indexStates, + [selected]: + indexStates[selected] - 1 >= 0 + ? indexStates[selected] - 1 + : menu.items[selected].values?.length! - 1 + }); + } else if (menu.items[selected].checked !== undefined) { + setCheckedStates({ + ...checkedStates, + [selected]: !checkedStates[selected] + }); + } break; case 'Enter': if (!menu.items[selected]) return; + if (menu.items[selected].checked !== undefined) { + setCheckedStates({ + ...checkedStates, + [selected]: !checkedStates[selected] + }); + } fetchNui('confirmSelected', [selected, indexStates[selected]]).catch(); if (menu.items[selected].close === undefined || menu.items[selected].close) setVisible(false); break; } }; + useEffect(() => { + if (firstRenderRef.current) { + firstRenderRef.current = false; + return; + } + if (menu.items[selected]?.checked === undefined) return; + const timer = setTimeout(() => { + fetchNui('changeChecked', [selected, indexStates[selected], checkedStates[selected]]).catch(); + }, 100); + return () => clearTimeout(timer); + }, [checkedStates]) + useEffect(() => { if (firstRenderRef.current) { firstRenderRef.current = false; @@ -93,7 +125,7 @@ const ListMenu: React.FC = () => { } if (!menu.items[selected]?.values) return; const timer = setTimeout(() => { - fetchNui('changeIndex', [selected, indexStates[selected]]).catch(); + fetchNui('changeIndex', [selected, indexStates[selected], checkedStates[selected]]).catch(); }, 100); return () => clearTimeout(timer); }, [indexStates]); @@ -107,7 +139,7 @@ const ListMenu: React.FC = () => { listRefs.current[selected]?.focus({ preventScroll: true }); // debounces the callback to avoid spam const timer = setTimeout(() => { - fetchNui('changeSelected', [selected, indexStates[selected]]).catch(); + fetchNui('changeSelected', [selected, indexStates[selected], checkedStates[selected]]).catch(); }, 100); return () => clearTimeout(timer); }, [selected, menu]); @@ -135,11 +167,14 @@ const ListMenu: React.FC = () => { listRefs.current = []; setMenu(data); setVisible(true); - let arrayIndexes: { [key: number]: number } = {}; + const arrayIndexes: { [key: number]: number } = {}; + const checkedIndexes: { [key: number]: boolean } = {}; for (let i = 0; i < data.items.length; i++) { if (Array.isArray(data.items[i].values)) arrayIndexes[i] = (data.items[i].defaultIndex || 1) - 1; + else if (data.items[i].checked !== undefined) checkedIndexes[i] = data.items[i].checked || false; } setIndexStates(arrayIndexes); + setCheckedStates(checkedIndexes); listRefs.current[data.startItemIndex]?.focus(); }); @@ -189,7 +224,7 @@ const ListMenu: React.FC = () => { {menu.items.map((item, index) => ( {item.label && ( - + )} ))} From c227282ce0968ad45560288d3b7335aab3c78b2a Mon Sep 17 00:00:00 2001 From: Luke Date: Sat, 29 Oct 2022 12:46:22 +0200 Subject: [PATCH 2/3] refactor(web/menu): update checkbox styling --- web/src/features/dev/debug/menu.ts | 1 + web/src/features/menu/list/CustomCheckbox.tsx | 28 +++++++++---------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/web/src/features/dev/debug/menu.ts b/web/src/features/dev/debug/menu.ts index ad8a91a..29a5b63 100644 --- a/web/src/features/dev/debug/menu.ts +++ b/web/src/features/dev/debug/menu.ts @@ -14,6 +14,7 @@ export const debugMenu = () => { label: 'Option 2', icon: 'basket-shopping', description: 'Tooltip description 1', + checked: true, }, { label: 'Vehicle class', diff --git a/web/src/features/menu/list/CustomCheckbox.tsx b/web/src/features/menu/list/CustomCheckbox.tsx index a2af255..1a28e0c 100644 --- a/web/src/features/menu/list/CustomCheckbox.tsx +++ b/web/src/features/menu/list/CustomCheckbox.tsx @@ -4,33 +4,33 @@ const CustomCheckbox: React.FC<{ checked: boolean }> = ({ checked }) => { const { getCheckboxProps, getInputProps, htmlProps } = useCheckbox(); return ( {checked && ( - - + + )} - ) + ); }; export default CustomCheckbox; From 9cc7b9270ea1a64859dab2bd7bab9389d65cee13 Mon Sep 17 00:00:00 2001 From: Luke Date: Sat, 29 Oct 2022 15:52:59 +0200 Subject: [PATCH 3/3] refactor(web/menu)!: pass scrollIndex or checked in the same value If the button is a scroll button then `isScroll` is passed into args, if a button is a checkbox then `isCheck` is passed into args. Requires `args` to always be of type `table` --- package/client/resource/interface/menu.ts | 2 +- resource/interface/client/menu.lua | 26 +++++++---- web/src/features/menu/list/index.tsx | 55 +++++++++++------------ 3 files changed, 45 insertions(+), 38 deletions(-) diff --git a/package/client/resource/interface/menu.ts b/package/client/resource/interface/menu.ts index 383598a..e0c0c35 100644 --- a/package/client/resource/interface/menu.ts +++ b/package/client/resource/interface/menu.ts @@ -10,7 +10,7 @@ interface MenuOptions { values?: Array; description?: string; defaultIndex?: number; - args?: any; + args?: Record; close?: boolean; } diff --git a/resource/interface/client/menu.lua b/resource/interface/client/menu.lua index 70cbd09..e7f3c85 100644 --- a/resource/interface/client/menu.lua +++ b/resource/interface/client/menu.lua @@ -14,7 +14,7 @@ local keepInput = IsNuiFocusKeepingInput() ---@field values? Array ---@field description? string ---@field defaultIndex? number ----@field args? any +---@field args? {[any]: any} ---@field close? boolean ---@class MenuProps @@ -148,7 +148,7 @@ RegisterNUICallback('changeIndex', function(data, cb) data[2] += 1 -- scrollIndex end - openMenu.onSideScroll(data[1], data[2], openMenu.options[data[1]].args, data[3]) + openMenu.onSideScroll(data[1], data[2], openMenu.options[data[1]].args) end) RegisterNUICallback('changeSelected', function(data, cb) @@ -157,11 +157,23 @@ RegisterNUICallback('changeSelected', function(data, cb) data[1] += 1 -- selected - if data[2] then + + local args = openMenu.options[data[1]].args + + if args and type(args) ~= 'table' then + return error("Menu args must be passed as a table") + end + + if not args then args = {} end + print(data[2], data[3]) + if data[2] then args[data[3]] = true end + print(args.isScroll, args.isCheck) + + if data[2] and not args.isCheck then data[2] += 1 -- scrollIndex end - openMenu.onSelected(data[1], data[2], openMenu.options[data[1]].args, data[3]) + openMenu.onSelected(data[1], data[2], args) end) RegisterNUICallback('changeChecked', function(data, cb) @@ -170,11 +182,7 @@ RegisterNUICallback('changeChecked', function(data, cb) data[1] += 1 -- selected - if data[2] then - data[2] += 1 -- scrollIndex - end - - openMenu.onCheck(data[1], data[2], openMenu.options[data[1]].args, data[3]) + openMenu.onCheck(data[1], data[2], openMenu.options[data[1]].args) end) RegisterNUICallback('closeMenu', function(data, cb) diff --git a/web/src/features/menu/list/index.tsx b/web/src/features/menu/list/index.tsx index c46b7d7..c0f9724 100644 --- a/web/src/features/menu/list/index.tsx +++ b/web/src/features/menu/list/index.tsx @@ -61,43 +61,28 @@ const ListMenu: React.FC = () => { }); break; case 'ArrowRight': - if (Array.isArray(menu.items[selected].values)) { + if (Array.isArray(menu.items[selected].values)) setIndexStates({ ...indexStates, [selected]: - indexStates[selected] + 1 <= menu.items[selected].values?.length! - 1 - ? indexStates[selected] + 1 - : 0 + indexStates[selected] + 1 <= menu.items[selected].values?.length! - 1 ? indexStates[selected] + 1 : 0, }); - } else if (menu.items[selected].checked !== undefined) { - setCheckedStates({ - ...checkedStates, - [selected]: !checkedStates[selected] - }); - } break; case 'ArrowLeft': - if (Array.isArray(menu.items[selected].values)) { + if (Array.isArray(menu.items[selected].values)) setIndexStates({ ...indexStates, [selected]: - indexStates[selected] - 1 >= 0 - ? indexStates[selected] - 1 - : menu.items[selected].values?.length! - 1 + indexStates[selected] - 1 >= 0 ? indexStates[selected] - 1 : menu.items[selected].values?.length! - 1, }); - } else if (menu.items[selected].checked !== undefined) { - setCheckedStates({ - ...checkedStates, - [selected]: !checkedStates[selected] - }); - } + break; case 'Enter': if (!menu.items[selected]) return; - if (menu.items[selected].checked !== undefined) { - setCheckedStates({ + if (menu.items[selected].checked !== undefined && !menu.items[selected].values) { + return setCheckedStates({ ...checkedStates, - [selected]: !checkedStates[selected] + [selected]: !checkedStates[selected], }); } fetchNui('confirmSelected', [selected, indexStates[selected]]).catch(); @@ -113,10 +98,10 @@ const ListMenu: React.FC = () => { } if (menu.items[selected]?.checked === undefined) return; const timer = setTimeout(() => { - fetchNui('changeChecked', [selected, indexStates[selected], checkedStates[selected]]).catch(); + fetchNui('changeChecked', [selected, checkedStates[selected]]).catch(); }, 100); return () => clearTimeout(timer); - }, [checkedStates]) + }, [checkedStates]); useEffect(() => { if (firstRenderRef.current) { @@ -125,7 +110,7 @@ const ListMenu: React.FC = () => { } if (!menu.items[selected]?.values) return; const timer = setTimeout(() => { - fetchNui('changeIndex', [selected, indexStates[selected], checkedStates[selected]]).catch(); + fetchNui('changeIndex', [selected, indexStates[selected]]).catch(); }, 100); return () => clearTimeout(timer); }, [indexStates]); @@ -139,7 +124,15 @@ const ListMenu: React.FC = () => { listRefs.current[selected]?.focus({ preventScroll: true }); // debounces the callback to avoid spam const timer = setTimeout(() => { - fetchNui('changeSelected', [selected, indexStates[selected], checkedStates[selected]]).catch(); + fetchNui('changeSelected', [ + selected, + menu.items[selected].values + ? indexStates[selected] + : menu.items[selected].checked + ? checkedStates[selected] + : null, + menu.items[selected].values ? 'isScroll' : menu.items[selected].checked ? 'isCheck' : null, + ]).catch(); }, 100); return () => clearTimeout(timer); }, [selected, menu]); @@ -224,7 +217,13 @@ const ListMenu: React.FC = () => { {menu.items.map((item, index) => ( {item.label && ( - + )} ))}