Compare commits
19 Commits
bc7262cede
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 52a97f2a97 | |||
| 6c141b71da | |||
| 89fed20f70 | |||
| 9414421430 | |||
| c4f0fcc233 | |||
| 5180f52698 | |||
| ce7cf9435a | |||
|
|
ad2252f5e4 | ||
|
|
287dbcf4da | ||
|
|
f963b38339 | ||
|
|
55cb9038ad | ||
|
|
9d907068a6 | ||
|
|
ecb90c69aa | ||
|
|
070e56e1af | ||
|
|
3e460ae46c | ||
|
|
9c64217b72 | ||
| 1fb57d3454 | |||
| a8e9c50290 | |||
| 31f2a0428f |
30
CHANGELOG.md
30
CHANGELOG.md
@@ -1,5 +1,35 @@
|
|||||||
# @warkypublic/zustandsyncstore
|
# @warkypublic/zustandsyncstore
|
||||||
|
|
||||||
|
## 0.0.30
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 89fed20: fix: update GridlerStore setState type to accept full state values
|
||||||
|
|
||||||
|
## 0.0.29
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 5180f52: feat(Former): ✨ update layout to use buttonArea prop instead of buttonOnTop
|
||||||
|
|
||||||
|
## 0.0.28
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 287dbcf: 1
|
||||||
|
|
||||||
|
## 0.0.27
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 9d90706: feat(Gridler): ✨ add isValuesInPages method and update state handling
|
||||||
|
|
||||||
|
## 0.0.26
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 3e460ae: fixed Gridler selectFirstRow
|
||||||
|
|
||||||
## 0.0.25
|
## 0.0.25
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const config = defineConfig([
|
|||||||
{
|
{
|
||||||
extends: ['js/recommended'],
|
extends: ['js/recommended'],
|
||||||
files: ['**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
|
files: ['**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
|
||||||
|
ignores: ['**/*.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}', '*stories.tsx','dist/**'],
|
||||||
languageOptions: { globals: globals.browser },
|
languageOptions: { globals: globals.browser },
|
||||||
plugins: { js },
|
plugins: { js },
|
||||||
},
|
},
|
||||||
@@ -20,7 +20,7 @@ const config = defineConfig([
|
|||||||
tseslint.configs.recommended,
|
tseslint.configs.recommended,
|
||||||
{
|
{
|
||||||
...pluginReact.configs.flat.recommended,
|
...pluginReact.configs.flat.recommended,
|
||||||
ignores: ['**/*.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}', '*stories.tsx'],
|
ignores: ['**/*.test.{js,mjs,cjs,ts,mts,cts,jsx,tsx}', '*stories.tsx','dist/**'],
|
||||||
rules: {
|
rules: {
|
||||||
...pluginReact.configs.flat.recommended.rules,
|
...pluginReact.configs.flat.recommended.rules,
|
||||||
'react/react-in-jsx-scope': 'off',
|
'react/react-in-jsx-scope': 'off',
|
||||||
@@ -34,6 +34,7 @@ const config = defineConfig([
|
|||||||
'@typescript-eslint/ban-ts-comment': 'off',
|
'@typescript-eslint/ban-ts-comment': 'off',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{ignores: ['dist/**','node_modules/**','vite.config.*','eslint.config.*' ]},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
84
package.json
84
package.json
@@ -1,8 +1,26 @@
|
|||||||
{
|
{
|
||||||
"name": "@warkypublic/oranguru",
|
"name": "@warkypublic/oranguru",
|
||||||
"author": "Warky Devs",
|
"author": "Warky Devs",
|
||||||
"version": "0.0.25",
|
"version": "0.0.30",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"types": "./dist/lib.d.ts",
|
||||||
|
"main": "./dist/lib.cjs.js",
|
||||||
|
"module": "./dist/lib.es.js",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/lib.d.ts",
|
||||||
|
"import": "./dist/lib.es.js",
|
||||||
|
"require": "./dist/lib.cjs.js"
|
||||||
|
},
|
||||||
|
"./oranguru.css": "./dist/oranguru.css",
|
||||||
|
"./package.json": "./package.json"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist/**",
|
||||||
|
"assets/**",
|
||||||
|
"public/**",
|
||||||
|
"global.d.ts"
|
||||||
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
@@ -17,70 +35,40 @@
|
|||||||
"storybook": "storybook dev -p 6006",
|
"storybook": "storybook dev -p 6006",
|
||||||
"build-storybook": "storybook build"
|
"build-storybook": "storybook build"
|
||||||
},
|
},
|
||||||
"files": [
|
|
||||||
"dist/**",
|
|
||||||
"assets/**",
|
|
||||||
"public/**",
|
|
||||||
"global.d.ts"
|
|
||||||
],
|
|
||||||
"module": "./src.lib.ts",
|
|
||||||
"types": "./src/lib.ts",
|
|
||||||
"publishConfig": {
|
|
||||||
"main": "./dist/lib.cjs.js",
|
|
||||||
"module": "./dist/lib.es.js",
|
|
||||||
"require": "./dist/lib.cjs.js",
|
|
||||||
"types": "./dist/lib.d.ts",
|
|
||||||
"typings": "./dist/lib.d.ts",
|
|
||||||
"exports": {
|
|
||||||
".": {
|
|
||||||
"import": "./dist/lib.es.js",
|
|
||||||
"types": "./dist/lib.d.ts",
|
|
||||||
"default": "./dist/lib.cjs.js"
|
|
||||||
},
|
|
||||||
"./package.json": "./package.json",
|
|
||||||
"./oranguru.css": "./dist/oranguru.css"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"exports": {
|
|
||||||
".": {
|
|
||||||
"types": "./src/lib.ts",
|
|
||||||
"default": "./src/lib.ts"
|
|
||||||
},
|
|
||||||
"./oranguru.css": "./src/oranguru.css"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tanstack/react-virtual": "^3.13.18",
|
||||||
"moment": "^2.30.1"
|
"moment": "^2.30.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@changesets/cli": "^2.29.7",
|
"@changesets/cli": "^2.29.8",
|
||||||
"@eslint/js": "^9.38.0",
|
"@eslint/js": "^9.39.2",
|
||||||
"@storybook/react-vite": "^9.1.15",
|
"@storybook/react-vite": "^10.2.1",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/node": "^24.9.1",
|
"@types/node": "^25.1.0",
|
||||||
"@types/react": "^19.2.2",
|
"@types/react": "^19.2.10",
|
||||||
"@types/react-dom": "^19.2.2",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@typescript-eslint/parser": "^8.46.2",
|
"@typescript-eslint/parser": "^8.54.0",
|
||||||
"@vitejs/plugin-react-swc": "^4.2.0",
|
"@vitejs/plugin-react-swc": "^4.2.2",
|
||||||
"eslint": "^9.38.0",
|
"eslint": "^9.38.0",
|
||||||
"eslint-config-mantine": "^4.0.3",
|
"eslint-config-mantine": "^4.0.3",
|
||||||
"eslint-plugin-perfectionist": "^4.15.1",
|
"eslint-plugin-perfectionist": "^5.4.0",
|
||||||
"eslint-plugin-react": "^7.37.5",
|
"eslint-plugin-react": "^7.37.5",
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
"eslint-plugin-storybook": "^9.1.15",
|
"eslint-plugin-storybook": "^9.1.15",
|
||||||
"global": "^4.4.0",
|
"global": "^4.4.0",
|
||||||
"globals": "^16.4.0",
|
"globals": "^17.2.0",
|
||||||
"jiti": "^2.6.1",
|
"jiti": "^2.6.1",
|
||||||
"jsdom": "^27.0.1",
|
"jsdom": "^27.4.0",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"postcss-preset-mantine": "^1.18.0",
|
"postcss-preset-mantine": "^1.18.0",
|
||||||
"postcss-simple-vars": "^7.0.1",
|
"postcss-simple-vars": "^7.0.1",
|
||||||
"prettier": "^3.6.2",
|
"prettier": "^3.6.2",
|
||||||
"prettier-eslint": "^16.4.2",
|
"prettier-eslint": "^16.4.2",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.4",
|
||||||
"storybook": "^9.1.15",
|
"storybook": "^9.1.15",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"typescript-eslint": "^8.46.2",
|
"typescript-eslint": "^8.46.2",
|
||||||
@@ -93,16 +81,16 @@
|
|||||||
"@glideapps/glide-data-grid": "^6.0.3",
|
"@glideapps/glide-data-grid": "^6.0.3",
|
||||||
"@mantine/core": "^8.3.1",
|
"@mantine/core": "^8.3.1",
|
||||||
"@mantine/hooks": "^8.3.1",
|
"@mantine/hooks": "^8.3.1",
|
||||||
"@mantine/notifications": "^8.3.5",
|
|
||||||
"@mantine/modals": "^8.3.5",
|
"@mantine/modals": "^8.3.5",
|
||||||
|
"@mantine/notifications": "^8.3.5",
|
||||||
"@tabler/icons-react": "^3.35.0",
|
"@tabler/icons-react": "^3.35.0",
|
||||||
"@tanstack/react-query": "^5.90.5",
|
"@tanstack/react-query": "^5.90.5",
|
||||||
"@warkypublic/artemis-kit": "^1.0.10",
|
"@warkypublic/artemis-kit": "^1.0.10",
|
||||||
"@warkypublic/zustandsyncstore": "^0.0.4",
|
"@warkypublic/zustandsyncstore": "^0.0.4",
|
||||||
"react-hook-form": "^7.71.0",
|
|
||||||
"immer": "^10.1.3",
|
"immer": "^10.1.3",
|
||||||
"react": ">= 19.0.0",
|
"react": ">= 19.0.0",
|
||||||
"react-dom": ">= 19.0.0",
|
"react-dom": ">= 19.0.0",
|
||||||
|
"react-hook-form": "^7.71.0",
|
||||||
"use-sync-external-store": ">= 1.4.0",
|
"use-sync-external-store": ">= 1.4.0",
|
||||||
"zustand": ">= 5.0.0"
|
"zustand": ">= 5.0.0"
|
||||||
}
|
}
|
||||||
|
|||||||
1107
pnpm-lock.yaml
generated
1107
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
159
src/Boxer/Boxer.store.tsx
Normal file
159
src/Boxer/Boxer.store.tsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import { createSyncStore } from '@warkypublic/zustandsyncstore';
|
||||||
|
import { produce } from 'immer';
|
||||||
|
|
||||||
|
import type { BoxerProps, BoxerStoreState } from './Boxer.types';
|
||||||
|
|
||||||
|
const { Provider: BoxerProvider, useStore: useBoxerStore } = createSyncStore<
|
||||||
|
BoxerStoreState,
|
||||||
|
BoxerProps
|
||||||
|
>(
|
||||||
|
(set, get) => ({
|
||||||
|
boxerData: [],
|
||||||
|
// Data Actions
|
||||||
|
fetchData: async (search?: string, reset?: boolean) => {
|
||||||
|
const state = get();
|
||||||
|
|
||||||
|
// Handle local data
|
||||||
|
if (state.dataSource === 'local' || !state.onAPICall) {
|
||||||
|
const localData = state.data ?? [];
|
||||||
|
|
||||||
|
if (!search) {
|
||||||
|
set({ boxerData: localData, hasMore: false, total: localData.length });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter local data based on search
|
||||||
|
const filtered = localData.filter((item) =>
|
||||||
|
item.label.toLowerCase().includes(search.toLowerCase())
|
||||||
|
);
|
||||||
|
set({ boxerData: filtered, hasMore: false, total: filtered.length });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle server-side data
|
||||||
|
if (state.onAPICall) {
|
||||||
|
try {
|
||||||
|
set({ isFetching: true });
|
||||||
|
|
||||||
|
const currentPage = reset ? 0 : state.page;
|
||||||
|
|
||||||
|
const result = await state.onAPICall({
|
||||||
|
page: currentPage,
|
||||||
|
pageSize: state.pageSize,
|
||||||
|
search,
|
||||||
|
});
|
||||||
|
|
||||||
|
set(
|
||||||
|
produce((draft) => {
|
||||||
|
if (reset) {
|
||||||
|
draft.boxerData = result.data;
|
||||||
|
draft.page = 0;
|
||||||
|
} else {
|
||||||
|
draft.boxerData = [...(draft.boxerData ?? []), ...result.data];
|
||||||
|
}
|
||||||
|
draft.total = result.total;
|
||||||
|
draft.hasMore = draft.boxerData.length < result.total;
|
||||||
|
draft.isFetching = false;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Boxer fetchData error:', error);
|
||||||
|
set({ isFetching: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fetchMoreOnBottomReached: (target: HTMLDivElement) => {
|
||||||
|
const state = get();
|
||||||
|
|
||||||
|
if (!state.hasMore || state.isFetching) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollPercentage =
|
||||||
|
(target.scrollTop + target.clientHeight) / target.scrollHeight;
|
||||||
|
|
||||||
|
// Load more when scrolled past 80%
|
||||||
|
if (scrollPercentage > 0.8) {
|
||||||
|
state.loadMore();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// State Management
|
||||||
|
getState: (key) => {
|
||||||
|
const current = get();
|
||||||
|
return current?.[key];
|
||||||
|
},
|
||||||
|
hasMore: true,
|
||||||
|
input: '',
|
||||||
|
isFetching: false,
|
||||||
|
loadMore: async () => {
|
||||||
|
const state = get();
|
||||||
|
|
||||||
|
if (!state.hasMore || state.isFetching) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
set(
|
||||||
|
produce((draft) => {
|
||||||
|
draft.page = draft.page + 1;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await state.fetchData(state.search);
|
||||||
|
},
|
||||||
|
// Initial State
|
||||||
|
opened: false,
|
||||||
|
page: 0,
|
||||||
|
|
||||||
|
pageSize: 50,
|
||||||
|
|
||||||
|
search: '',
|
||||||
|
|
||||||
|
selectedOptionIndex: -1,
|
||||||
|
|
||||||
|
setInput: (input: string) => {
|
||||||
|
set({ input });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
setOpened: (opened: boolean) => {
|
||||||
|
set({ opened });
|
||||||
|
},
|
||||||
|
|
||||||
|
setSearch: (search: string) => {
|
||||||
|
set({ search });
|
||||||
|
},
|
||||||
|
|
||||||
|
setSelectedOptionIndex: (index: number) => {
|
||||||
|
set({ selectedOptionIndex: index });
|
||||||
|
},
|
||||||
|
|
||||||
|
setState: (key, value) => {
|
||||||
|
set(
|
||||||
|
produce((state) => {
|
||||||
|
state[key] = value;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
total: 0,
|
||||||
|
}),
|
||||||
|
({
|
||||||
|
data = [],
|
||||||
|
dataSource = 'local',
|
||||||
|
pageSize = 50,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
return {
|
||||||
|
...props,
|
||||||
|
boxerData: data, // Initialize with local data if provided
|
||||||
|
data,
|
||||||
|
dataSource,
|
||||||
|
hasMore: dataSource === 'server',
|
||||||
|
pageSize,
|
||||||
|
total: data.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export { BoxerProvider };
|
||||||
|
export { useBoxerStore };
|
||||||
379
src/Boxer/Boxer.tsx
Normal file
379
src/Boxer/Boxer.tsx
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
import { Combobox, ScrollArea, useVirtualizedCombobox } from '@mantine/core';
|
||||||
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
|
import React, { useCallback, useEffect, useImperativeHandle, useRef } from 'react';
|
||||||
|
|
||||||
|
import type { BoxerItem, BoxerProps, BoxerRef } from './Boxer.types';
|
||||||
|
|
||||||
|
import { BoxerProvider, useBoxerStore } from './Boxer.store';
|
||||||
|
import BoxerTarget from './BoxerTarget';
|
||||||
|
import useBoxerOptions from './hooks/useBoxerOptions';
|
||||||
|
|
||||||
|
const BoxerInner = React.forwardRef<BoxerRef>((_, ref) => {
|
||||||
|
// Component Refs
|
||||||
|
const parentRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const valueRef = useRef<any>(null);
|
||||||
|
const bufferRef = useRef<any>(null);
|
||||||
|
|
||||||
|
// Component store State
|
||||||
|
const {
|
||||||
|
boxerData,
|
||||||
|
clearable,
|
||||||
|
comboBoxProps,
|
||||||
|
dropDownProps,
|
||||||
|
error,
|
||||||
|
fetchData,
|
||||||
|
fetchMoreOnBottomReached,
|
||||||
|
input,
|
||||||
|
isFetching,
|
||||||
|
label,
|
||||||
|
mah,
|
||||||
|
multiSelect,
|
||||||
|
onBufferChange,
|
||||||
|
onChange,
|
||||||
|
opened,
|
||||||
|
openOnClear,
|
||||||
|
placeholder,
|
||||||
|
scrollAreaProps,
|
||||||
|
search,
|
||||||
|
selectedOptionIndex,
|
||||||
|
selectFirst,
|
||||||
|
setInput,
|
||||||
|
setOpened,
|
||||||
|
setSearch,
|
||||||
|
setSelectedOptionIndex,
|
||||||
|
showAll,
|
||||||
|
value,
|
||||||
|
} = useBoxerStore((state) => ({
|
||||||
|
boxerData: state.boxerData,
|
||||||
|
clearable: state.clearable,
|
||||||
|
comboBoxProps: state.comboBoxProps,
|
||||||
|
dropDownProps: state.dropDownProps,
|
||||||
|
error: state.error,
|
||||||
|
fetchData: state.fetchData,
|
||||||
|
fetchMoreOnBottomReached: state.fetchMoreOnBottomReached,
|
||||||
|
input: state.input,
|
||||||
|
isFetching: state.isFetching,
|
||||||
|
label: state.label,
|
||||||
|
mah: state.mah,
|
||||||
|
multiSelect: state.multiSelect,
|
||||||
|
onBufferChange: state.onBufferChange,
|
||||||
|
onChange: state.onChange,
|
||||||
|
opened: state.opened,
|
||||||
|
openOnClear: state.openOnClear,
|
||||||
|
placeholder: state.placeholder,
|
||||||
|
scrollAreaProps: state.scrollAreaProps,
|
||||||
|
search: state.search,
|
||||||
|
selectedOptionIndex: state.selectedOptionIndex,
|
||||||
|
selectFirst: state.selectFirst,
|
||||||
|
setInput: state.setInput,
|
||||||
|
setOpened: state.setOpened,
|
||||||
|
setSearch: state.setSearch,
|
||||||
|
setSelectedOptionIndex: state.setSelectedOptionIndex,
|
||||||
|
showAll: state.showAll,
|
||||||
|
value: state.value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Virtualization setup
|
||||||
|
const count = boxerData.length;
|
||||||
|
const virtualizer = useVirtualizer({
|
||||||
|
count,
|
||||||
|
estimateSize: () => 36,
|
||||||
|
getScrollElement: () => parentRef.current,
|
||||||
|
});
|
||||||
|
const virtualItems = virtualizer.getVirtualItems();
|
||||||
|
|
||||||
|
// Component Callback Functions
|
||||||
|
const onOptionSubmit = useCallback(
|
||||||
|
(indexOrId: number | string) => {
|
||||||
|
const index = typeof indexOrId === 'string' ? parseInt(indexOrId, 10) : indexOrId;
|
||||||
|
const option = boxerData[index];
|
||||||
|
|
||||||
|
if (!option) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (multiSelect) {
|
||||||
|
// Handle multi-select
|
||||||
|
const currentValues = Array.isArray(value) ? value : [];
|
||||||
|
const isSelected = currentValues.includes(option.value);
|
||||||
|
|
||||||
|
const newValues = isSelected
|
||||||
|
? currentValues.filter((v: any) => v !== option.value)
|
||||||
|
: [...currentValues, option.value];
|
||||||
|
|
||||||
|
onChange?.(newValues);
|
||||||
|
|
||||||
|
// Update buffer for multi-select
|
||||||
|
const newBuffer = boxerData.filter((item) => newValues.includes(item.value));
|
||||||
|
onBufferChange?.(newBuffer);
|
||||||
|
} else {
|
||||||
|
// Handle single select
|
||||||
|
onChange?.(option.value);
|
||||||
|
setSearch('');
|
||||||
|
setInput(option.label);
|
||||||
|
valueRef.current = option.value;
|
||||||
|
setOpened(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[boxerData, multiSelect, value, onChange, onBufferChange, setSearch, setInput, setOpened]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onClear = useCallback(() => {
|
||||||
|
if (showAll && selectFirst) {
|
||||||
|
onOptionSubmit(0);
|
||||||
|
} else {
|
||||||
|
if (multiSelect) {
|
||||||
|
onChange?.([] as any);
|
||||||
|
} else {
|
||||||
|
onChange?.(null as any);
|
||||||
|
}
|
||||||
|
setSearch('');
|
||||||
|
setInput('');
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (openOnClear) {
|
||||||
|
setOpened(true);
|
||||||
|
}
|
||||||
|
}, [showAll, selectFirst, multiSelect, onChange, setSearch, setInput, openOnClear, setOpened, onOptionSubmit]);
|
||||||
|
|
||||||
|
// Component Hooks
|
||||||
|
const combobox = useVirtualizedCombobox({
|
||||||
|
getOptionId: (index) => String(index),
|
||||||
|
onDropdownClose: () => {
|
||||||
|
setOpened(false);
|
||||||
|
},
|
||||||
|
onDropdownOpen: () => {
|
||||||
|
if (!value || (multiSelect && (!Array.isArray(value) || value.length === 0))) {
|
||||||
|
setSearch('');
|
||||||
|
setInput('');
|
||||||
|
}
|
||||||
|
combobox.selectFirstOption();
|
||||||
|
},
|
||||||
|
onSelectedOptionSubmit: onOptionSubmit,
|
||||||
|
opened,
|
||||||
|
selectedOptionIndex,
|
||||||
|
setSelectedOptionIndex: (index) => {
|
||||||
|
setSelectedOptionIndex(index);
|
||||||
|
if (index !== -1) {
|
||||||
|
virtualizer.scrollToIndex(index);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
totalOptionsCount: boxerData.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Component variables
|
||||||
|
const { options } = useBoxerOptions({
|
||||||
|
boxerData,
|
||||||
|
multiSelect,
|
||||||
|
onOptionSubmit,
|
||||||
|
value,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Component useEffects
|
||||||
|
useEffect(() => {
|
||||||
|
// Fetch initial data
|
||||||
|
fetchData('', true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Handle search changes
|
||||||
|
const delayDebounceFn = setTimeout(() => {
|
||||||
|
if (search !== undefined && opened) {
|
||||||
|
fetchData(search, true);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
return () => clearTimeout(delayDebounceFn);
|
||||||
|
}, [search, opened]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Sync input with value
|
||||||
|
if (multiSelect) {
|
||||||
|
const labels = boxerData
|
||||||
|
.filter((item) => Array.isArray(value) && value.includes(item.value))
|
||||||
|
.map((item) => item.label)
|
||||||
|
.join(', ');
|
||||||
|
|
||||||
|
// When dropdown is closed, show selected labels. When open, allow searching
|
||||||
|
if (!opened && input !== labels) {
|
||||||
|
setInput(labels);
|
||||||
|
setSearch('');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const label = boxerData.find((item) => item.value === value)?.label;
|
||||||
|
|
||||||
|
// Only sync if we need to update the input to match the value
|
||||||
|
if (input !== label && (search ?? '') === '' && valueRef.current !== value && value) {
|
||||||
|
setInput(label ?? '');
|
||||||
|
} else if (!value && !valueRef.current && (search ?? '') === '') {
|
||||||
|
setSearch('');
|
||||||
|
setInput('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle buffer change
|
||||||
|
if (multiSelect) {
|
||||||
|
const buffer =
|
||||||
|
boxerData.filter((item: BoxerItem) => Array.isArray(value) && value.includes(item.value)) ??
|
||||||
|
[];
|
||||||
|
|
||||||
|
if (JSON.stringify(bufferRef.current) !== JSON.stringify(buffer)) {
|
||||||
|
onBufferChange?.(buffer);
|
||||||
|
bufferRef.current = buffer;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const buffer = boxerData?.find((item: BoxerItem) => item.value === value) ?? null;
|
||||||
|
|
||||||
|
if (bufferRef.current?.value !== buffer?.value) {
|
||||||
|
onBufferChange?.(buffer);
|
||||||
|
bufferRef.current = buffer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [value, boxerData, input, search, multiSelect, opened, onBufferChange, setInput, setSearch]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Select first option automatically
|
||||||
|
if (selectFirst && (boxerData?.length ?? 0) > 0 && !multiSelect) {
|
||||||
|
if (!value) {
|
||||||
|
onOptionSubmit?.(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [selectFirst, boxerData, multiSelect]);
|
||||||
|
|
||||||
|
// Expose ref methods
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
clear: () => {
|
||||||
|
onClear();
|
||||||
|
},
|
||||||
|
close: () => {
|
||||||
|
setOpened(false);
|
||||||
|
combobox.closeDropdown();
|
||||||
|
},
|
||||||
|
focus: () => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
},
|
||||||
|
getValue: () => {
|
||||||
|
return value;
|
||||||
|
},
|
||||||
|
open: () => {
|
||||||
|
setOpened(true);
|
||||||
|
combobox.openDropdown();
|
||||||
|
},
|
||||||
|
setValue: (newValue: any) => {
|
||||||
|
onChange?.(newValue);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Combobox
|
||||||
|
{...comboBoxProps}
|
||||||
|
resetSelectionOnOptionHover={false}
|
||||||
|
store={combobox}
|
||||||
|
withinPortal={true}
|
||||||
|
>
|
||||||
|
<Combobox.Target>
|
||||||
|
<Combobox.EventsTarget>
|
||||||
|
<BoxerTarget
|
||||||
|
clearable={clearable}
|
||||||
|
combobox={combobox}
|
||||||
|
error={error}
|
||||||
|
isFetching={isFetching}
|
||||||
|
label={label}
|
||||||
|
onBlur={() => {
|
||||||
|
if (!value && !multiSelect) {
|
||||||
|
setSearch('');
|
||||||
|
setInput('');
|
||||||
|
combobox.closeDropdown();
|
||||||
|
setOpened(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClear={onClear}
|
||||||
|
onSearch={(event) => {
|
||||||
|
setSearch(event.currentTarget.value);
|
||||||
|
setInput(event.currentTarget.value);
|
||||||
|
setOpened(true);
|
||||||
|
}}
|
||||||
|
placeholder={placeholder}
|
||||||
|
ref={inputRef}
|
||||||
|
search={input}
|
||||||
|
/>
|
||||||
|
</Combobox.EventsTarget>
|
||||||
|
</Combobox.Target>
|
||||||
|
|
||||||
|
<Combobox.Dropdown
|
||||||
|
onKeyDown={() => {
|
||||||
|
if (inputRef.current) {
|
||||||
|
inputRef.current.value = '';
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
p={2}
|
||||||
|
{...dropDownProps}
|
||||||
|
>
|
||||||
|
{opened && options.length > 0 ? (
|
||||||
|
<Combobox.Options>
|
||||||
|
<ScrollArea.Autosize
|
||||||
|
{...scrollAreaProps}
|
||||||
|
mah={mah ?? 200}
|
||||||
|
viewportProps={{
|
||||||
|
...scrollAreaProps?.viewportProps,
|
||||||
|
onScroll: (event) => {
|
||||||
|
fetchMoreOnBottomReached(event.currentTarget as HTMLDivElement);
|
||||||
|
},
|
||||||
|
style: { border: '1px solid gray', borderRadius: 4 },
|
||||||
|
}}
|
||||||
|
viewportRef={parentRef}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: `${virtualizer.getTotalSize()}px`,
|
||||||
|
position: 'relative',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
left: 0,
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
transform: `translateY(${virtualItems[0]?.start ?? 0}px)`,
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{virtualItems.map((virtualRow) => (
|
||||||
|
<div
|
||||||
|
data-index={virtualRow.index}
|
||||||
|
key={virtualRow.key}
|
||||||
|
ref={virtualizer.measureElement}
|
||||||
|
>
|
||||||
|
{options[virtualRow.index]}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ScrollArea.Autosize>
|
||||||
|
</Combobox.Options>
|
||||||
|
) : (
|
||||||
|
<Combobox.Empty>Nothing found</Combobox.Empty>
|
||||||
|
)}
|
||||||
|
</Combobox.Dropdown>
|
||||||
|
</Combobox>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
BoxerInner.displayName = 'BoxerInner';
|
||||||
|
|
||||||
|
const Boxer = React.forwardRef<BoxerRef, BoxerProps>((props, ref) => {
|
||||||
|
return (
|
||||||
|
<BoxerProvider {...props}>
|
||||||
|
<BoxerInner ref={ref} />
|
||||||
|
</BoxerProvider>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Boxer.displayName = 'Boxer';
|
||||||
|
|
||||||
|
export { Boxer };
|
||||||
|
export default Boxer;
|
||||||
109
src/Boxer/Boxer.types.ts
Normal file
109
src/Boxer/Boxer.types.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import type { ComboboxProps, ScrollAreaAutosizeProps, TextInputProps } from '@mantine/core';
|
||||||
|
import type { VirtualizerOptions } from '@tanstack/react-virtual';
|
||||||
|
|
||||||
|
export type BoxerDataSource =
|
||||||
|
| 'local' // Local array data
|
||||||
|
| 'server'; // Server-side with infinite loading
|
||||||
|
|
||||||
|
export type BoxerItem = {
|
||||||
|
[key: string]: any;
|
||||||
|
label: string;
|
||||||
|
value: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface BoxerProps {
|
||||||
|
clearable?: boolean;
|
||||||
|
// Component Props
|
||||||
|
comboBoxProps?: Partial<ComboboxProps>;
|
||||||
|
|
||||||
|
// Data Configuration
|
||||||
|
data?: Array<BoxerItem>;
|
||||||
|
|
||||||
|
dataSource?: BoxerDataSource;
|
||||||
|
disabled?: boolean;
|
||||||
|
dropDownProps?: React.ComponentPropsWithoutRef<'div'>;
|
||||||
|
|
||||||
|
error?: string;
|
||||||
|
// Advanced
|
||||||
|
id?: string;
|
||||||
|
inputProps?: Partial<TextInputProps>;
|
||||||
|
label?: string;
|
||||||
|
leftSection?: React.ReactNode;
|
||||||
|
mah?: number; // Max height for dropdown
|
||||||
|
|
||||||
|
// Component Configuration
|
||||||
|
multiSelect?: boolean;
|
||||||
|
name?: string;
|
||||||
|
// API Configuration (for server-side)
|
||||||
|
onAPICall?: (params: {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
search?: string;
|
||||||
|
}) => Promise<{ data: Array<BoxerItem>; total: number }>;
|
||||||
|
onBufferChange?: (buffer: Array<BoxerItem> | BoxerItem | null) => void;
|
||||||
|
onChange?: (value: any | Array<any>) => void;
|
||||||
|
|
||||||
|
openOnClear?: boolean;
|
||||||
|
|
||||||
|
pageSize?: number;
|
||||||
|
|
||||||
|
// UI Configuration
|
||||||
|
placeholder?: string;
|
||||||
|
// Styling
|
||||||
|
rightSection?: React.ReactNode;
|
||||||
|
scrollAreaProps?: Partial<ScrollAreaAutosizeProps>;
|
||||||
|
searchable?: boolean;
|
||||||
|
|
||||||
|
selectFirst?: boolean;
|
||||||
|
showAll?: boolean;
|
||||||
|
|
||||||
|
// Value Management
|
||||||
|
value?: any | Array<any>;
|
||||||
|
// Virtualization
|
||||||
|
virtualizer?: Partial<VirtualizerOptions<HTMLDivElement, Element>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BoxerRef {
|
||||||
|
clear: () => void;
|
||||||
|
close: () => void;
|
||||||
|
focus: () => void;
|
||||||
|
getValue: () => any | Array<any>;
|
||||||
|
open: () => void;
|
||||||
|
setValue: (value: any | Array<any>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BoxerState {
|
||||||
|
// Data State
|
||||||
|
boxerData: Array<BoxerItem>;
|
||||||
|
fetchData: (search?: string, reset?: boolean) => Promise<void>;
|
||||||
|
fetchMoreOnBottomReached: (target: HTMLDivElement) => void;
|
||||||
|
// State Management
|
||||||
|
getState: <K extends keyof BoxerStoreState>(key: K) => BoxerStoreState[K];
|
||||||
|
hasMore: boolean;
|
||||||
|
|
||||||
|
input: string;
|
||||||
|
isFetching: boolean;
|
||||||
|
// Data Actions
|
||||||
|
loadMore: () => Promise<void>;
|
||||||
|
// Internal State
|
||||||
|
opened: boolean;
|
||||||
|
page: number;
|
||||||
|
|
||||||
|
pageSize: number;
|
||||||
|
search: string;
|
||||||
|
selectedOptionIndex: number;
|
||||||
|
setInput: (input: string) => void;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
setOpened: (opened: boolean) => void;
|
||||||
|
setSearch: (search: string) => void;
|
||||||
|
setSelectedOptionIndex: (index: number) => void;
|
||||||
|
|
||||||
|
setState: <K extends keyof BoxerStoreState>(
|
||||||
|
key: K,
|
||||||
|
value: Partial<BoxerStoreState[K]>
|
||||||
|
) => void;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BoxerStoreState = BoxerProps & BoxerState;
|
||||||
73
src/Boxer/BoxerTarget.tsx
Normal file
73
src/Boxer/BoxerTarget.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import type { ComboboxStore } from '@mantine/core';
|
||||||
|
|
||||||
|
import { ActionIcon, Loader, TextInput } from '@mantine/core';
|
||||||
|
import { IconX } from '@tabler/icons-react';
|
||||||
|
import React, { forwardRef } from 'react';
|
||||||
|
|
||||||
|
interface BoxerTargetProps {
|
||||||
|
clearable?: boolean;
|
||||||
|
combobox: ComboboxStore;
|
||||||
|
disabled?: boolean;
|
||||||
|
error?: string;
|
||||||
|
isFetching?: boolean;
|
||||||
|
label?: string;
|
||||||
|
leftSection?: React.ReactNode;
|
||||||
|
onBlur: () => void;
|
||||||
|
onClear: () => void;
|
||||||
|
onSearch: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
search: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BoxerTarget = forwardRef<HTMLInputElement, BoxerTargetProps>((props, ref) => {
|
||||||
|
const {
|
||||||
|
clearable = true,
|
||||||
|
combobox,
|
||||||
|
disabled,
|
||||||
|
error,
|
||||||
|
isFetching,
|
||||||
|
label,
|
||||||
|
leftSection,
|
||||||
|
onBlur,
|
||||||
|
onClear,
|
||||||
|
onSearch,
|
||||||
|
placeholder,
|
||||||
|
search,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const rightSection = isFetching ? (
|
||||||
|
<Loader size="xs" />
|
||||||
|
) : search && clearable ? (
|
||||||
|
<ActionIcon
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClear();
|
||||||
|
}}
|
||||||
|
size="sm"
|
||||||
|
variant="subtle"
|
||||||
|
>
|
||||||
|
<IconX size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TextInput
|
||||||
|
disabled={disabled}
|
||||||
|
error={error}
|
||||||
|
label={label}
|
||||||
|
leftSection={leftSection}
|
||||||
|
onBlur={onBlur}
|
||||||
|
onChange={onSearch}
|
||||||
|
onClick={() => combobox.openDropdown()}
|
||||||
|
onFocus={() => combobox.openDropdown()}
|
||||||
|
placeholder={placeholder}
|
||||||
|
ref={ref}
|
||||||
|
rightSection={rightSection}
|
||||||
|
value={search}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
BoxerTarget.displayName = 'BoxerTarget';
|
||||||
|
|
||||||
|
export default BoxerTarget;
|
||||||
47
src/Boxer/hooks/useBoxerOptions.tsx
Normal file
47
src/Boxer/hooks/useBoxerOptions.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { Combobox, Checkbox } from '@mantine/core';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import type { BoxerItem } from '../Boxer.types';
|
||||||
|
|
||||||
|
interface UseBoxerOptionsProps {
|
||||||
|
boxerData: Array<BoxerItem>;
|
||||||
|
value?: any | Array<any>;
|
||||||
|
multiSelect?: boolean;
|
||||||
|
onOptionSubmit: (index: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const useBoxerOptions = (props: UseBoxerOptionsProps) => {
|
||||||
|
const { boxerData, value, multiSelect, onOptionSubmit } = props;
|
||||||
|
|
||||||
|
const options = useMemo(() => {
|
||||||
|
return boxerData.map((item, index) => {
|
||||||
|
const isSelected = multiSelect
|
||||||
|
? Array.isArray(value) && value.includes(item.value)
|
||||||
|
: value === item.value;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Combobox.Option
|
||||||
|
key={`${item.value}-${index}`}
|
||||||
|
value={String(index)}
|
||||||
|
active={isSelected}
|
||||||
|
onClick={() => {
|
||||||
|
onOptionSubmit(index);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{multiSelect ? (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<Checkbox checked={isSelected} onChange={() => {}} tabIndex={-1} />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
item.label
|
||||||
|
)}
|
||||||
|
</Combobox.Option>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, [boxerData, value, multiSelect, onOptionSubmit]);
|
||||||
|
|
||||||
|
return { options };
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useBoxerOptions;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export { Boxer, default } from './Boxer';
|
||||||
|
export { BoxerProvider, useBoxerStore } from './Boxer.store';
|
||||||
|
export type {
|
||||||
|
BoxerDataSource,
|
||||||
|
BoxerItem,
|
||||||
|
BoxerProps,
|
||||||
|
BoxerRef,
|
||||||
|
BoxerState,
|
||||||
|
BoxerStoreState,
|
||||||
|
} from './Boxer.types';
|
||||||
|
|||||||
218
src/Boxer/stories/Boxer.stories.tsx
Normal file
218
src/Boxer/stories/Boxer.stories.tsx
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
//@ts-ignore
|
||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import type { BoxerItem } from '../Boxer.types';
|
||||||
|
|
||||||
|
import { Boxer } from '../Boxer';
|
||||||
|
|
||||||
|
const meta: Meta<typeof Boxer> = {
|
||||||
|
component: Boxer,
|
||||||
|
parameters: {
|
||||||
|
layout: 'centered',
|
||||||
|
},
|
||||||
|
tags: ['autodocs'],
|
||||||
|
title: 'Components/Boxer',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof Boxer>;
|
||||||
|
|
||||||
|
// Sample data
|
||||||
|
const sampleData: Array<BoxerItem> = [
|
||||||
|
{ label: 'Apple', value: 'apple' },
|
||||||
|
{ label: 'Banana', value: 'banana' },
|
||||||
|
{ label: 'Cherry', value: 'cherry' },
|
||||||
|
{ label: 'Date', value: 'date' },
|
||||||
|
{ label: 'Elderberry', value: 'elderberry' },
|
||||||
|
{ label: 'Fig', value: 'fig' },
|
||||||
|
{ label: 'Grape', value: 'grape' },
|
||||||
|
{ label: 'Honeydew', value: 'honeydew' },
|
||||||
|
{ label: 'Kiwi', value: 'kiwi' },
|
||||||
|
{ label: 'Lemon', value: 'lemon' },
|
||||||
|
{ label: 'Mango', value: 'mango' },
|
||||||
|
{ label: 'Nectarine', value: 'nectarine' },
|
||||||
|
{ label: 'Orange', value: 'orange' },
|
||||||
|
{ label: 'Papaya', value: 'papaya' },
|
||||||
|
{ label: 'Quince', value: 'quince' },
|
||||||
|
{ label: 'Raspberry', value: 'raspberry' },
|
||||||
|
{ label: 'Strawberry', value: 'strawberry' },
|
||||||
|
{ label: 'Tangerine', value: 'tangerine' },
|
||||||
|
{ label: 'Ugli Fruit', value: 'ugli' },
|
||||||
|
{ label: 'Watermelon', value: 'watermelon' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Local Data Example
|
||||||
|
export const LocalData: Story = {
|
||||||
|
render: () => {
|
||||||
|
const [value, setValue] = useState<null | string>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ width: 300 }}>
|
||||||
|
<Boxer
|
||||||
|
clearable
|
||||||
|
data={sampleData}
|
||||||
|
dataSource="local"
|
||||||
|
label="Favorite Fruit"
|
||||||
|
onChange={setValue}
|
||||||
|
placeholder="Select a fruit"
|
||||||
|
searchable
|
||||||
|
value={value}
|
||||||
|
/>
|
||||||
|
<div style={{ marginTop: 20 }}>
|
||||||
|
<strong>Selected Value:</strong> {value ?? 'None'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Multi-Select Example
|
||||||
|
export const MultiSelect: Story = {
|
||||||
|
render: () => {
|
||||||
|
const [value, setValue] = useState<Array<string>>([]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ width: 300 }}>
|
||||||
|
<Boxer
|
||||||
|
clearable
|
||||||
|
data={sampleData}
|
||||||
|
dataSource="local"
|
||||||
|
label="Favorite Fruits"
|
||||||
|
multiSelect
|
||||||
|
onChange={setValue}
|
||||||
|
placeholder="Select fruits"
|
||||||
|
searchable
|
||||||
|
value={value}
|
||||||
|
/>
|
||||||
|
<div style={{ marginTop: 20 }}>
|
||||||
|
<strong>Selected Values:</strong>{' '}
|
||||||
|
{value.length > 0 ? value.join(', ') : 'None'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Server-Side Example (Simulated)
|
||||||
|
export const ServerSide: Story = {
|
||||||
|
render: () => {
|
||||||
|
const [value, setValue] = useState<null | string>(null);
|
||||||
|
|
||||||
|
// Simulate server-side API call
|
||||||
|
const handleAPICall = async (params: {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
search?: string;
|
||||||
|
}): Promise<{ data: Array<BoxerItem>; total: number }> => {
|
||||||
|
// Simulate network delay
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
|
||||||
|
// Filter based on search
|
||||||
|
let filteredData = [...sampleData];
|
||||||
|
if (params.search) {
|
||||||
|
filteredData = filteredData.filter((item) =>
|
||||||
|
item.label.toLowerCase().includes(params.search!.toLowerCase())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paginate
|
||||||
|
const start = params.page * params.pageSize;
|
||||||
|
const end = start + params.pageSize;
|
||||||
|
const paginatedData = filteredData.slice(start, end);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: paginatedData,
|
||||||
|
total: filteredData.length,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ width: 300 }}>
|
||||||
|
<Boxer
|
||||||
|
clearable
|
||||||
|
dataSource="server"
|
||||||
|
label="Favorite Fruit (Server-side)"
|
||||||
|
onAPICall={handleAPICall}
|
||||||
|
onChange={setValue}
|
||||||
|
pageSize={10}
|
||||||
|
placeholder="Select a fruit (Server-side)"
|
||||||
|
searchable
|
||||||
|
value={value}
|
||||||
|
/>
|
||||||
|
<div style={{ marginTop: 20 }}>
|
||||||
|
<strong>Selected Value:</strong> {value ?? 'None'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Select First Example
|
||||||
|
export const SelectFirst: Story = {
|
||||||
|
render: () => {
|
||||||
|
const [value, setValue] = useState<null | string>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ width: 300 }}>
|
||||||
|
<Boxer
|
||||||
|
clearable
|
||||||
|
data={sampleData}
|
||||||
|
dataSource="local"
|
||||||
|
label="Auto-select First"
|
||||||
|
onChange={setValue}
|
||||||
|
placeholder="Select a fruit"
|
||||||
|
searchable
|
||||||
|
selectFirst
|
||||||
|
value={value}
|
||||||
|
/>
|
||||||
|
<div style={{ marginTop: 20 }}>
|
||||||
|
<strong>Selected Value:</strong> {value ?? 'None'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// With Error
|
||||||
|
export const WithError: Story = {
|
||||||
|
render: () => {
|
||||||
|
const [value, setValue] = useState<null | string>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ width: 300 }}>
|
||||||
|
<Boxer
|
||||||
|
clearable
|
||||||
|
data={sampleData}
|
||||||
|
dataSource="local"
|
||||||
|
error="Please select a fruit"
|
||||||
|
label="With Error"
|
||||||
|
onChange={setValue}
|
||||||
|
placeholder="Select a fruit"
|
||||||
|
searchable
|
||||||
|
value={value}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Disabled
|
||||||
|
export const Disabled: Story = {
|
||||||
|
render: () => {
|
||||||
|
return (
|
||||||
|
<div style={{ width: 300 }}>
|
||||||
|
<Boxer
|
||||||
|
data={sampleData}
|
||||||
|
dataSource="local"
|
||||||
|
disabled
|
||||||
|
label="Disabled"
|
||||||
|
onChange={() => {}}
|
||||||
|
placeholder="Select a fruit"
|
||||||
|
value="apple"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,3 +1,10 @@
|
|||||||
|
The plan and requirements:
|
||||||
|
Auto complete lookup with server side lookup support and infinite loading.
|
||||||
|
It must also have local array lookup and autocomplete.
|
||||||
|
When a users starts typing, it must start autocomplete list.
|
||||||
|
Exiting selected item must always be the first on the list and populated from the input in case the options does not exist anymore, it must not beak existing data.
|
||||||
|
|
||||||
|
|
||||||
- [ ] Auto Complete
|
- [ ] Auto Complete
|
||||||
- [ ] Multi Select
|
- [ ] Multi Select
|
||||||
- [ ] Virtualize
|
- [ ] Virtualize
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ export interface FormerProps<T extends FieldValues = any> {
|
|||||||
id?: string;
|
id?: string;
|
||||||
keepOpen?: boolean;
|
keepOpen?: boolean;
|
||||||
layout?: {
|
layout?: {
|
||||||
|
buttonArea?: "bottom" | "none" | "top";
|
||||||
buttonAreaGroupProps?: GroupProps;
|
buttonAreaGroupProps?: GroupProps;
|
||||||
buttonOnTop?: boolean;
|
|
||||||
closeButtonProps?: ButtonProps;
|
closeButtonProps?: ButtonProps;
|
||||||
closeButtonTitle?: React.ReactNode;
|
closeButtonTitle?: React.ReactNode;
|
||||||
renderBottom?: FormerSectionRender<T>;
|
renderBottom?: FormerSectionRender<T>;
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { useFormerStore } from './Former.store';
|
|||||||
import { FormerButtonArea } from './FormerButtonArea';
|
import { FormerButtonArea } from './FormerButtonArea';
|
||||||
|
|
||||||
export const FormerLayoutBottom = () => {
|
export const FormerLayoutBottom = () => {
|
||||||
const { buttonOnTop, getState, opened, renderBottom } = useFormerStore((state) => ({
|
const { buttonArea, getState, opened, renderBottom } = useFormerStore((state) => ({
|
||||||
buttonOnTop: state.layout?.buttonOnTop,
|
buttonArea: state.layout?.buttonArea,
|
||||||
getState: state.getState,
|
getState: state.getState,
|
||||||
opened: state.opened,
|
opened: state.opened,
|
||||||
renderBottom: state.layout?.renderBottom,
|
renderBottom: state.layout?.renderBottom,
|
||||||
@@ -19,5 +19,5 @@ export const FormerLayoutBottom = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return buttonOnTop ? <></> : <FormerButtonArea />;
|
return buttonArea === "bottom" ? <FormerButtonArea /> : <></>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { useFormerStore } from './Former.store';
|
|||||||
import { FormerButtonArea } from './FormerButtonArea';
|
import { FormerButtonArea } from './FormerButtonArea';
|
||||||
|
|
||||||
export const FormerLayoutTop = () => {
|
export const FormerLayoutTop = () => {
|
||||||
const { buttonOnTop, getState, opened, renderTop } = useFormerStore((state) => ({
|
const { buttonArea, getState, opened, renderTop } = useFormerStore((state) => ({
|
||||||
buttonOnTop: state.layout?.buttonOnTop,
|
buttonArea: state.layout?.buttonArea,
|
||||||
getState: state.getState,
|
getState: state.getState,
|
||||||
opened: state.opened,
|
opened: state.opened,
|
||||||
renderTop: state.layout?.renderTop,
|
renderTop: state.layout?.renderTop,
|
||||||
@@ -18,5 +18,5 @@ export const FormerLayoutTop = () => {
|
|||||||
getState
|
getState
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return buttonOnTop ? <FormerButtonArea /> : <></>;
|
return buttonArea === "top" ? <FormerButtonArea /> : <></>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ export const FormTest = () => {
|
|||||||
url: '',
|
url: '',
|
||||||
});
|
});
|
||||||
const [layout, setLayout] = useState({
|
const [layout, setLayout] = useState({
|
||||||
|
buttonArea: "bottom",
|
||||||
buttonAreaGroupProps: { justify: 'center' },
|
buttonAreaGroupProps: { justify: 'center' },
|
||||||
buttonOnTop: false,
|
|
||||||
title: 'Custom Former Title',
|
title: 'Custom Former Title',
|
||||||
} as FormerProps['layout']);
|
} as FormerProps['layout']);
|
||||||
|
|
||||||
@@ -63,11 +63,13 @@ export const FormTest = () => {
|
|||||||
label="Disable HTML Form"
|
label="Disable HTML Form"
|
||||||
onChange={(event) => setDisableHTML(event.currentTarget.checked)}
|
onChange={(event) => setDisableHTML(event.currentTarget.checked)}
|
||||||
/>
|
/>
|
||||||
<Switch
|
<Select
|
||||||
checked={layout?.buttonOnTop ?? false}
|
data={['top', 'bottom', 'none']}
|
||||||
label="Button On Top"
|
|
||||||
onChange={(event) => setLayout({ ...layout, buttonOnTop: event.currentTarget.checked })}
|
onChange={(value) => setLayout({ ...layout, buttonArea: value as 'bottom' | 'none' | 'top' })}
|
||||||
|
value={layout?.buttonArea}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Switch
|
<Switch
|
||||||
checked={apiOptions.type === 'api'}
|
checked={apiOptions.type === 'api'}
|
||||||
label="Use API"
|
label="Use API"
|
||||||
|
|||||||
38
src/FormerControllers/Inputs/InlineWapper.module.css
Normal file
38
src/FormerControllers/Inputs/InlineWapper.module.css
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
.prompt {
|
||||||
|
border-top-right-radius: 0px;
|
||||||
|
border-bottom-right-radius: 0px;
|
||||||
|
border: 1px solid #ced4da;
|
||||||
|
border-right: 0px;
|
||||||
|
|
||||||
|
@mixin dark {
|
||||||
|
border: 1px solid #373a40;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
border: 1px solid #ced4da;
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
&:not([data-promptArea]) {
|
||||||
|
border-top-left-radius: 0px;
|
||||||
|
border-bottom-left-radius: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-promptArea] {
|
||||||
|
border-top-right-radius: 0px;
|
||||||
|
border-bottom-right-radius: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-disabled] {
|
||||||
|
color: black;
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin dark {
|
||||||
|
border: 1px solid #373a40;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.root {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
176
src/FormerControllers/Inputs/InlineWrapper.tsx
Normal file
176
src/FormerControllers/Inputs/InlineWrapper.tsx
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Center,
|
||||||
|
Flex,
|
||||||
|
type FlexProps,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Title,
|
||||||
|
type TitleProps,
|
||||||
|
Tooltip,
|
||||||
|
useMantineColorScheme,
|
||||||
|
} from '@mantine/core'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
import classes from './InlineWapper.module.css'
|
||||||
|
|
||||||
|
interface InlineWrapperCallbackProps extends Partial<InlineWrapperPropsOnly> {
|
||||||
|
classNames: React.CSSProperties
|
||||||
|
dataCssProps?: Record<string, any>
|
||||||
|
size: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InlineWrapperProps extends InlineWrapperPropsOnly{
|
||||||
|
children?: ((props: InlineWrapperCallbackProps) => ReactNode) | ReactNode
|
||||||
|
}
|
||||||
|
interface InlineWrapperPropsOnly {
|
||||||
|
error?: ReactNode | string
|
||||||
|
flexProps?: FlexProps
|
||||||
|
label: ReactNode | string
|
||||||
|
labelProps?: TitleProps
|
||||||
|
promptArea?: ((props: InlineWrapperCallbackProps) => ReactNode) | ReactNode
|
||||||
|
promptWidth?: FlexProps['w']
|
||||||
|
required?: boolean
|
||||||
|
rightSection?: ((props: InlineWrapperCallbackProps) => ReactNode) | ReactNode
|
||||||
|
styles?: React.CSSProperties
|
||||||
|
tooltip?: string
|
||||||
|
value?: any
|
||||||
|
}
|
||||||
|
|
||||||
|
function InlineWrapper(props: InlineWrapperProps) {
|
||||||
|
return (
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Flex
|
||||||
|
gap={0}
|
||||||
|
h={undefined}
|
||||||
|
m={0}
|
||||||
|
mb={0}
|
||||||
|
p={0}
|
||||||
|
w={undefined}
|
||||||
|
wrap='nowrap'
|
||||||
|
{...props.flexProps}
|
||||||
|
bg={'var(--input-background)'}
|
||||||
|
>
|
||||||
|
{props.promptWidth && props.promptWidth !== 0 ? <Prompt {...props} /> : null}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: 0,
|
||||||
|
flex: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{typeof props.children === 'function' ? (
|
||||||
|
props.children({ ...props, classNames: classes, size: 'xs' })
|
||||||
|
) : typeof props.children === 'object' && React.isValidElement(props.children) ? (
|
||||||
|
<props.children.type classNames={classes} size='xs' {...(typeof props.children.props === "object" ? props.children.props : {})} />
|
||||||
|
) : (
|
||||||
|
props.children
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!props.rightSection ? undefined : typeof props.rightSection === 'function' ? (
|
||||||
|
props.rightSection({
|
||||||
|
...props,
|
||||||
|
classNames: classes,
|
||||||
|
size: 'xs',
|
||||||
|
})
|
||||||
|
) : typeof props.rightSection === 'object' && React.isValidElement(props.rightSection) ? (
|
||||||
|
<props.rightSection.type classNames={classes} size='xs' {...(typeof props.rightSection.props === "object" ? props.rightSection.props : {})} />
|
||||||
|
) : (
|
||||||
|
props.rightSection
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
{/* <ErrorComponent {...props} /> */}
|
||||||
|
</Stack>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValueEmpty(inputValue: any) {
|
||||||
|
if (inputValue === null || inputValue === undefined) return true
|
||||||
|
if (typeof inputValue === 'number') {
|
||||||
|
if (inputValue === 0) return false
|
||||||
|
} else if (typeof inputValue === 'string' || inputValue === '') {
|
||||||
|
return inputValue.trim() === ''
|
||||||
|
} else if (inputValue instanceof File) {
|
||||||
|
return inputValue.size === 0
|
||||||
|
} else if (inputValue.target) {
|
||||||
|
return isValueEmpty(inputValue.target?.value)
|
||||||
|
} else if (inputValue.constructor?.name === 'Date') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function Prompt(props: Partial<InlineWrapperProps>) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{props.tooltip ? (
|
||||||
|
<Tooltip label={props.tooltip}>
|
||||||
|
<PromptDetail {...props} />
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<PromptDetail {...props} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PromptDetail(props: Partial<InlineWrapperProps>) {
|
||||||
|
const colors = useColors(props)
|
||||||
|
return props.promptArea ? (
|
||||||
|
<Box maw={props.promptWidth} w={'100%'}>
|
||||||
|
{!props.promptArea ? undefined : typeof props.promptArea === 'function' ? (
|
||||||
|
props.promptArea({
|
||||||
|
...props,
|
||||||
|
classNames: classes,
|
||||||
|
dataCssProps: { 'data-promptArea': true },
|
||||||
|
size: 'xs',
|
||||||
|
})
|
||||||
|
) : typeof props.rightSection === 'object' && React.isValidElement(props.promptArea) ? (
|
||||||
|
<props.promptArea.type
|
||||||
|
classNames={classes}
|
||||||
|
data-promptArea='true'
|
||||||
|
size='xs'
|
||||||
|
{...(typeof props.promptArea?.props === "object" ? props.promptArea.props : {})}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
props.promptArea
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Paper
|
||||||
|
bg={colors.paperColor}
|
||||||
|
className={classes.prompt}
|
||||||
|
px='md'
|
||||||
|
w={props.promptWidth}
|
||||||
|
withBorder
|
||||||
|
>
|
||||||
|
<Center h='100%' style={{ justifyContent: 'start' }} w='100%'>
|
||||||
|
<Title c={colors.titleColor} fz='xs' order={6} {...props.labelProps}>
|
||||||
|
{props.label}
|
||||||
|
{props.required && isValueEmpty(props.value) && <span style={{ color: 'red' }}>*</span>}
|
||||||
|
</Title>
|
||||||
|
</Center>
|
||||||
|
</Paper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function useColors(props: Partial<InlineWrapperProps>) {
|
||||||
|
const { colorScheme } = useMantineColorScheme()
|
||||||
|
|
||||||
|
let titleColor = colorScheme === 'dark' ? 'dark.0' : 'gray.8'
|
||||||
|
let paperColor = colorScheme === 'dark' ? 'dark.7' : 'gray.1'
|
||||||
|
|
||||||
|
if (props.required && isValueEmpty(props.value)) {
|
||||||
|
paperColor = colorScheme === 'dark' ? '#413012e7' : 'yellow.1'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.error) {
|
||||||
|
paperColor = colorScheme === 'dark' ? 'red.7' : 'red.0'
|
||||||
|
titleColor = colorScheme === 'dark' ? 'red.0' : 'red.9'
|
||||||
|
}
|
||||||
|
return { paperColor, titleColor }
|
||||||
|
}
|
||||||
|
|
||||||
|
export { InlineWrapper }
|
||||||
|
export type { InlineWrapperProps }
|
||||||
50
src/FormerControllers/stories/Formers.goapi.stories.tsx
Normal file
50
src/FormerControllers/stories/Formers.goapi.stories.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
//@ts-nocheck
|
||||||
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||||
|
|
||||||
|
import { Stack } from '@mantine/core';
|
||||||
|
import { fn } from 'storybook/test';
|
||||||
|
|
||||||
|
import { Former, NativeSelectCtrl, TextInputCtrl } from '../../lib';
|
||||||
|
import { InlineWrapper } from '../Inputs/InlineWrapper';
|
||||||
|
import NumberInputCtrl from '../Inputs/NumberInputCtrl';
|
||||||
|
|
||||||
|
const Renderable = () => {
|
||||||
|
return (
|
||||||
|
<Former>
|
||||||
|
<Stack h="100%" mih="400px" miw="400px" w="100%">
|
||||||
|
<TextInputCtrl label="Test" name="test" />
|
||||||
|
<NumberInputCtrl label="AgeTest" name="age" />
|
||||||
|
<InlineWrapper label="Select One" promptWidth={200}>
|
||||||
|
<NativeSelectCtrl data={["One","Two","Three"]} name="option1"/>
|
||||||
|
</InlineWrapper>
|
||||||
|
</Stack>
|
||||||
|
</Former>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
// Use `fn` to spy on the onClick arg, which will appear in the actions panel once invoked: https://storybook.js.org/docs/essentials/actions#action-args
|
||||||
|
args: { onClick: fn() },
|
||||||
|
// More on argTypes: https://storybook.js.org/docs/api/argtypes
|
||||||
|
argTypes: {
|
||||||
|
backgroundColor: { control: 'color' },
|
||||||
|
},
|
||||||
|
component: Renderable,
|
||||||
|
parameters: {
|
||||||
|
// Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout
|
||||||
|
//layout: 'centered',
|
||||||
|
},
|
||||||
|
// This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs
|
||||||
|
tags: ['autodocs'],
|
||||||
|
title: 'Former/Controls Basic',
|
||||||
|
} satisfies Meta<typeof Renderable>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
// More on writing stories with args: https://storybook.js.org/docs/writing-stories/args
|
||||||
|
export const BasicExample: Story = {
|
||||||
|
args: {
|
||||||
|
label: 'Test',
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -25,9 +25,10 @@ export const Computer = React.memo(() => {
|
|||||||
scrollToRowKey,
|
scrollToRowKey,
|
||||||
searchStr,
|
searchStr,
|
||||||
selectedRowKey,
|
selectedRowKey,
|
||||||
|
selectFirstRowOnMount,
|
||||||
setState,
|
setState,
|
||||||
setStateFN,
|
setStateFN,
|
||||||
values,
|
values
|
||||||
} = useGridlerStore((s) => ({
|
} = useGridlerStore((s) => ({
|
||||||
_glideref: s._glideref,
|
_glideref: s._glideref,
|
||||||
_gridSelectionRows: s._gridSelectionRows,
|
_gridSelectionRows: s._gridSelectionRows,
|
||||||
@@ -44,6 +45,7 @@ export const Computer = React.memo(() => {
|
|||||||
scrollToRowKey: s.scrollToRowKey,
|
scrollToRowKey: s.scrollToRowKey,
|
||||||
searchStr: s.searchStr,
|
searchStr: s.searchStr,
|
||||||
selectedRowKey: s.selectedRowKey,
|
selectedRowKey: s.selectedRowKey,
|
||||||
|
selectFirstRowOnMount:s.selectFirstRowOnMount,
|
||||||
setState: s.setState,
|
setState: s.setState,
|
||||||
setStateFN: s.setStateFN,
|
setStateFN: s.setStateFN,
|
||||||
uniqueid: s.uniqueid,
|
uniqueid: s.uniqueid,
|
||||||
@@ -268,18 +270,25 @@ export const Computer = React.memo(() => {
|
|||||||
//Logic to select first row on mount
|
//Logic to select first row on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const _events = getState('_events');
|
const _events = getState('_events');
|
||||||
|
|
||||||
const loadPage = () => {
|
const loadPage = () => {
|
||||||
const selectFirstRowOnMount = getState('selectFirstRowOnMount');
|
const selectFirstRowOnMount = getState('selectFirstRowOnMount');
|
||||||
|
const ready = getState('ready');
|
||||||
|
|
||||||
if (ready && selectFirstRowOnMount) {
|
if (ready && selectFirstRowOnMount) {
|
||||||
|
|
||||||
const scrollToRowKey = getState('scrollToRowKey');
|
const scrollToRowKey = getState('scrollToRowKey');
|
||||||
|
|
||||||
|
|
||||||
if (scrollToRowKey && scrollToRowKey >= 0) {
|
if (scrollToRowKey && scrollToRowKey >= 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const keyField = getState('keyField') ?? 'id';
|
const keyField = getState('keyField') ?? 'id';
|
||||||
const page_data = getState('_page_data');
|
const page_data = getState('_page_data');
|
||||||
|
|
||||||
const firstBuffer = page_data?.[0]?.[0];
|
const firstBuffer = page_data?.[0]?.[0];
|
||||||
const firstRow = firstBuffer?.[keyField];
|
const firstRow = firstBuffer?.[keyField] ?? -1;
|
||||||
const currentValues = getState('values') ?? [];
|
const currentValues = getState('values') ?? [];
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -290,6 +299,7 @@ export const Computer = React.memo(() => {
|
|||||||
) {
|
) {
|
||||||
const values = [firstBuffer, ...(currentValues as Array<Record<string, unknown>>)];
|
const values = [firstBuffer, ...(currentValues as Array<Record<string, unknown>>)];
|
||||||
|
|
||||||
|
|
||||||
const onChange = getState('onChange');
|
const onChange = getState('onChange');
|
||||||
//console.log('Selecting first row:', firstRow, firstBuffer, values);
|
//console.log('Selecting first row:', firstRow, firstBuffer, values);
|
||||||
if (onChange) {
|
if (onChange) {
|
||||||
@@ -308,7 +318,7 @@ export const Computer = React.memo(() => {
|
|||||||
return () => {
|
return () => {
|
||||||
_events?.removeEventListener('loadPage', loadPage);
|
_events?.removeEventListener('loadPage', loadPage);
|
||||||
};
|
};
|
||||||
}, [ready]);
|
}, [ready, selectFirstRowOnMount]);
|
||||||
|
|
||||||
/// logic to apply the selected row.
|
/// logic to apply the selected row.
|
||||||
// useEffect(() => {
|
// useEffect(() => {
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ export interface GridlerState {
|
|||||||
hasLocalData: boolean;
|
hasLocalData: boolean;
|
||||||
isEmpty: boolean;
|
isEmpty: boolean;
|
||||||
|
|
||||||
|
isValuesInPages: () => boolean
|
||||||
loadingData?: boolean;
|
loadingData?: boolean;
|
||||||
loadPage: (page: number, clearMode?: 'all' | 'page') => Promise<void>;
|
loadPage: (page: number, clearMode?: 'all' | 'page') => Promise<void>;
|
||||||
mounted: boolean;
|
mounted: boolean;
|
||||||
@@ -180,6 +181,7 @@ export interface GridlerState {
|
|||||||
onHeaderClicked: (colIndex: number, event: HeaderClickedEventArgs) => void;
|
onHeaderClicked: (colIndex: number, event: HeaderClickedEventArgs) => void;
|
||||||
onHeaderMenuClick: (col: number, screenPosition: Rectangle) => void;
|
onHeaderMenuClick: (col: number, screenPosition: Rectangle) => void;
|
||||||
onItemHovered: (args: GridMouseEventArgs) => void;
|
onItemHovered: (args: GridMouseEventArgs) => void;
|
||||||
|
|
||||||
onVisibleRegionChanged: (
|
onVisibleRegionChanged: (
|
||||||
r: Rectangle,
|
r: Rectangle,
|
||||||
tx: number,
|
tx: number,
|
||||||
@@ -189,18 +191,18 @@ export interface GridlerState {
|
|||||||
freezeRegions?: readonly Rectangle[];
|
freezeRegions?: readonly Rectangle[];
|
||||||
selected?: Item;
|
selected?: Item;
|
||||||
}
|
}
|
||||||
|
|
||||||
) => void;
|
) => void;
|
||||||
|
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
|
|
||||||
ready: boolean;
|
ready: boolean;
|
||||||
refreshCells: (fromRow?: number, toRow?: number, col?: number) => void;
|
refreshCells: (fromRow?: number, toRow?: number, col?: number) => void;
|
||||||
reload?: () => Promise<void>;
|
|
||||||
|
|
||||||
|
reload?: () => Promise<void>;
|
||||||
renderColumns?: GridlerColumns;
|
renderColumns?: GridlerColumns;
|
||||||
setState: <K extends keyof GridlerStoreState>(
|
setState: <K extends keyof GridlerStoreState>(
|
||||||
key: K,
|
key: K,
|
||||||
value: Partial<GridlerStoreState[K]>
|
value: GridlerStoreState[K]
|
||||||
) => void;
|
) => void;
|
||||||
setStateFN: <K extends keyof GridlerStoreState>(
|
setStateFN: <K extends keyof GridlerStoreState>(
|
||||||
key: K,
|
key: K,
|
||||||
@@ -378,6 +380,31 @@ const { Provider, useStore: useGridlerStore } = createSyncStore<GridlerStoreStat
|
|||||||
},
|
},
|
||||||
hasLocalData: false,
|
hasLocalData: false,
|
||||||
isEmpty: true,
|
isEmpty: true,
|
||||||
|
isValuesInPages: () => {
|
||||||
|
const state = get();
|
||||||
|
if (state.values && Object.keys(state._page_data).length > 0) {
|
||||||
|
let found = false;
|
||||||
|
for (const page in state._page_data) {
|
||||||
|
const pageData = state._page_data[Number(page)];
|
||||||
|
for (const row of pageData) {
|
||||||
|
const keyField = state.keyField ?? 'id';
|
||||||
|
const rowKey = row?.[keyField];
|
||||||
|
if (rowKey !== undefined) {
|
||||||
|
const match = state.values.find((v) => String(v?.[keyField]) === String(rowKey));
|
||||||
|
if (match) {
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (found) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
},
|
||||||
keyField: 'id',
|
keyField: 'id',
|
||||||
loadPage: async (pPage: number, clearMode?: 'all' | 'page') => {
|
loadPage: async (pPage: number, clearMode?: 'all' | 'page') => {
|
||||||
const state = get();
|
const state = get();
|
||||||
@@ -511,6 +538,7 @@ const { Provider, useStore: useGridlerStore } = createSyncStore<GridlerStoreStat
|
|||||||
return { ...renderCols, [fromItem?.id]: to, [toItem?.id]: from };
|
return { ...renderCols, [fromItem?.id]: to, [toItem?.id]: from };
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
onColumnProposeMove: (startIndex: number, endIndex: number) => {
|
onColumnProposeMove: (startIndex: number, endIndex: number) => {
|
||||||
const s = get();
|
const s = get();
|
||||||
const fromItem = s.renderColumns?.[startIndex];
|
const fromItem = s.renderColumns?.[startIndex];
|
||||||
@@ -520,7 +548,6 @@ const { Provider, useStore: useGridlerStore } = createSyncStore<GridlerStoreStat
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
onColumnResize: (
|
onColumnResize: (
|
||||||
column: GridColumn,
|
column: GridColumn,
|
||||||
newSize: number,
|
newSize: number,
|
||||||
@@ -922,7 +949,7 @@ const { Provider, useStore: useGridlerStore } = createSyncStore<GridlerStoreStat
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
total_rows: 1000,
|
total_rows: 1000,
|
||||||
uniqueid: getUUID(),
|
uniqueid: getUUID()
|
||||||
}),
|
}),
|
||||||
(props) => {
|
(props) => {
|
||||||
const [setState, getState] = props.useStore((s) => [s.setState, s.getState]);
|
const [setState, getState] = props.useStore((s) => [s.setState, s.getState]);
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ function _GlidlerAPIAdaptorForGoLangv2<T = unknown>(props: GlidlerAPIAdaptorForG
|
|||||||
const searchStr = getState('searchStr');
|
const searchStr = getState('searchStr');
|
||||||
const searchFields = getState('searchFields');
|
const searchFields = getState('searchFields');
|
||||||
const _active_requests = getState('_active_requests');
|
const _active_requests = getState('_active_requests');
|
||||||
|
const keyField = getState('keyField');
|
||||||
setState('loadingData', true);
|
setState('loadingData', true);
|
||||||
try {
|
try {
|
||||||
//console.log('APIAdaptorGoLangv2', { _active_requests, index, pageSize, props });
|
//console.log('APIAdaptorGoLangv2', { _active_requests, index, pageSize, props });
|
||||||
@@ -113,6 +114,12 @@ function _GlidlerAPIAdaptorForGoLangv2<T = unknown>(props: GlidlerAPIAdaptorForG
|
|||||||
col_ids?.push(props.hotfields.join(','));
|
col_ids?.push(props.hotfields.join(','));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (keyField) {
|
||||||
|
if (!col_ids.includes(keyField)) {
|
||||||
|
col_ids.push(keyField);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (col_ids && col_ids.length > 0) {
|
if (col_ids && col_ids.length > 0) {
|
||||||
ops.push({
|
ops.push({
|
||||||
type: 'select-fields',
|
type: 'select-fields',
|
||||||
@@ -223,7 +230,7 @@ function _GlidlerAPIAdaptorForGoLangv2<T = unknown>(props: GlidlerAPIAdaptorForG
|
|||||||
ops.push({
|
ops.push({
|
||||||
name: 'sql_filter',
|
name: 'sql_filter',
|
||||||
type: 'custom-sql-w',
|
type: 'custom-sql-w',
|
||||||
value: props.filter,
|
value: `(${props.filter})`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,9 +272,13 @@ function _GlidlerAPIAdaptorForGoLangv2<T = unknown>(props: GlidlerAPIAdaptorForG
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setState('useAPIQuery', useAPIQuery);
|
setState('useAPIQuery', useAPIQuery);
|
||||||
setState('askAPIRowNumber', askAPIRowNumber);
|
setState('askAPIRowNumber', askAPIRowNumber);
|
||||||
|
const isValuesInPages = getState('isValuesInPages');
|
||||||
|
|
||||||
const _refresh = getState('_refresh');
|
const _refresh = getState('_refresh');
|
||||||
|
if (!isValuesInPages) {
|
||||||
|
setState('values', []);
|
||||||
|
}
|
||||||
|
|
||||||
//Reset the loaded pages to new rules
|
//Reset the loaded pages to new rules
|
||||||
_refresh?.().then(() => {
|
_refresh?.().then(() => {
|
||||||
const onChange = getState('onChange');
|
const onChange = getState('onChange');
|
||||||
@@ -282,6 +293,8 @@ function _GlidlerAPIAdaptorForGoLangv2<T = unknown>(props: GlidlerAPIAdaptorForG
|
|||||||
return <></>;
|
return <></>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
//The computer component does not need to be recalculated on every render, so we use React.memo to prevent unnecessary re-renders.
|
//The computer component does not need to be recalculated on every render, so we use React.memo to prevent unnecessary re-renders.
|
||||||
export const GlidlerAPIAdaptorForGoLangv2 = React.memo(_GlidlerAPIAdaptorForGoLangv2);
|
export const GlidlerAPIAdaptorForGoLangv2 = React.memo(_GlidlerAPIAdaptorForGoLangv2);
|
||||||
|
|
||||||
|
|||||||
@@ -59,8 +59,8 @@ export function GlidlerFormAdaptor(props: {
|
|||||||
storeState: GridlerState,
|
storeState: GridlerState,
|
||||||
row?: Record<string, unknown>,
|
row?: Record<string, unknown>,
|
||||||
col?: GridlerColumn,
|
col?: GridlerColumn,
|
||||||
defaultItems?: Array<unknown>
|
defaultItems?: MantineBetterMenuInstanceItem[]
|
||||||
) => {
|
): MantineBetterMenuInstanceItem[] => {
|
||||||
//console.log('GlidlerFormInterface getMenuItems', id);
|
//console.log('GlidlerFormInterface getMenuItems', id);
|
||||||
|
|
||||||
if (id === 'header-menu') {
|
if (id === 'header-menu') {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export * from './Boxer';
|
||||||
export * from './Former';
|
export * from './Former';
|
||||||
export * from './FormerControllers';
|
export * from './FormerControllers';
|
||||||
export * from './Gridler';
|
export * from './Gridler';
|
||||||
|
|||||||
@@ -17,14 +17,14 @@ Object.defineProperty(window, 'matchMedia', {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Mock ResizeObserver
|
// Mock ResizeObserver
|
||||||
global.ResizeObserver = vi.fn().mockImplementation(() => ({
|
globalThis.ResizeObserver = vi.fn().mockImplementation(() => ({
|
||||||
disconnect: vi.fn(),
|
disconnect: vi.fn(),
|
||||||
observe: vi.fn(),
|
observe: vi.fn(),
|
||||||
unobserve: vi.fn(),
|
unobserve: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Mock IntersectionObserver
|
// Mock IntersectionObserver
|
||||||
global.IntersectionObserver = vi.fn().mockImplementation(() => ({
|
globalThis.IntersectionObserver = vi.fn().mockImplementation(() => ({
|
||||||
disconnect: vi.fn(),
|
disconnect: vi.fn(),
|
||||||
observe: vi.fn(),
|
observe: vi.fn(),
|
||||||
unobserve: vi.fn(),
|
unobserve: vi.fn(),
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
/* Bundler mode */
|
/* Bundler mode */
|
||||||
"moduleResolution": "Node",
|
"moduleResolution": "bundler",
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"verbatimModuleSyntax": true,
|
"verbatimModuleSyntax": true,
|
||||||
@@ -37,5 +37,6 @@
|
|||||||
"src",
|
"src",
|
||||||
"lib.ts",
|
"lib.ts",
|
||||||
"*.d.ts",
|
"*.d.ts",
|
||||||
]
|
],
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -23,5 +23,5 @@
|
|||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"vite.config.ts"
|
"vite.config.ts"
|
||||||
]
|
],
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user