Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
### Added
* [#755](https://github.com/shlinkio/shlink-web-component/issues/755) Add support for `any-value-query-param` and `valueless-query-param` redirect conditions when using Shlink >=4.5.0.
* [#756](https://github.com/shlinkio/shlink-web-component/issues/756) Add support for desktop device types on device redirect conditions, when using Shlink >=4.5.0.
* [#713](https://github.com/shlinkio/shlink-web-component/issues/713) Expose a new `ShlinkSidebarToggleButton` component that can be used to customize the location of the sidebar toggle, rather than making it assume there's a header bar and position it there.

### Changed
* *Nothing*
Expand Down
31 changes: 30 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Minimal UI to interact with Shlink on React applications.

### Basic usage

This package exports a `ShlinkWebComponent` react component, which renders a set of sections that can be used to provide a convenient UI to interact with every aspect of a Shlink server: short URLs, tags domains, etc.
This package exports a `ShlinkWebComponent` react component, which renders a set of sections that can be used to provide a convenient UI to interact with every aspect of a Shlink server: short URLs, tags, domains, etc.

The main prop is the `apiClient`, which is used by the component in order to know how to consume the server's API.

Expand All @@ -42,6 +42,35 @@ export function App() {
>
> Also, this component re-exports the SDK types for your convenience, so you can choose to import from `@shlinkio/shlink-js-sdk/api-contract` or `@shlinkio/shlink-web-component/api-contract`.

### Responsive sidebar toggle

The layout rendered by `ShlinkWebComponent` includes a sidebar that is hidden and designed to slide from the left in small screen resolutions.

In touch devices, it can be shown by swiping right and hidden by swiping left, but this library provides a toggle button so that you can provide a piece of UI to toggle the sidebar.

> **Note**
> You need to make sure both `ShlinkSidebarToggleButton` and `ShlinkWebComponent` are wrapped in the same `ShlinkSidebarVisibilityProvider`.
> Additionally, you need to pass `autoSidebarToggle={false}` to `ShlinkWebComponent`, or it will try to render its own toggle button too.

```tsx
import {
ShlinkSidebarToggleButton,
ShlinkSidebarVisibilityProvider,
ShlinkWebComponent,
} from '@shlinkio/shlink-web-component';

export function App() {
return (
<div>
<ShlinkSidebarVisibilityProvider>
<ShlinkSidebarToggleButton />
<ShlinkWebComponent autoSidebarToggle={false} {...} />
</ShlinkSidebarVisibilityProvider>
</div>
);
};
```

### Settings

It is possible to customize some aspects of the UI by providing some optional settings.
Expand Down
18 changes: 11 additions & 7 deletions dev/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { clsx } from 'clsx';
import type { FC } from 'react';
import { useCallback , useEffect, useMemo, useState } from 'react';
import { Link, Navigate, Route, Routes, useLocation } from 'react-router';
import { ShlinkWebComponent } from '../src';
import { ShlinkSidebarToggleButton, ShlinkSidebarVisibilityProvider, ShlinkWebComponent } from '../src';
import type { Settings } from '../src/settings';
import { ShlinkWebSettings } from '../src/settings';
import type { SemVer } from '../src/utils/helpers/version';
Expand Down Expand Up @@ -74,12 +74,16 @@ export const App: FC = () => {
<Route
path={routesPrefix ? `${routesPrefix}*` : '*'}
element={apiClient && serverVersion ? (
<ShlinkWebComponent
serverVersion={serverVersion}
apiClient={apiClient}
settings={settings}
routesPrefix={routesPrefix}
/>
<ShlinkSidebarVisibilityProvider>
<ShlinkSidebarToggleButton className="fixed z-3000 right-2 top-[calc(var(--header-height)+8px)]" />
<ShlinkWebComponent
serverVersion={serverVersion}
apiClient={apiClient}
settings={settings}
routesPrefix={routesPrefix}
autoSidebarToggle={false}
/>
</ShlinkSidebarVisibilityProvider>
) : <div className="container mx-auto pt-6">Not connected</div>}
/>
<Route path="*" element={<h3 className="mt-4 text-center">Not found</h3>} />
Expand Down
27 changes: 6 additions & 21 deletions src/Main.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,18 @@
import { faBars as burgerIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useToggle } from '@shlinkio/shlink-frontend-kit';
import { clsx } from 'clsx';
import type { FC, ReactNode } from 'react';
import { useEffect } from 'react';
import { Navigate, Route, Routes, useLocation } from 'react-router';
import { AsideMenu } from './common/AsideMenu';
import type { FCWithDeps } from './container/utils';
import { componentFactory, useDependencies } from './container/utils';
import { UnstyledButton } from './utils/components/UnstyledButton';
import { ShlinkSidebarToggleButton } from './sidebar/ShlinkSidebarToggleButton';
import { useSidebarVisibility } from './sidebar/ShlinkSidebarVisibilityProvider';
import { useFeature } from './utils/features';
import { useSwipeable } from './utils/helpers/hooks';
import { useRoutesPrefix } from './utils/routesPrefix';

export type MainProps = {
createNotFound?: (nonPrefixedHomePath: string) => ReactNode;
autoToggleButton: boolean;
};

type MainDeps = {
Expand All @@ -35,7 +33,7 @@ type MainDeps = {
ShortUrlRedirectRules: FC,
};

const Main: FCWithDeps<MainProps, MainDeps> = ({ createNotFound }) => {
const Main: FCWithDeps<MainProps, MainDeps> = ({ createNotFound, autoToggleButton }) => {
const {
TagsList,
ShortUrlsList,
Expand All @@ -56,7 +54,7 @@ const Main: FCWithDeps<MainProps, MainDeps> = ({ createNotFound }) => {
const location = useLocation();
const routesPrefix = useRoutesPrefix();

const { flag: sidebarVisible, toggle: toggleSidebar, setToTrue: showSidebar, setToFalse: hideSidebar } = useToggle();
const { sidebarVisible, showSidebar, hideSidebar } = useSidebarVisibility()!;

// Hide sidebar every time the route changes
useEffect(() => hideSidebar(), [location, hideSidebar]);
Expand All @@ -66,20 +64,7 @@ const Main: FCWithDeps<MainProps, MainDeps> = ({ createNotFound }) => {

return (
<>
<UnstyledButton
aria-label="Toggle sidebar"
className={clsx(
'fixed top-4 left-3 z-1035',
'md:hidden transition-colors',
{
'text-white/50': !sidebarVisible,
'text-white': sidebarVisible,
},
)}
onClick={toggleSidebar}
>
<FontAwesomeIcon icon={burgerIcon} size="xl" />
</UnstyledButton>
{autoToggleButton && <ShlinkSidebarToggleButton className="fixed top-4 left-3 z-1035" />}

<div {...swipeableProps} className="h-full">
<div className="h-full">
Expand Down
31 changes: 20 additions & 11 deletions src/ShlinkWebComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,26 @@ import { BrowserRouter, useInRouterContext } from 'react-router';
import type { ShlinkApiClient } from './api-contract';
import type { Settings } from './settings';
import { SettingsProvider } from './settings';
import { ShlinkSidebarVisibilityProvider } from './sidebar/ShlinkSidebarVisibilityProvider';
import { FeaturesProvider, useFeatures } from './utils/features';
import type { SemVerOrLatest } from './utils/helpers/version';
import { RoutesPrefixProvider } from './utils/routesPrefix';
import type { TagColorsStorage } from './utils/services/TagColorsStorage';

type ShlinkWebComponentProps = {
export type ShlinkWebComponentProps = {
serverVersion: SemVerOrLatest; // FIXME Consider making this optional and trying to resolve it if not set
apiClient: ShlinkApiClient;
tagColorsStorage?: TagColorsStorage;
routesPrefix?: string;
settings?: Exclude<Settings, 'ui'>;
createNotFound?: (nonPrefixedHomePath: string) => ReactNode;

/**
* Whether to automatically append a responsive sidebar toggle button or not.
* You can set this to `false` and position your own toggle where it better suits you.
* Defaults to `true`.
*/
autoSidebarToggle?: boolean;
};

// FIXME This allows to track the reference to be resolved by the container, but it's hacky and relies on not more than
Expand All @@ -29,7 +37,7 @@ let apiClientRef: ShlinkApiClient;
export const createShlinkWebComponent = (
bottle: Bottle,
): FC<ShlinkWebComponentProps> => (
{ serverVersion, apiClient, settings, routesPrefix = '', createNotFound, tagColorsStorage },
{ serverVersion, apiClient, settings, routesPrefix = '', createNotFound, tagColorsStorage, autoSidebarToggle = true },
) => {
const features = useFeatures(serverVersion);
const mainContent = useRef<ReactNode>(undefined);
Expand All @@ -48,9 +56,8 @@ export const createShlinkWebComponent = (

// It's important to not try to resolve services before the API client has been registered, as many other services
// depend on it
const { container } = bottle;
const { Main, store, loadMercureInfo, listTags, listDomains } = container;
mainContent.current = <Main createNotFound={createNotFound} />;
const { Main, store, loadMercureInfo, listTags, listDomains } = bottle.container;
mainContent.current = <Main createNotFound={createNotFound} autoToggleButton={autoSidebarToggle} />;
setStore(store);

// Load mercure info
Expand All @@ -59,17 +66,19 @@ export const createShlinkWebComponent = (
store.dispatch(listTags());
// Load domains, as they are used by multiple components
store.dispatch(listDomains());
}, [apiClient, createNotFound, settings, tagColorsStorage]);
}, [apiClient, autoSidebarToggle, createNotFound, settings, tagColorsStorage]);

return !theStore ? <></> : (
<ReduxStoreProvider store={theStore}>
<SettingsProvider value={settings ?? {}}>
<FeaturesProvider value={features}>
<RoutesPrefixProvider value={routesPrefix}>
<RouterOrFragment>
{mainContent.current}
</RouterOrFragment>
</RoutesPrefixProvider>
<ShlinkSidebarVisibilityProvider>
<RoutesPrefixProvider value={routesPrefix}>
<RouterOrFragment>
{mainContent.current}
</RouterOrFragment>
</RoutesPrefixProvider>
</ShlinkSidebarVisibilityProvider>
</FeaturesProvider>
</SettingsProvider>
</ReduxStoreProvider>
Expand Down
6 changes: 6 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,10 @@ export const ShlinkWebComponent = createShlinkWebComponent(bottle);

export type ShlinkWebComponentType = typeof ShlinkWebComponent;

export type { ShlinkWebComponentProps } from './ShlinkWebComponent';

export type { TagColorsStorage } from './utils/services/TagColorsStorage';

export { ShlinkSidebarToggleButton } from './sidebar/ShlinkSidebarToggleButton';
export type { ShlinkSidebarToggleButtonProps } from './sidebar/ShlinkSidebarToggleButton';
export { ShlinkSidebarVisibilityProvider } from './sidebar/ShlinkSidebarVisibilityProvider';
6 changes: 1 addition & 5 deletions src/settings/components/ShlinkWebSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,6 @@ export type ShlinkWebSettingsProps = {
defaultShortUrlsListOrdering: NonNullable<ShortUrlsListSettings['defaultOrdering']>;
/** Callback invoked every time the settings are updated */
onUpdateSettings?: (settings: Settings) => void;

/** @deprecated Use onUpdateSettings instead */
updateSettings?: (settings: Settings) => void;
};

const SettingsSections: FC<PropsWithChildren<{ className?: string }>> = ({ children, className }) => (
Expand All @@ -36,8 +33,7 @@ const SettingsSections: FC<PropsWithChildren<{ className?: string }>> = ({ child

export const ShlinkWebSettings: FC<ShlinkWebSettingsProps> = ({
settings,
updateSettings,
onUpdateSettings = updateSettings,
onUpdateSettings,
defaultShortUrlsListOrdering,
}) => {
const updatePartialSettings = useCallback(
Expand Down
35 changes: 35 additions & 0 deletions src/sidebar/ShlinkSidebarToggleButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { faBars as burgerIcon } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { clsx } from 'clsx';
import type { FC, HTMLProps } from 'react';
import { UnstyledButton } from '../utils/components/UnstyledButton';
import { useSidebarVisibility } from './ShlinkSidebarVisibilityProvider';

export type ShlinkSidebarToggleButtonProps = Omit<HTMLProps<HTMLButtonElement>, 'onClick'>;

export const ShlinkSidebarToggleButton: FC<ShlinkSidebarToggleButtonProps> = ({ className, ...rest }) => {
const context = useSidebarVisibility();
if (!context) {
throw new Error('ShlinkSidebarToggleButton has to be used inside a ShlinkSidebarVisibilityProvider');
}

const { sidebarVisible, toggleSidebar } = context;

return (
<UnstyledButton
aria-label="Toggle sidebar"
className={clsx(
'md:hidden transition-colors',
{
'text-white/50': !sidebarVisible,
'text-white': sidebarVisible,
},
className,
)}
onClick={toggleSidebar}
{...rest}
>
<FontAwesomeIcon icon={burgerIcon} size="xl" />
</UnstyledButton>
);
};
29 changes: 29 additions & 0 deletions src/sidebar/ShlinkSidebarVisibilityProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { useToggle } from '@shlinkio/shlink-frontend-kit';
import type { FC, PropsWithChildren } from 'react';
import { createContext,useContext } from 'react';

export type SidebarVisibility = {
sidebarVisible: boolean;
toggleSidebar: () => void;
showSidebar: () => void;
hideSidebar: () => void;
};

const SidebarVisibilityContext = createContext<SidebarVisibility | undefined>(undefined);

export const useSidebarVisibility = () => useContext(SidebarVisibilityContext);

export const ShlinkSidebarVisibilityProvider: FC<PropsWithChildren> = ({ children }) => {
const prevContext = useSidebarVisibility();
const { flag: sidebarVisible, toggle: toggleSidebar, setToTrue: showSidebar, setToFalse: hideSidebar } = useToggle();

// Ensure all nested ShlinkSidebarVisibilityProviders use the same context, but if this is the first provider, create
// a new one
const providerValue = prevContext ?? { sidebarVisible, toggleSidebar, showSidebar, hideSidebar };

return (
<SidebarVisibilityContext.Provider value={providerValue}>
{children}
</SidebarVisibilityContext.Provider>
);
};
20 changes: 18 additions & 2 deletions test/Main.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { render, screen } from '@testing-library/react';
import { fromPartial } from '@total-typescript/shoehorn';
import { MemoryRouter } from 'react-router';
import { ShlinkSidebarVisibilityProvider } from '../src';
import type { MainProps } from '../src/Main';
import { MainFactory } from '../src/Main';
import { FeaturesProvider } from '../src/utils/features';
Expand All @@ -10,6 +11,7 @@ type SetUpOptions = {
currentPath?: string
createNotFound?: MainProps['createNotFound'];
supportsRedirectRules?: boolean;
autoToggleButton?: boolean;
};

describe('<Main />', () => {
Expand All @@ -30,10 +32,14 @@ describe('<Main />', () => {
ShortUrlVisitsComparison: () => <>ShortUrlVisitsComparison</>,
ShortUrlRedirectRules: () => <>ShortUrlRedirectRules</>,
}));
const setUp = ({ createNotFound, currentPath = '/', supportsRedirectRules = false }: SetUpOptions) => render(
const setUp = (
{ createNotFound, currentPath = '/', supportsRedirectRules = false, autoToggleButton = true }: SetUpOptions,
) => render(
<MemoryRouter initialEntries={[{ pathname: currentPath }]}>
<FeaturesProvider value={fromPartial({ shortUrlRedirectRules: supportsRedirectRules })}>
<Main createNotFound={createNotFound} />
<ShlinkSidebarVisibilityProvider>
<Main createNotFound={createNotFound} autoToggleButton={autoToggleButton} />
</ShlinkSidebarVisibilityProvider>
</FeaturesProvider>
</MemoryRouter>,
);
Expand Down Expand Up @@ -75,4 +81,14 @@ describe('<Main />', () => {

expect(screen.getByText('Oops! Route not found.')).toBeInTheDocument();
});

it.each([true, false])('can decide whether to render a toggle button or not', (autoToggleButton) => {
setUp({ autoToggleButton });

if (autoToggleButton) {
expect(screen.getByLabelText('Toggle sidebar')).toBeInTheDocument();
} else {
expect(screen.queryByLabelText('Toggle sidebar')).not.toBeInTheDocument();
}
});
});
Loading