(hooks) => {
{
id: "data-table-disabled_row_action",
width: 80,
- // eslint-disable-next-line react/prop-types
Cell: ({ row: { original: obj, }, }) =>
obj?.permissions?.edit ? (
<>
@@ -53,7 +49,6 @@ export const createUseRowDisabledHook = (hocProps) => (hooks) => {
};
export const rowSelectHooks = [
- useRowSelect,
(hooks) => {
hooks.visibleColumns.push((columns, { instance: { isRowSelectable, }, }) => [
{
@@ -108,7 +103,6 @@ export const rowSelectHooks = [
];
export const rowExpandHooks = [
- useExpanded,
(hooks) => {
hooks.visibleColumns.push((columns) => [
{
diff --git a/src/components/table/useQueryParamsTable.jsx b/src/components/table/useQueryParamsTable.jsx
index e9de71b..e7e4951 100644
--- a/src/components/table/useQueryParamsTable.jsx
+++ b/src/components/table/useQueryParamsTable.jsx
@@ -1,6 +1,7 @@
import React from "react";
import { useNavigate, useLocation } from "react-router-dom";
-import { useAsyncDebounce } from "react-table";
+
+import useAsyncDebounce from "../../hooks/useAsyncDebounce";
import {
serializeFilterParams,
@@ -65,18 +66,25 @@ function useQueryParamsTable() {
const tableStateReducer = React.useCallback(
(newState, action) => {
+ const filters = newState?.filters || newState?.columnFilters || [];
+ const sortBy = newState?.sortBy || newState?.sorting || [];
+ const pageIndex =
+ typeof action?.pageIndex === "number"
+ ? action.pageIndex
+ : newState?.pageIndex || newState?.pagination?.pageIndex || 0;
+
switch (action.type) {
case "gotoPage":
setParams((currParams) => ({
...currParams,
- page: action.pageIndex + 1,
+ page: pageIndex + 1,
}));
break;
case "setFilter":
- onTableFilterDebounced(newState.filters);
+ onTableFilterDebounced(filters);
break;
case "toggleSortBy":
- onTableSortDebounced(newState.sortBy);
+ onTableSortDebounced(sortBy);
break;
default:
break;
diff --git a/src/components/table/utils.jsx b/src/components/table/utils.jsx
index c7c0b3f..9580ee3 100644
--- a/src/components/table/utils.jsx
+++ b/src/components/table/utils.jsx
@@ -1,21 +1,9 @@
-import {
- useSortBy,
- useFilters,
- usePagination,
- useColumnOrder,
- useFlexLayout
-} from "react-table";
-
import { rowSelectHooks, rowExpandHooks } from "./hooks";
// gotta maintain the order of these plugins
function makeTableArgs(config) {
- const args = [useColumnOrder];
- if (config?.enableFlexLayout) args.push(useFlexLayout);
- if (config?.enableFilters) args.push(useFilters);
- if (config?.enableSortBy) args.push(useSortBy);
+ const args = [];
if (config?.enableExpanded) args.push(...rowExpandHooks);
- args.push(usePagination);
if (config?.enableSelection) args.push(...rowSelectHooks);
return [...args, ...config.customHooks];
}
diff --git a/src/components/tabs/RouterTabs.jsx b/src/components/tabs/RouterTabs.jsx
index d139dad..8c70e02 100644
--- a/src/components/tabs/RouterTabs.jsx
+++ b/src/components/tabs/RouterTabs.jsx
@@ -1,5 +1,4 @@
import React from "react";
-import PropTypes from "prop-types";
import classnames from "classnames";
import { Nav } from "reactstrap";
@@ -21,11 +20,10 @@ import useRouterTabs from "./useRouterTabs";
* ```
*
*/
-function RouterTabs(props) {
+function RouterTabs({ routes, className = undefined, overflow = false, redirect = true, children = null, extraNavComponent = null, ...rest }) {
// props
- const { routes, className, overflow, redirect, children, extraNavComponent, ...rest } = props;
- const navClasses = classnames("nav-tabs", className);
+ const navClasses = classnames("nav-tabs", { "overflow-auto": overflow, }, className);
// call hook
const { renderNavItems, renderRoutes, } = useRouterTabs({
@@ -38,35 +36,11 @@ function RouterTabs(props) {
{renderRoutes()}
>
);
}
-RouterTabs.propTypes = {
- routes: PropTypes.arrayOf(
- PropTypes.shape({
- key: PropTypes.string.isRequired,
- location: PropTypes.string.isRequired,
- Title: PropTypes.oneOfType([PropTypes.func, PropTypes.object]).isRequired,
- Component: PropTypes.oneOfType([PropTypes.func, PropTypes.object])
- .isRequired,
- })
- ).isRequired,
- redirect: PropTypes.bool,
- overflow: PropTypes.bool,
- className: PropTypes.string,
- children: PropTypes.node,
- extraNavComponent: PropTypes.node,
-};
-
-RouterTabs.defaultProps = {
- redirect: true,
- overflow: false,
- className: undefined,
- children: null,
- extraNavComponent: null,
-};
-
export default RouterTabs;
diff --git a/src/components/tabs/Tabs.jsx b/src/components/tabs/Tabs.jsx
index a613c61..5ae0dba 100644
--- a/src/components/tabs/Tabs.jsx
+++ b/src/components/tabs/Tabs.jsx
@@ -1,6 +1,5 @@
import React from "react";
-import PropTypes from "prop-types";
import { TabContent, TabPane, Nav, NavItem, NavLink } from "reactstrap";
import classnames from "classnames";
@@ -8,10 +7,7 @@ import classnames from "classnames";
* @type {component}
* @param props
*/
-function Tabs(props) {
- const { tabTitles, renderables, defaultTab, overflow, className, ...rest } =
- props;
-
+function Tabs({ tabTitles, renderables, defaultTab = 0, overflow = false, className = undefined, ...rest }) {
const [activeTab, setActiveTab] = React.useState(defaultTab);
const toggle = (tab) => {
@@ -20,7 +16,7 @@ function Tabs(props) {
}
};
- const navClasses = classnames("nav-tabs", className);
+ const navClasses = classnames("nav-tabs", { "overflow-auto": overflow, }, className);
return (
<>
@@ -49,20 +45,4 @@ function Tabs(props) {
);
}
-Tabs.defaultProps = {
- defaultTab: 0,
- overflow: false,
- className: undefined,
-};
-
-Tabs.propTypes = {
- tabTitles: PropTypes.arrayOf(
- PropTypes.oneOfType([PropTypes.string, PropTypes.node, PropTypes.object])
- ).isRequired,
- renderables: PropTypes.arrayOf(PropTypes.func).isRequired,
- defaultTab: PropTypes.number,
- overflow: PropTypes.bool,
- className: PropTypes.string,
-};
-
export default Tabs;
diff --git a/src/components/tabs/useRouterTabs.jsx b/src/components/tabs/useRouterTabs.jsx
index cc18b39..be611af 100644
--- a/src/components/tabs/useRouterTabs.jsx
+++ b/src/components/tabs/useRouterTabs.jsx
@@ -3,7 +3,7 @@ Based on: https://codesandbox.io/embed/6brgz
*/
import React from "react";
-import { NavLink, NavItem } from "reactstrap";
+import { NavItem } from "reactstrap";
import { NavLink as RRNavLink, Route, Navigate, Routes, useLocation, useResolvedPath } from "react-router-dom";
import FallbackLoading from "../misc/FallbackLoading";
@@ -12,16 +12,16 @@ import FallbackLoading from "../misc/FallbackLoading";
export default function useRouterTabs({ routes, redirect, }) {
const hLocation = useLocation();
const resolvedPath = useResolvedPath("");
- const activeKeyRef = React.useRef(null);
+ const localRoutes = React.useMemo(() => routes || [], [routes]);
const activeKey = React.useMemo(() => {
- const a = routes?.find(r => {
+ const a = localRoutes.find(r => {
const loc1 = hLocation.pathname;
const loc2 = `${resolvedPath.pathname}/${r.location}`.replaceAll("//", "");
return loc1.includes(loc2);
});
return a?.key;
- }, [routes, hLocation, resolvedPath]);
+ }, [localRoutes, hLocation.pathname, resolvedPath.pathname]);
/**
* Renders the reactstrap `NavItem`s. Note that reactstrap's `NavLink`
@@ -33,22 +33,18 @@ export default function useRouterTabs({ routes, redirect, }) {
*/
const renderNavItems = React.useCallback(
() =>
- routes.map(({ key, Title, location, }) => (
+ localRoutes.map(({ key, Title, location, }) => (
- `nav-link${isActive ? " active" : ""}`}
to={location}
- style={({isActive,}) => {
- if (isActive)
- activeKeyRef.current = key;
- return undefined;
- }}
+ end
>
-
+
)),
- [routes]
+ [localRoutes]
);
/**
@@ -57,7 +53,7 @@ export default function useRouterTabs({ routes, redirect, }) {
const renderRoutes = React.useCallback(
() =>
- {routes.map(({ key, Component, location, }) =>
+ {localRoutes.map(({ key, Component, location, }) =>
)}
- {redirect && routes.length &&
+ {redirect && localRoutes.length &&
}
+ element={}
/>}
,
- [routes, redirect]
+ [localRoutes, redirect]
);
return { activeKey, renderNavItems, renderRoutes, };
diff --git a/src/components/text/SlicedText.jsx b/src/components/text/SlicedText.jsx
index 875ad54..034a17a 100644
--- a/src/components/text/SlicedText.jsx
+++ b/src/components/text/SlicedText.jsx
@@ -1,13 +1,12 @@
import React from "react";
-import PropTypes from "prop-types";
import { UncontrolledTooltip } from "reactstrap";
import { nanoid } from "nanoid";
import CopyToClipboardButton from "../buttons/CopyToClipboardButton";
-function SlicedText({ value, id, cutoffLength, ...rest }) {
+function SlicedText({ value, id = undefined, cutoffLength = 15, ...rest }) {
// vars
- const btnId = id || `copybtn-${nanoid(4)}`;
+ const btnId = React.useMemo(() => id || `copybtn-${nanoid(4)}`, [id]);
return (
@@ -26,15 +25,4 @@ function SlicedText({ value, id, cutoffLength, ...rest }) {
);
}
-SlicedText.propTypes = {
- value: PropTypes.string.isRequired,
- id: PropTypes.string,
- cutoffLength: PropTypes.number,
-};
-
-SlicedText.defaultProps = {
- id: undefined,
- cutoffLength: 15,
-};
-
export default React.memo(SlicedText, (pp, np) => pp.id === np.id);
diff --git a/src/components/time/DateHoverable.jsx b/src/components/time/DateHoverable.jsx
index 99df407..7437a4b 100644
--- a/src/components/time/DateHoverable.jsx
+++ b/src/components/time/DateHoverable.jsx
@@ -1,5 +1,4 @@
import React from "react";
-import PropTypes from "prop-types";
import classnames from "classnames";
import {
ListGroup,
@@ -15,9 +14,7 @@ import {
} from "date-fns";
import { nanoid } from "nanoid";
-
-function DateHoverable(props) {
- const { id, value, className, noHover, ago, showAgo, format: formatProp, showFormat, ...rest } = props;
+function DateHoverable({ id = undefined, value, className = undefined, noHover = false, ago = false, showAgo = false, format: formatProp = "PPpppp", showFormat = "p PP", ...rest }) {
const [utcVal, userTz, userTzVal] = React.useMemo(() => {
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
@@ -26,7 +23,7 @@ function DateHoverable(props) {
return [format(dUTC, showFormat), tz, format(d, showFormat)];
}, [value, showFormat]);
- const timeId = id || `date-${nanoid(4)}`;
+ const timeId = React.useMemo(() => id || `date-${nanoid(4)}`, [id]);
return (
<>
@@ -66,25 +63,4 @@ function DateHoverable(props) {
);
}
-DateHoverable.propTypes = {
- value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
- className: PropTypes.string,
- format: PropTypes.string,
- id: PropTypes.string,
- noHover: PropTypes.bool,
- showFormat: PropTypes.string,
- ago: PropTypes.bool,
- showAgo: PropTypes.bool,
-};
-
-DateHoverable.defaultProps = {
- ago: false,
- className: undefined,
- format: "PPpppp",
- id: undefined,
- noHover: false,
- showAgo: false,
- showFormat: "p PP",
-};
-
export default React.memo(DateHoverable);
diff --git a/src/components/time/ElasticTimePicker.jsx b/src/components/time/ElasticTimePicker.jsx
index 522f049..a6df800 100644
--- a/src/components/time/ElasticTimePicker.jsx
+++ b/src/components/time/ElasticTimePicker.jsx
@@ -1,5 +1,4 @@
import React from "react";
-import PropTypes from "prop-types";
import { Button, ButtonGroup } from "reactstrap";
import { IoInfinite } from "react-icons/io5";
import { sub } from "date-fns";
@@ -35,9 +34,7 @@ function intervalToTime(ti) {
* />
* ```
*/
-function ElasticTimePicker(props) {
- const { onChange, size, defaultSelected, intervals, showInfinity, ...rest } =
- props;
+function ElasticTimePicker({ onChange, size = "sm", defaultSelected = "24h", intervals = Object.keys(TIME_INTERVALS), showInfinity = false, ...rest }) {
// state
const [selected, setSelected] = React.useState(defaultSelected);
@@ -86,19 +83,4 @@ function ElasticTimePicker(props) {
);
}
-ElasticTimePicker.defaultProps = {
- size: "sm",
- defaultSelected: "24h",
- intervals: Object.keys(TIME_INTERVALS),
- showInfinity: false,
-};
-
-ElasticTimePicker.propTypes = {
- onChange: PropTypes.func.isRequired,
- defaultSelected: PropTypes.string,
- size: PropTypes.string,
- intervals: PropTypes.arrayOf(PropTypes.string),
- showInfinity: PropTypes.bool,
-};
-
export default ElasticTimePicker;
diff --git a/src/hooks/index.js b/src/hooks/index.js
index fb33145..a67fc26 100644
--- a/src/hooks/index.js
+++ b/src/hooks/index.js
@@ -1,3 +1,4 @@
export { default as useAxiosComponentLoader } from "./useAxiosComponentLoader";
export { default as useFuzzySearch } from "./useFuzzySearch";
-export { default as useDebounceInput } from "./useDebounceInput";
\ No newline at end of file
+export { default as useDebounceInput } from "./useDebounceInput";
+export { default as useAsyncDebounce } from "./useAsyncDebounce";
\ No newline at end of file
diff --git a/src/hooks/useAsyncDebounce.jsx b/src/hooks/useAsyncDebounce.jsx
new file mode 100644
index 0000000..c953414
--- /dev/null
+++ b/src/hooks/useAsyncDebounce.jsx
@@ -0,0 +1,57 @@
+import React from "react";
+
+/**
+ * Debounces a callback and returns a promise that resolves with the latest call result.
+ * This mirrors the behavior commonly relied on from react-table's useAsyncDebounce helper.
+ */
+export default function useAsyncDebounce(defaultFn, defaultWait = 0) {
+ const fnRef = React.useRef(defaultFn);
+ const waitRef = React.useRef(defaultWait);
+ const debounceRef = React.useRef({});
+
+ React.useEffect(() => {
+ fnRef.current = defaultFn;
+ }, [defaultFn]);
+
+ React.useEffect(() => {
+ waitRef.current = defaultWait;
+ }, [defaultWait]);
+
+ React.useEffect(
+ () => () => {
+ if (debounceRef.current.timeout) {
+ clearTimeout(debounceRef.current.timeout);
+ }
+ },
+ []
+ );
+
+ return React.useCallback((...args) => {
+ if (!debounceRef.current.promise) {
+ debounceRef.current.promise = new Promise((resolve, reject) => {
+ debounceRef.current.resolve = resolve;
+ debounceRef.current.reject = reject;
+ });
+ }
+
+ if (debounceRef.current.timeout) {
+ clearTimeout(debounceRef.current.timeout);
+ }
+
+ debounceRef.current.timeout = setTimeout(async () => {
+ delete debounceRef.current.timeout;
+
+ try {
+ debounceRef.current.resolve(await fnRef.current(...args));
+ } catch (error) {
+ debounceRef.current.reject(error);
+ } finally {
+ delete debounceRef.current.promise;
+ delete debounceRef.current.resolve;
+ delete debounceRef.current.reject;
+ }
+ }, waitRef.current);
+
+ return debounceRef.current.promise;
+ }, []);
+}
diff --git a/src/hooks/useAxiosComponentLoader.jsx b/src/hooks/useAxiosComponentLoader.jsx
index 0d55c43..41b5346 100644
--- a/src/hooks/useAxiosComponentLoader.jsx
+++ b/src/hooks/useAxiosComponentLoader.jsx
@@ -1,15 +1,57 @@
import React from "react";
-import useAxios from "axios-hooks";
+import axios from "axios";
import Loader from "../components/containers/Loader";
const noop = (x) => x;
function useAxiosComponentLoader(axiosOptions, modifier = noop) {
- const obj = React.useMemo(() => axiosOptions, [axiosOptions ]);
+ const requestConfig = React.useMemo(
+ () => (typeof axiosOptions === "string" ? { url: axiosOptions, } : axiosOptions),
+ [axiosOptions]
+ );
+ const [requestKey, setRequestKey] = React.useState(0);
+ const [data, setData] = React.useState(null);
+ const [loading, setLoading] = React.useState(false);
+ const [error, setError] = React.useState(null);
+
+ React.useEffect(() => {
+ const controller = new AbortController();
+ let isMounted = true;
+
+ setLoading(true);
+ setError(null);
+
+ axios({
+ ...requestConfig,
+ signal: controller.signal,
+ })
+ .then((response) => {
+ if (!isMounted)
+ return;
+
+ setData(response.data);
+ })
+ .catch((requestError) => {
+ if (!isMounted || requestError?.code === "ERR_CANCELED")
+ return;
+
+ setError(requestError);
+ })
+ .finally(() => {
+ if (isMounted)
+ setLoading(false);
+ });
+
+ return () => {
+ isMounted = false;
+ controller.abort();
+ };
+ }, [requestConfig, requestKey]);
- // API
- const [{ data, loading, error, }, refetch] = useAxios(obj);
+ const refetch = React.useCallback(() => {
+ setRequestKey((currentKey) => currentKey + 1);
+ }, []);
// memo
const MyLoader = React.useMemo(
diff --git a/src/hooks/useDebounceInput.jsx b/src/hooks/useDebounceInput.jsx
index a73a4f0..e14ba77 100644
--- a/src/hooks/useDebounceInput.jsx
+++ b/src/hooks/useDebounceInput.jsx
@@ -9,6 +9,5 @@ export default function useDebounceInput(inputValue, delay, setFunction) {
setFunction(inputValue);
}, delay);
return () => clearTimeout(timer);
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [inputValue]);
+ }, [inputValue, delay, setFunction]);
}
\ No newline at end of file
diff --git a/src/hooks/useFuzzySearch.jsx b/src/hooks/useFuzzySearch.jsx
index b28b955..04d1896 100644
--- a/src/hooks/useFuzzySearch.jsx
+++ b/src/hooks/useFuzzySearch.jsx
@@ -1,6 +1,7 @@
import React from "react";
import { matchSorter } from "match-sorter";
-import { useAsyncDebounce } from "react-table";
+
+import useAsyncDebounce from "./useAsyncDebounce";
/**
* React hook for fuzzy searching text among list of objects.
@@ -12,8 +13,9 @@ export default function useFuzzySearch({ dataList, searchableKeys, }) {
const [searchInput, setSearchInput] = React.useState("");
// filter function
- const updateItems = () =>
- setItems(() =>
+ const updateItems = React.useCallback(
+ () =>
+ setItems(() =>
searchInput
? // matched items
matchSorter(dataList, searchInput, {
@@ -21,7 +23,9 @@ export default function useFuzzySearch({ dataList, searchableKeys, }) {
})
: // reset items list
dataList
- );
+ ),
+ [dataList, searchableKeys, searchInput]
+ );
// debounced callback
const debouncedSearch = useAsyncDebounce(updateItems, 500);
@@ -38,8 +42,7 @@ export default function useFuzzySearch({ dataList, searchableKeys, }) {
// side effect that updates item if input `dataList` is changed
React.useEffect(() => {
updateItems();
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [dataList]);
+ }, [updateItems]);
return [searchInput, onInputChange, items];
}
diff --git a/src/stores/index.js b/src/stores/index.js
index 555433a..cd4a648 100644
--- a/src/stores/index.js
+++ b/src/stores/index.js
@@ -1,6 +1,6 @@
import useTimePickerStore from "./useTimePickerStore";
import useToastr from "./useToastr";
-const { addToast, } = useToastr.getState();
+const addToast = (...args) => useToastr.getState().addToast(...args);
export { useTimePickerStore, useToastr, addToast };
diff --git a/src/stores/useTimePickerStore.jsx b/src/stores/useTimePickerStore.jsx
index 00632f2..de22e3c 100644
--- a/src/stores/useTimePickerStore.jsx
+++ b/src/stores/useTimePickerStore.jsx
@@ -1,6 +1,6 @@
-import create from "zustand";
-import { persist } from "zustand/middleware";
+import { create } from "zustand";
+import { persist, createJSONStorage } from "zustand/middleware";
// constants
const DEFAULT_RANGE_DATEFORMAT_MAP = {
@@ -31,7 +31,7 @@ const useTimePickerStore = create(
}),
{
name: "gbUI-useTimePickerStore", // unique name
- getStorage: () => localStorage,
+ storage: createJSONStorage(() => localStorage),
}
)
);
diff --git a/src/stores/useToastr.jsx b/src/stores/useToastr.jsx
index e94ee00..6a0beb0 100644
--- a/src/stores/useToastr.jsx
+++ b/src/stores/useToastr.jsx
@@ -1,6 +1,8 @@
-import create from "zustand";
+import { create } from "zustand";
import { nanoid } from "nanoid";
+const toastTimeouts = new Map();
+
// store
const useToastr = create((set, get) => ({
toasts: [],
@@ -13,15 +15,26 @@ const useToastr = create((set, get) => ({
showToggle,
timeout,
};
- setTimeout(() => get().removeToast(payload.id), timeout);
+ const timeoutId = setTimeout(() => {
+ get().removeToast(payload.id);
+ toastTimeouts.delete(payload.id);
+ }, timeout);
+ toastTimeouts.set(payload.id, timeoutId);
set(({ toasts, }) => ({
toasts: [...toasts, payload],
}));
},
- removeToast: (id) =>
+ removeToast: (id) => {
+ const timeoutId = toastTimeouts.get(id);
+ if (timeoutId) {
+ clearTimeout(timeoutId);
+ toastTimeouts.delete(id);
+ }
+
set(({ toasts, }) => ({
toasts: toasts.filter((t) => t.id !== id),
- })),
+ }));
+ },
}));
export default useToastr;
diff --git a/src/styles/_variables.scss b/src/styles/_variables.scss
index cfa890c..ba19b82 100644
--- a/src/styles/_variables.scss
+++ b/src/styles/_variables.scss
@@ -270,8 +270,8 @@ $chat-nav-bubble-color: linear-gradient(
#fff 100%
);
-@import "~bootstrap/scss/functions";
-@import "~bootstrap/scss/variables";
+@import "bootstrap/scss/functions";
+@import "bootstrap/scss/variables";
// merge colors (Workaround BUG Bootstrap 5.1 https://github.com/twbs/bootstrap/issues/34756)
// bug should be fixed after 5.2.0 so we removed the code after the following line
diff --git a/src/styles/root.scss b/src/styles/root.scss
index abae01f..f729d2f 100644
--- a/src/styles/root.scss
+++ b/src/styles/root.scss
@@ -4,7 +4,7 @@
// theme
@import "variables";
-@import "~bootstrap/scss/bootstrap";
+@import "bootstrap/scss/bootstrap";
@import url("https://fonts.googleapis.com/css?family=Montserrat:200,300,400,500,600|Roboto:700");
// local files
diff --git a/tests/visual/table.visual.spec.js b/tests/visual/table.visual.spec.js
new file mode 100644
index 0000000..1b30e11
--- /dev/null
+++ b/tests/visual/table.visual.spec.js
@@ -0,0 +1,197 @@
+import { test, expect } from "@playwright/test";
+
+async function freezeExternalNetwork(page, baseURL) {
+ await page.route("**/*", async (route) => {
+ const url = route.request().url();
+
+ if (
+ url.startsWith(baseURL) ||
+ url.startsWith("data:") ||
+ url.startsWith("blob:")
+ ) {
+ await route.continue();
+ return;
+ }
+
+ await route.abort();
+ });
+}
+
+async function openTable(page, baseURL) {
+ await freezeExternalNetwork(page, baseURL);
+ await page.goto("/#/table");
+ await expect(page.getByRole("heading", { name: "Table", })).toBeVisible();
+ await expect(page.getByTestId("table-visual-interactive")).toBeVisible();
+}
+
+test.describe("table visual states", () => {
+ test("default state", async ({ page, baseURL, browserName, isMobile, }) => {
+ test.skip(browserName !== "chromium");
+ test.skip(isMobile);
+
+ await openTable(page, baseURL);
+
+ await expect(page.getByTestId("table-visual-interactive")).toHaveScreenshot(
+ "table-default-desktop.png",
+ {
+ animations: "disabled",
+ caret: "hide",
+ }
+ );
+ });
+
+ test("sorted ascending", async ({ page, baseURL, browserName, isMobile, }) => {
+ test.skip(browserName !== "chromium");
+ test.skip(isMobile);
+
+ await openTable(page, baseURL);
+
+ const section = page.getByTestId("table-visual-interactive");
+ await section.getByRole("button", { name: /Title/i, }).click();
+
+ await expect(section).toHaveScreenshot("table-sorted-asc-desktop.png", {
+ animations: "disabled",
+ caret: "hide",
+ });
+ });
+
+ test("sorted descending", async ({ page, baseURL, browserName, isMobile, }) => {
+ test.skip(browserName !== "chromium");
+ test.skip(isMobile);
+
+ await openTable(page, baseURL);
+
+ const section = page.getByTestId("table-visual-interactive");
+ const titleHeader = section.getByRole("button", { name: /Title/i, });
+
+ await titleHeader.click();
+ await titleHeader.click();
+
+ await expect(section).toHaveScreenshot("table-sorted-desc-desktop.png", {
+ animations: "disabled",
+ caret: "hide",
+ });
+ });
+
+ test("active filters", async ({ page, baseURL, browserName, isMobile, }) => {
+ test.skip(browserName !== "chromium");
+ test.skip(isMobile);
+
+ await openTable(page, baseURL);
+
+ const section = page.getByTestId("table-visual-interactive");
+ const titleFilter = section.getByPlaceholder("Search keyword..").first();
+
+ await titleFilter.fill("Alpha Watch");
+ await titleFilter.press("Enter");
+
+ await expect(section).toHaveScreenshot("table-filtered-desktop.png", {
+ animations: "disabled",
+ caret: "hide",
+ });
+ });
+
+ test("expanded row", async ({ page, baseURL, browserName, isMobile, }) => {
+ test.skip(browserName !== "chromium");
+ test.skip(isMobile);
+
+ await openTable(page, baseURL);
+
+ const section = page.getByTestId("table-visual-interactive");
+
+ await section.locator("tbody tr [role='button']").first().click();
+ await expect(section.getByTestId("expanded-row-1")).toBeVisible();
+
+ await expect(section).toHaveScreenshot("table-expanded-desktop.png", {
+ animations: "disabled",
+ caret: "hide",
+ });
+ });
+
+ test("selected rows", async ({ page, baseURL, browserName, isMobile, }) => {
+ test.skip(browserName !== "chromium");
+ test.skip(isMobile);
+
+ await openTable(page, baseURL);
+
+ const section = page.getByTestId("table-visual-interactive");
+ const alphaRow = section.locator("tbody tr", { hasText: "Alpha Watch", });
+ const cipherRow = section.locator("tbody tr", { hasText: "Cipher Sweep", });
+
+ await alphaRow.locator("input[type='checkbox']").check();
+ await cipherRow.locator("input[type='checkbox']").check();
+ await expect(page.getByTestId("table-visual-selected-count")).toHaveText("2 selected");
+ await page.mouse.move(0, 0);
+ await page.evaluate(() => {
+ if (document.activeElement instanceof HTMLElement)
+ document.activeElement.blur();
+ });
+
+ await expect(section).toHaveScreenshot("table-selected-desktop.png", {
+ animations: "disabled",
+ caret: "hide",
+ maxDiffPixels: 700,
+ });
+ });
+
+ test("disabled row styling", async ({ page, baseURL, browserName, isMobile, }) => {
+ test.skip(browserName !== "chromium");
+ test.skip(isMobile);
+
+ await openTable(page, baseURL);
+
+ await expect(page.getByTestId("table-visual-disabled")).toHaveScreenshot(
+ "table-disabled-row-desktop.png",
+ {
+ animations: "disabled",
+ caret: "hide",
+ }
+ );
+ });
+
+ test("empty state", async ({ page, baseURL, browserName, isMobile, }) => {
+ test.skip(browserName !== "chromium");
+ test.skip(isMobile);
+
+ await openTable(page, baseURL);
+
+ await expect(page.getByTestId("table-visual-empty")).toHaveScreenshot(
+ "table-empty-state-desktop.png",
+ {
+ animations: "disabled",
+ caret: "hide",
+ }
+ );
+ });
+
+ test("paginator ellipsis", async ({ page, baseURL, browserName, isMobile, }) => {
+ test.skip(browserName !== "chromium");
+ test.skip(isMobile);
+
+ await openTable(page, baseURL);
+
+ await expect(
+ page.getByTestId("table-visual-interactive").locator(".table-paginator")
+ ).toHaveScreenshot("table-paginator-ellipsis-desktop.png", {
+ animations: "disabled",
+ caret: "hide",
+ });
+ });
+});
+
+test.describe("table mobile visual state", () => {
+ test("default state on mobile", async ({ page, baseURL, browserName, isMobile, }) => {
+ test.skip(browserName !== "chromium");
+ test.skip(!isMobile);
+
+ await openTable(page, baseURL);
+
+ await expect(page.getByTestId("table-visual-interactive")).toHaveScreenshot(
+ "table-default-mobile.png",
+ {
+ animations: "disabled",
+ caret: "hide",
+ }
+ );
+ });
+});
diff --git a/tests/visual/table.visual.spec.js-snapshots/table-default-desktop.png b/tests/visual/table.visual.spec.js-snapshots/table-default-desktop.png
new file mode 100644
index 0000000..5309690
Binary files /dev/null and b/tests/visual/table.visual.spec.js-snapshots/table-default-desktop.png differ
diff --git a/tests/visual/table.visual.spec.js-snapshots/table-default-mobile.png b/tests/visual/table.visual.spec.js-snapshots/table-default-mobile.png
new file mode 100644
index 0000000..23252bb
Binary files /dev/null and b/tests/visual/table.visual.spec.js-snapshots/table-default-mobile.png differ
diff --git a/tests/visual/table.visual.spec.js-snapshots/table-disabled-row-desktop.png b/tests/visual/table.visual.spec.js-snapshots/table-disabled-row-desktop.png
new file mode 100644
index 0000000..a5030cd
Binary files /dev/null and b/tests/visual/table.visual.spec.js-snapshots/table-disabled-row-desktop.png differ
diff --git a/tests/visual/table.visual.spec.js-snapshots/table-empty-state-desktop.png b/tests/visual/table.visual.spec.js-snapshots/table-empty-state-desktop.png
new file mode 100644
index 0000000..8f77b07
Binary files /dev/null and b/tests/visual/table.visual.spec.js-snapshots/table-empty-state-desktop.png differ
diff --git a/tests/visual/table.visual.spec.js-snapshots/table-expanded-desktop.png b/tests/visual/table.visual.spec.js-snapshots/table-expanded-desktop.png
new file mode 100644
index 0000000..02c5db8
Binary files /dev/null and b/tests/visual/table.visual.spec.js-snapshots/table-expanded-desktop.png differ
diff --git a/tests/visual/table.visual.spec.js-snapshots/table-filtered-desktop.png b/tests/visual/table.visual.spec.js-snapshots/table-filtered-desktop.png
new file mode 100644
index 0000000..b72089e
Binary files /dev/null and b/tests/visual/table.visual.spec.js-snapshots/table-filtered-desktop.png differ
diff --git a/tests/visual/table.visual.spec.js-snapshots/table-paginator-ellipsis-desktop.png b/tests/visual/table.visual.spec.js-snapshots/table-paginator-ellipsis-desktop.png
new file mode 100644
index 0000000..df24fea
Binary files /dev/null and b/tests/visual/table.visual.spec.js-snapshots/table-paginator-ellipsis-desktop.png differ
diff --git a/tests/visual/table.visual.spec.js-snapshots/table-selected-desktop.png b/tests/visual/table.visual.spec.js-snapshots/table-selected-desktop.png
new file mode 100644
index 0000000..b2a7d17
Binary files /dev/null and b/tests/visual/table.visual.spec.js-snapshots/table-selected-desktop.png differ
diff --git a/tests/visual/table.visual.spec.js-snapshots/table-sorted-asc-desktop.png b/tests/visual/table.visual.spec.js-snapshots/table-sorted-asc-desktop.png
new file mode 100644
index 0000000..bf657aa
Binary files /dev/null and b/tests/visual/table.visual.spec.js-snapshots/table-sorted-asc-desktop.png differ
diff --git a/tests/visual/table.visual.spec.js-snapshots/table-sorted-desc-desktop.png b/tests/visual/table.visual.spec.js-snapshots/table-sorted-desc-desktop.png
new file mode 100644
index 0000000..c385937
Binary files /dev/null and b/tests/visual/table.visual.spec.js-snapshots/table-sorted-desc-desktop.png differ
diff --git a/vite.config.mjs b/vite.config.mjs
new file mode 100644
index 0000000..5cea21c
--- /dev/null
+++ b/vite.config.mjs
@@ -0,0 +1,40 @@
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+import { readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import path from "node:path";
+
+const pkg = JSON.parse(
+ readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "package.json"), "utf8")
+);
+
+const externals = [
+ ...Object.keys(pkg.peerDependencies || {}),
+ ...Object.keys(pkg.dependencies || {}),
+];
+
+const isExternal = (id) =>
+ externals.some((dep) => id === dep || id.startsWith(`${dep}/`)) ||
+ /^node:/.test(id) ||
+ id === "react/jsx-runtime" ||
+ id === "react/jsx-dev-runtime";
+
+export default defineConfig({
+ plugins: [react()],
+ build: {
+ lib: {
+ entry: "src/index.js",
+ formats: ["es", "cjs"],
+ fileName: (format) => (format === "cjs" ? "index.js" : "index.modern.js"),
+ },
+ rollupOptions: {
+ external: isExternal,
+ output: {
+ preserveModules: false,
+ },
+ },
+ sourcemap: true,
+ minify: false,
+ emptyOutDir: true,
+ },
+});
diff --git a/vitest.config.mjs b/vitest.config.mjs
new file mode 100644
index 0000000..16d16eb
--- /dev/null
+++ b/vitest.config.mjs
@@ -0,0 +1,12 @@
+import { defineConfig } from "vitest/config";
+import react from "@vitejs/plugin-react";
+
+export default defineConfig({
+ plugins: [react({ include: /\.(jsx?|tsx?)$/ })],
+ test: {
+ environment: "jsdom",
+ globals: true,
+ setupFiles: ["./vitest.setup.mjs"],
+ include: ["src/**/*.{test,spec}.{js,jsx}"],
+ },
+});
diff --git a/vitest.setup.mjs b/vitest.setup.mjs
new file mode 100644
index 0000000..d0de870
--- /dev/null
+++ b/vitest.setup.mjs
@@ -0,0 +1 @@
+import "@testing-library/jest-dom";