feat: standardize class names (#873)

This commit is contained in:
Daniel Lautzenheiser 2023-09-14 09:49:51 -04:00 committed by GitHub
parent 7e1734aab6
commit 1338ad2f57
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
305 changed files with 8639 additions and 5405 deletions

View File

@ -66,11 +66,10 @@
"@emotion/css": "11.10.6",
"@emotion/react": "11.10.6",
"@emotion/styled": "11.10.6",
"@headlessui/react": "1.7.7",
"@lezer/common": "1.0.2",
"@mdx-js/mdx": "2.3.0",
"@mdx-js/react": "2.3.0",
"@mui/base": "5.0.0-alpha.124",
"@mui/base": "5.0.0-beta.14",
"@mui/material": "5.11.16",
"@mui/system": "5.11.16",
"@mui/x-date-pickers": "5.0.20",
@ -84,9 +83,10 @@
"@styled-icons/material-rounded": "10.47.0",
"@styled-icons/remix-editor": "10.46.0",
"@styled-icons/simple-icons": "10.46.0",
"@udecode/plate": "21.3.2",
"@udecode/plate-juice": "21.3.2",
"@udecode/plate-serializer-md": "21.3.2",
"@udecode/plate": "23.7.4",
"@udecode/plate-cursor": "23.7.4",
"@udecode/plate-juice": "23.7.4",
"@udecode/plate-serializer-md": "23.7.4",
"@uiw/codemirror-extensions-langs": "4.19.16",
"@uiw/react-codemirror": "4.19.16",
"ajv": "8.12.0",
@ -159,7 +159,7 @@
"slate": "0.94.1",
"slate-history": "0.93.0",
"slate-hyperscript": "0.77.0",
"slate-react": "0.95.0",
"slate-react": "0.98.3",
"stream-browserify": "3.0.0",
"styled-components": "5.3.10",
"symbol-observable": "4.0.0",

View File

@ -12,3 +12,5 @@ export const translate = () => (Component: FC) => {
return React.createElement(Component, { t, ...props });
};
};
export const useTranslate = () => (key: string, _options: unknown) => key;

View File

@ -0,0 +1,3 @@
.CMS_App_root {
@apply h-full;
}

View File

@ -20,6 +20,7 @@ import { currentBackend } from '@staticcms/core/backend';
import { changeTheme } from '../actions/globalUI';
import { invokeEvent } from '../lib/registry';
import { getDefaultPath } from '../lib/util/collection.util';
import { generateClassNames } from '../lib/util/theming.util';
import { selectTheme } from '../reducers/selectors/globalUI';
import { useAppDispatch, useAppSelector } from '../store/hooks';
import CollectionRoute from './collections/CollectionRoute';
@ -37,6 +38,10 @@ import type { RootState } from '@staticcms/core/store';
import type { ComponentType } from 'react';
import type { ConnectedProps } from 'react-redux';
import './App.css';
export const classes = generateClassNames('App', ['root', 'content']);
TopBarProgress.config({
barColors: {
0: '#000',
@ -267,8 +272,8 @@ const App = ({
<ScrollSync key="scroll-sync" enabled={scrollSyncEnabled}>
<>
<div key="back-to-top-anchor" id="back-to-top-anchor" />
<div key="cms-root" id="cms-root" className="h-full">
<div key="cms-wrapper" className="cms-wrapper">
<div key="cms-root" id="cms-root" className={classes.root}>
<div key="cms-wrapper" className={classes.content}>
<Snackbars key="snackbars" />
{content}
<Alert key="alert" />

View File

@ -0,0 +1,43 @@
.CMS_ErrorBoundary_root {
@apply flex
flex-col
bg-slate-50
dark:bg-slate-900
min-h-screen
gap-2;
}
.CMS_ErrorBoundary_header {
@apply flex
flex-col
py-2
px-4
gap-2;
}
.CMS_ErrorBoundary_title {
@apply text-2xl
font-bold;
}
.CMS_ErrorBoundary_report-link {
@apply text-blue-500
hover:underline;
}
.CMS_ErrorBoundary_content {
@apply flex
flex-col
py-2
px-4
gap-2;
}
.CMS_ErrorBoundary_details-title {
@apply text-xl
font-bold;
}
.CMS_ErrorBoundary_error-line {
@apply whitespace-pre;
}

View File

@ -6,10 +6,23 @@ import { translate } from 'react-polyglot';
import yaml from 'yaml';
import { localForage } from '@staticcms/core/lib/util';
import { generateClassNames } from '../lib/util/theming.util';
import type { Config, TranslatedProps } from '@staticcms/core/interface';
import type { ComponentClass, ReactNode } from 'react';
import './ErrorBoundary.css';
export const classes = generateClassNames('ErrorBoundary', [
'root',
'header',
'title',
'report-link',
'content',
'details-title',
'error-line',
]);
const ISSUE_URL = 'https://github.com/StaticJsCMS/static-cms/issues/new?';
function getIssueTemplate(version: string, provider: string, browser: string, config: string) {
@ -147,27 +160,9 @@ class ErrorBoundary extends Component<TranslatedProps<ErrorBoundaryProps>, Error
return this.props.children;
}
return (
<div
key="error-boundary-container"
className="
flex
flex-col
bg-slate-50
dark:bg-slate-900
min-h-screen
gap-2
"
>
<div
className="
flex
flex-col
py-2
px-4
gap-2
"
>
<h1 className="text-2xl bold">{t('ui.errorBoundary.title')}</h1>
<div key="error-boundary-container" className={classes.root}>
<div className={classes.header}>
<h1 className={classes.title}>{t('ui.errorBoundary.title')}</h1>
<p>
<span>{t('ui.errorBoundary.details')}</span>
<a
@ -175,10 +170,7 @@ class ErrorBoundary extends Component<TranslatedProps<ErrorBoundaryProps>, Error
target="_blank"
rel="noopener noreferrer"
data-testid="issue-url"
className="
text-blue-500
hover:underline
"
className={classes['report-link']}
>
{t('ui.errorBoundary.reportIt')}
</a>
@ -193,19 +185,11 @@ class ErrorBoundary extends Component<TranslatedProps<ErrorBoundaryProps>, Error
</p>
</div>
<hr />
<div
className="
flex
flex-col
py-2
px-4
gap-2
"
>
<h2 className="text-xl bold">{t('ui.errorBoundary.detailsHeading')}</h2>
<div className={classes.content}>
<h2 className={classes['details-title']}>{t('ui.errorBoundary.detailsHeading')}</h2>
<p>
{errorMessage.split('\n').map((item, index) => [
<span key={`error-line-${index}`} className="whitespace-pre">
<span key={`error-line-${index}`} className={classes['error-line']}>
{item}
</span>,
<br key={`error-break-${index}`} />,

View File

@ -0,0 +1,30 @@
.CMS_MainView_root {
@apply flex
bg-slate-50
dark:bg-slate-900;
}
.CMS_MainView_body {
@apply h-main-mobile
md:h-main
relative
w-full;
&.CMS_MainView_show-left-nav {
@apply left-0
md:w-main;
}
&:not(.CMS_MainView_no-margin) {
@apply px-5
py-4;
}
&.CMS_MainView_no-scroll {
@apply overflow-hidden;
}
&:not(.CMS_MainView_no-scroll) {
@apply overflow-y-auto;
}
}

View File

@ -2,6 +2,7 @@ import React from 'react';
import TopBarProgress from 'react-topbar-progress-indicator';
import classNames from '../lib/util/classNames.util';
import { generateClassNames } from '../lib/util/theming.util';
import BottomNavigation from './navbar/BottomNavigation';
import Navbar from './navbar/Navbar';
import Sidebar from './navbar/Sidebar';
@ -9,6 +10,16 @@ import Sidebar from './navbar/Sidebar';
import type { ReactNode } from 'react';
import type { Breadcrumb, Collection } from '../interface';
import './MainView.css';
export const classes = generateClassNames('MainView', [
'root',
'body',
'show-left-nav',
'no-margin',
'no-scroll',
]);
TopBarProgress.config({
barColors: {
0: '#000',
@ -46,20 +57,15 @@ const MainView = ({
showQuickCreate={showQuickCreate}
navbarActions={navbarActions}
/>
<div className="flex bg-slate-50 dark:bg-slate-900">
<div className={classes.root}>
{showLeftNav ? <Sidebar /> : null}
<div
id="main-view"
className={classNames(
showLeftNav ? ' w-full left-0 md:w-main' : 'w-full',
!noMargin && 'px-5 py-4',
noScroll ? 'overflow-hidden' : 'overflow-y-auto',
`
h-main-mobile
md:h-main
relative
styled-scrollbars
`,
classes.body,
showLeftNav && classes['show-left-nav'],
noMargin && classes['no-margin'],
noScroll && classes['no-scroll'],
)}
>
{children}

View File

@ -0,0 +1,17 @@
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
const collectionClasses = generateClassNames('Collection', [
'root',
'content',
'search-query',
'description',
'description-card',
'controls',
'header-wrapper',
'header',
'header-icon',
'header-label',
'new-entry-button',
]);
export default collectionClasses;

View File

@ -0,0 +1,87 @@
.CMS_Collection_root {
@apply flex
flex-col
h-full
px-5
pt-4
overflow-hidden;
}
.CMS_Collection_content {
@apply flex
items-center
mb-4
flex-row
gap-4
sm:gap-0
flex-wrap
md:flex-nowrap;
}
.CMS_Collection_search-query {
@apply flex-grow;
}
.CMS_Collection_description {
@apply flex
mb-4;
}
.CMS_Collection_description-card {
@apply flex-grow
px-3.5
py-2.5
text-sm;
}
.CMS_Collection_controls {
@apply flex
items-center
relative
z-20
md:flex-grow-0
flex-grow
justify-end
gap-1.5
sm:w-auto
lg:gap-2;
}
.CMS_Collection_header-wrapper {
@apply flex
gap-2
sm:gap-2
md:gap-4
md:w-full
justify-normal
xs:justify-between
sm:justify-normal
truncate;
}
.CMS_Collection_header {
@apply text-xl
font-semibold
flex
items-center
text-gray-800
dark:text-gray-300
gap-2
md:w-auto;
}
.CMS_Collection_header-icon {
@apply flex
items-center;
}
.CMS_Collection_header-label {
@apply max-w-collection-header
md:flex-grow
truncate;
}
.CMS_Collection_new-entry-button {
@apply hidden
md:flex;
}

View File

@ -1,6 +1,7 @@
import React, { useMemo } from 'react';
import ViewStyleControl from '../common/view-style/ViewStyleControl';
import collectionClasses from './Collection.classes';
import FilterControl from './FilterControl';
import GroupControl from './GroupControl';
import MobileCollectionControls from './mobile/MobileCollectionControls';
@ -61,20 +62,7 @@ const CollectionControls = ({
return (
<>
<div
className="
flex
items-center
relative
z-20
w-full
justify-end
gap-1.5
sm:w-auto
sm:justify-normal
lg:gap-2
"
>
<div className={collectionClasses.controls}>
<ViewStyleControl viewStyle={viewStyle} onChangeViewStyle={onChangeViewStyle} />
{showGroupControl || showFilterControl || showFilterControl ? (
<MobileCollectionControls

View File

@ -4,6 +4,7 @@ import { useParams } from 'react-router-dom';
import useEntries from '@staticcms/core/lib/hooks/useEntries';
import useIcon from '@staticcms/core/lib/hooks/useIcon';
import useNewEntryUrl from '@staticcms/core/lib/hooks/useNewEntryUrl';
import {
selectEntryCollectionTitle,
selectFolderEntryExtension,
@ -11,7 +12,7 @@ import {
import { isNotEmpty } from '@staticcms/core/lib/util/string.util';
import { addFileTemplateFields } from '@staticcms/core/lib/widgets/stringTemplate';
import Button from '../common/button/Button';
import useNewEntryUrl from '@staticcms/core/lib/hooks/useNewEntryUrl';
import collectionClasses from './Collection.classes';
import type { Collection, Entry, TranslatedProps } from '@staticcms/core/interface';
import type { FC } from 'react';
@ -63,54 +64,19 @@ const CollectionHeader: FC<TranslatedProps<CollectionHeaderProps>> = ({ collecti
}, [collection, collectionLabel, entries, filterTerm]);
return (
<>
<div
className="
flex
flex-grow
gap-4
justify-normal
xs:justify-between
sm:justify-normal
w-full
truncate
"
>
<h2
className="
text-xl
font-semibold
flex
items-center
text-gray-800
dark:text-gray-300
gap-2
flex-grow
w-full
md:grow-0
md:w-auto
"
>
<div className="flex items-center">{icon}</div>
<div
className="
w-collection-header
flex-grow
truncate
"
>
{pluralLabel}
</div>
<div className={collectionClasses['header-wrapper']}>
<h2 className={collectionClasses.header}>
<div className={collectionClasses['header-icon']}>{icon}</div>
<div className={collectionClasses['header-label']}>{pluralLabel}</div>
</h2>
{newEntryUrl ? (
<Button to={newEntryUrl} className="hidden md:flex">
<Button to={newEntryUrl} className={collectionClasses['new-entry-button']}>
{t('collection.collectionTop.newButton', {
collectionLabel: collectionLabelSingular || pluralLabel,
})}
</Button>
) : null}
</div>
</>
);
};

View File

@ -0,0 +1,80 @@
.CMS_CollectionSearch_content {
@apply relative;
}
.CMS_CollectionSearch_icon-wrapper {
@apply absolute
inset-y-0
left-0
flex
items-center
pl-3
pointer-events-none;
}
.CMS_CollectionSearch_icon {
@apply w-5
h-5
text-gray-500
dark:text-gray-400;
}
.CMS_CollectionSearch_input {
@apply block
w-full
p-1.5
pl-10
text-sm
text-gray-800
border
border-gray-300
rounded-lg
bg-gray-50
focus-visible:outline-none
focus:ring-4
focus:ring-gray-200
dark:bg-gray-700
dark:border-gray-600
dark:placeholder-gray-400
dark:text-white
dark:focus:ring-slate-700;
}
.CMS_CollectionSearch_search-in {
@apply absolute
overflow-auto
rounded-md
bg-white
text-base
shadow-md
ring-1
ring-black
ring-opacity-5
focus:outline-none
sm:text-sm
z-[1300]
dark:bg-slate-700
dark:shadow-lg;
}
.CMS_CollectionSearch_search-in-content {
@apply flex
flex-col
min-w-[200px];
}
.CMS_CollectionSearch_search-in-label {
@apply text-base
text-slate-500
dark:text-slate-400
py-2
px-3;
}
.CMS_CollectionSearch_search-in-option {
@apply cursor-pointer
hover:bg-blue-500
hover:text-gray-100
py-2
px-3;
}

View File

@ -1,11 +1,27 @@
import PopperUnstyled from '@mui/base/PopperUnstyled';
import { Popper as BasePopper } from '@mui/base/Popper';
import { Search as SearchIcon } from '@styled-icons/material/Search';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { translate } from 'react-polyglot';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import type { Collection, Collections, TranslatedProps } from '@staticcms/core/interface';
import type { ChangeEvent, FocusEvent, KeyboardEvent, MouseEvent } from 'react';
import './CollectionSearch.css';
export const classes = generateClassNames('CollectionSearch', [
'root',
'content',
'icon-wrapper',
'icon',
'input',
'search-in',
'search-in-content',
'search-in-label',
'search-in-option',
]);
interface CollectionSearchProps {
collections: Collections;
collection?: Collection;
@ -136,39 +152,21 @@ const CollectionSearch = ({
[submitSearch],
);
const handleClick = useCallback((event: MouseEvent) => {
const handleClick = useCallback((event: MouseEvent<HTMLInputElement>) => {
event.stopPropagation();
setAnchorEl(event.currentTarget);
}, []);
return (
<div>
<div className="relative">
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<SearchIcon className="w-5 h-5 text-gray-500 dark:text-gray-400" />
<div className={classes.root}>
<div className={classes.content}>
<div className={classes['icon-wrapper']}>
<SearchIcon className={classes.icon} />
</div>
<input
type="text"
id="first_name"
className="
block
w-full
p-1.5
pl-10
text-sm
text-gray-800
border
border-gray-300
rounded-lg
bg-gray-50
focus-visible:outline-none
focus:ring-4
focus:ring-gray-200
dark:bg-gray-700
dark:border-gray-600
dark:placeholder-gray-400
dark:text-white
dark:focus:ring-slate-700
"
className={classes.input}
placeholder={t('collection.sidebar.searchAll')}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
@ -178,57 +176,20 @@ const CollectionSearch = ({
onClick={handleClick}
/>
</div>
<PopperUnstyled
<BasePopper
open={open}
component="div"
placement="top"
anchorEl={anchorEl}
tabIndex={0}
className="
absolute
overflow-auto
rounded-md
bg-white
text-base
shadow-md
ring-1
ring-black
ring-opacity-5
focus:outline-none
sm:text-sm
z-[1300]
dark:bg-slate-700
dark:shadow-lg
"
className={classes['search-in']}
slots={{
root: 'div',
}}
>
<div key="edit-content" contentEditable={false} className={classes['search-in-content']}>
<div className={classes['search-in-label']}>{t('collection.sidebar.searchIn')}</div>
<div
key="edit-content"
contentEditable={false}
className="
flex
flex-col
min-w-[200px]
"
>
<div
className="
text-md
text-slate-500
dark:text-slate-400
py-2
px-3
"
>
{t('collection.sidebar.searchIn')}
</div>
<div
className="
cursor-pointer
hover:bg-blue-500
hover:color-gray-100
py-2
px-3
"
className={classes['search-in-option']}
onClick={e => handleSuggestionClick(e, -1)}
onMouseDown={e => e.preventDefault()}
>
@ -239,52 +200,13 @@ const CollectionSearch = ({
key={idx}
onClick={e => handleSuggestionClick(e, idx)}
onMouseDown={e => e.preventDefault()}
className="
cursor-pointer
hover:bg-blue-500
hover:color-gray-100
py-2
px-3
"
className={classes['search-in-option']}
>
{collection.label}
</div>
))}
</div>
</PopperUnstyled>
{/* <Popover
id="search-popover"
open={open}
anchorEl={anchorEl}
onClose={handleClose}
disableAutoFocus
disableEnforceFocus
disableScrollLock
hideBackdrop
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
sx={{
width: 300
}}
>
<div>
<div>{t('collection.sidebar.searchIn')}</div>
<div onClick={e => handleSuggestionClick(e, -1)} onMouseDown={e => e.preventDefault()}>
{t('collection.sidebar.allCollections')}
</div>
{collections.map((collection, idx) => (
<div
key={idx}
onClick={e => handleSuggestionClick(e, idx)}
onMouseDown={e => e.preventDefault()}
>
{collection.label}
</div>
))}
</div>
</Popover> */}
</BasePopper>
</div>
);
};

View File

@ -21,6 +21,7 @@ import {
selectViewStyle,
} from '@staticcms/core/reducers/selectors/entries';
import Card from '../common/card/Card';
import collectionClasses from './Collection.classes';
import CollectionControls from './CollectionControls';
import CollectionHeader from './CollectionHeader';
import EntriesCollection from './entries/EntriesCollection';
@ -37,6 +38,8 @@ import type { RootState } from '@staticcms/core/store';
import type { ComponentType } from 'react';
import type { ConnectedProps } from 'react-redux';
import './Collection.css';
const CollectionView = ({
collection,
collections,
@ -183,11 +186,11 @@ const CollectionView = ({
const collectionDescription = collection?.description;
return (
<div className="flex flex-col h-full px-5 pt-4 overflow-hidden">
<div className="flex items-center mb-4 flex-row gap-4 sm:gap-0">
<div className={collectionClasses.root}>
<div className={collectionClasses.content}>
{isSearchResults ? (
<>
<div className="flex-grow">
<div className={collectionClasses['search-query']}>
<div>{t(searchResultKey, { searchTerm, collection: collection?.label })}</div>
</div>
<CollectionControls viewStyle={viewStyle} onChangeViewStyle={changeViewStyle} />
@ -212,8 +215,8 @@ const CollectionView = ({
)}
</div>
{collectionDescription ? (
<div className="flex mb-4">
<Card className="flex-grow px-3.5 py-2.5 text-sm">{collectionDescription}</Card>
<div className={collectionClasses.description}>
<Card className={collectionClasses['description-card']}>{collectionDescription}</Card>
</div>
) : null}
{entries}

View File

@ -0,0 +1,42 @@
.CMS_FilterControl_root {
@apply hidden
lg:block;
}
.CMS_FilterControl_filter-label {
@apply ml-2
text-sm
font-medium
text-gray-800
dark:text-gray-300;
}
.CMS_FilterControl_list-root {
@apply flex
flex-col
gap-2;
}
.CMS_FilterControl_list-label {
@apply text-lg
font-bold
text-gray-800
dark:text-white;
}
.CMS_FilterControl_list-filter {
@apply ml-1.5
font-medium
flex
items-center
text-gray-800
dark:text-gray-300;
}
.CMS_FilterControl_list-filter-label {
@apply ml-2
text-base
font-medium
text-gray-800
dark:text-gray-300;
}

View File

@ -1,6 +1,7 @@
import React, { useCallback, useMemo } from 'react';
import { translate } from 'react-polyglot';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import Menu from '../common/menu/Menu';
import MenuGroup from '../common/menu/MenuGroup';
import MenuItemButton from '../common/menu/MenuItemButton';
@ -8,6 +9,18 @@ import MenuItemButton from '../common/menu/MenuItemButton';
import type { FilterMap, TranslatedProps, ViewFilter } from '@staticcms/core/interface';
import type { FC, MouseEvent } from 'react';
import './FilterControl.css';
export const classes = generateClassNames('FilterControl', [
'root',
'filter',
'filter-label',
'list-root',
'list-label',
'list-filter',
'list-filter-label',
]);
export interface FilterControlProps {
filter: Record<string, FilterMap> | undefined;
viewFilters: ViewFilter[] | undefined;
@ -35,31 +48,15 @@ const FilterControl = ({
if (variant === 'list') {
return (
<div key="filter-by-list" className="flex flex-col gap-2">
<h3
className="
text-lg
font-bold
text-gray-800
dark:text-white
"
>
{t('collection.collectionTop.filterBy')}
</h3>
<div key="filter-by-list" className={classes['list-root']}>
<h3 className={classes['list-label']}>{t('collection.collectionTop.filterBy')}</h3>
{viewFilters.map(viewFilter => {
const checked = Boolean(viewFilter.id && filter[viewFilter?.id]?.active) ?? false;
const labelId = `filter-list-label-${viewFilter.label}`;
return (
<div
key={viewFilter.id}
className="
ml-1.5
font-medium
flex
items-center
text-gray-800
dark:text-gray-300
"
className={classes['list-filter']}
onClick={handleFilterClick(viewFilter)}
>
<input
@ -67,13 +64,10 @@ const FilterControl = ({
id={labelId}
type="checkbox"
value=""
className=""
checked={checked}
readOnly
/>
<label className="ml-2 text-md font-medium text-gray-800 dark:text-gray-300">
{viewFilter.label}
</label>
<label className={classes['list-filter-label']}>{viewFilter.label}</label>
</div>
);
})}
@ -86,26 +80,27 @@ const FilterControl = ({
key="filter-by-menu"
label={t('collection.collectionTop.filterBy')}
variant={anyActive ? 'contained' : 'outlined'}
rootClassName="hidden lg:block"
rootClassName={classes.root}
>
<MenuGroup>
{viewFilters.map(viewFilter => {
const checked = Boolean(viewFilter.id && filter[viewFilter?.id]?.active) ?? false;
const labelId = `filter-list-label-${viewFilter.label}`;
return (
<MenuItemButton key={viewFilter.id} onClick={handleFilterClick(viewFilter)}>
<MenuItemButton
key={viewFilter.id}
onClick={handleFilterClick(viewFilter)}
className={classes.filter}
>
<input
key={`${labelId}-${checked}`}
id={labelId}
type="checkbox"
value=""
className=""
checked={checked}
readOnly
/>
<label className="ml-2 text-sm font-medium text-gray-800 dark:text-gray-300">
{viewFilter.label}
</label>
<label className={classes['filter-label']}>{viewFilter.label}</label>
</MenuItemButton>
);
})}

View File

@ -0,0 +1,47 @@
.CMS_GroupControl_root {
@apply hidden
lg:block;
}
.CMS_GroupControl_list {
@apply flex
flex-col
gap-2;
}
.CMS_GroupControl_list-label {
@apply text-lg
font-bold
text-gray-800
dark:text-white;
}
.CMS_GroupControl_list-option {
@apply ml-0.5
font-medium
flex
items-center
text-gray-800
dark:text-gray-300;
}
.CMS_GroupControl_list-option-label {
@apply ml-2
text-base
font-medium
text-gray-800
dark:text-gray-300;
}
.CMS_GroupControl_list-option-checked-icon {
@apply ml-2
w-6
h-6
text-blue-500;
}
.CMS_GroupControl_list-option-not-checked {
@apply ml-2
w-6
h-6;
}

View File

@ -2,6 +2,7 @@ import { Check as CheckIcon } from '@styled-icons/material/Check';
import React, { useCallback, useMemo } from 'react';
import { translate } from 'react-polyglot';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import Menu from '../common/menu/Menu';
import MenuGroup from '../common/menu/MenuGroup';
import MenuItemButton from '../common/menu/MenuItemButton';
@ -9,6 +10,19 @@ import MenuItemButton from '../common/menu/MenuItemButton';
import type { GroupMap, TranslatedProps, ViewGroup } from '@staticcms/core/interface';
import type { FC, MouseEvent } from 'react';
import './GroupControl.css';
export const classes = generateClassNames('GroupControl', [
'root',
'option',
'list',
'list-label',
'list-option',
'list-option-label',
'list-option-checked-icon',
'list-option-not-checked',
]);
export interface GroupControlProps {
group: Record<string, GroupMap> | undefined;
viewGroups: ViewGroup[] | undefined;
@ -36,39 +50,21 @@ const GroupControl = ({
if (variant === 'list') {
return (
<div key="filter-by-list" className="flex flex-col gap-2">
<h3
className="
text-lg
font-bold
text-gray-800
dark:text-white
"
>
{t('collection.collectionTop.groupBy')}
</h3>
<div key="filter-by-list" className={classes.list}>
<h3 className={classes['list-label']}>{t('collection.collectionTop.groupBy')}</h3>
{viewGroups.map(viewGroup => {
const active = Boolean(viewGroup.id && group[viewGroup?.id]?.active) ?? false;
return (
<div
key={viewGroup.id}
className="
ml-0.5
font-medium
flex
items-center
text-gray-800
dark:text-gray-300
"
className={classes['list-option']}
onClick={handleGroupClick(viewGroup)}
>
<label className="ml-2 text-md font-medium text-gray-800 dark:text-gray-300">
{viewGroup.label}
</label>
<label className={classes['list-option-label']}>{viewGroup.label}</label>
{active ? (
<CheckIcon key="checkmark" className="ml-2 w-6 h-6 text-blue-500" />
<CheckIcon key="checkmark" className={classes['list-option-checked-icon']} />
) : (
<div key="not-checked" className="ml-2 w-6 h-6" />
<div key="not-checked" className={classes['list-option-not-checked']} />
)}
</div>
);
@ -81,7 +77,7 @@ const GroupControl = ({
<Menu
label={t('collection.collectionTop.groupBy')}
variant={activeGroup ? 'contained' : 'outlined'}
rootClassName="hidden lg:block"
rootClassName={classes.root}
>
<MenuGroup>
{viewGroups.map(viewGroup => (
@ -89,6 +85,7 @@ const GroupControl = ({
key={viewGroup.id}
onClick={() => onGroupClick?.(viewGroup)}
endIcon={viewGroup.id === activeGroup?.id ? CheckIcon : undefined}
className={classes.option}
>
{viewGroup.label}
</MenuItemButton>

View File

@ -0,0 +1,62 @@
.CMS_NestedCollection_root-node {
&.CMS_NestedCollection_active {
&.CMS_NestedCollection_expanded {
& > .CMS_NestedCollection_link {
& .CMS_NestedCollection_node-children-icon {
@apply rotate-90
transform;
}
}
}
}
}
.CMS_NestedCollection_active {
}
.CMS_NestedCollection_expanded {
}
.CMS_NestedCollection_root-node-icon {
@apply h-6
w-6;
}
.CMS_NestedCollection_node {
@apply ml-8;
&.CMS_NestedCollection_expanded {
& > .CMS_NestedCollection_link {
& .CMS_NestedCollection_node-children-icon {
@apply rotate-90
transform;
}
}
}
}
.CMS_NestedCollection_node-icon {
@apply h-5
w-5;
}
.CMS_NestedCollection_node-content {
@apply flex
w-full
gap-2
items-center
justify-between;
}
.CMS_NestedCollection_node-children-icon {
@apply transition-transform
h-5
w-5
group-focus-within/active-list:text-blue-500
group-hover/active-list:text-blue-500;
}
.CMS_NestedCollection_node-children {
@apply mt-2
space-y-1.5;
}

View File

@ -1,18 +1,35 @@
import { Article as ArticleIcon } from '@styled-icons/material/Article';
import { ChevronRight as ChevronRightIcon } from '@styled-icons/material/ChevronRight';
import sortBy from 'lodash/sortBy';
import React, { Fragment, useCallback, useEffect, useState } from 'react';
import React, { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
import { useLocation } from 'react-router-dom';
import useEntries from '@staticcms/core/lib/hooks/useEntries';
import classNames from '@staticcms/core/lib/util/classNames.util';
import { getTreeData } from '@staticcms/core/lib/util/nested.util';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import NavLink from '../navbar/NavLink';
import type { Collection, Entry } from '@staticcms/core/interface';
import type { TreeNodeData } from '@staticcms/core/lib/util/nested.util';
import type { MouseEvent } from 'react';
import './NestedCollection.css';
export const classes = generateClassNames('NestedCollection', [
'root',
'active',
'expanded',
'root-node',
'root-node-icon',
'link',
'node',
'node-icon',
'node-content',
'node-children-icon',
'node-children',
]);
function getNodeTitle(node: TreeNodeData) {
const title = node.isRoot
? node.title
@ -23,28 +40,46 @@ function getNodeTitle(node: TreeNodeData) {
interface TreeNodeProps {
collection: Collection;
treeData: TreeNodeData[];
rootIsActive: boolean;
path: string;
depth?: number;
onToggle: ({ node, expanded }: { node: TreeNodeData; expanded: boolean }) => void;
}
const TreeNode = ({ collection, treeData, depth = 0, onToggle }: TreeNodeProps) => {
const TreeNode = ({
collection,
treeData,
rootIsActive,
path,
depth = 0,
onToggle,
}: TreeNodeProps) => {
const collectionName = collection.name;
const handleClick = useCallback(
(event: MouseEvent | undefined, node: TreeNodeData, expanded: boolean) => {
if (!rootIsActive) {
return;
}
event?.stopPropagation();
event?.preventDefault();
if (event) {
onToggle({ node, expanded });
} else {
onToggle({ node, expanded: true });
onToggle({ node, expanded: path === node.path ? expanded : true });
}
},
[onToggle],
[onToggle, path, rootIsActive],
);
const sortedData = sortBy(treeData, getNodeTitle);
if (depth !== 0 && !rootIsActive) {
return null;
}
return (
<>
{sortedData.map(node => {
@ -62,36 +97,42 @@ const TreeNode = ({ collection, treeData, depth = 0, onToggle }: TreeNodeProps)
return (
<Fragment key={node.path}>
<div className={classNames(depth !== 0 && 'ml-8')}>
<div
className={classNames(
depth === 0 ? classes['root-node'] : classes.node,
depth === 0 && rootIsActive && classes.active,
node.expanded && classes.expanded,
)}
>
<NavLink
to={to}
onClick={() => handleClick(undefined, node, !node.expanded)}
data-testid={node.path}
icon={<ArticleIcon className={classNames(depth === 0 ? 'h-6 w-6' : 'h-5 w-5')} />}
className={classes.link}
icon={
<ArticleIcon
className={classNames(
depth === 0 ? classes['root-node-icon'] : classes['node-icon'],
)}
/>
}
>
<div className="flex w-full gap-2 items-center justify-between">
<div className={classes['node-content']}>
<div>{title}</div>
{hasChildren && (
<ChevronRightIcon
onClick={event => handleClick(event, node, !node.expanded)}
className={classNames(
node.expanded && 'rotate-90 transform',
`
transition-transform
h-5
w-5
group-focus-within/active-list:text-blue-500
group-hover/active-list:text-blue-500
`,
)}
className={classes['node-children-icon']}
/>
)}
</div>
</NavLink>
<div className="mt-2 space-y-1.5">
<div className={classes['node-children']}>
{node.expanded && (
<TreeNode
rootIsActive={rootIsActive}
collection={collection}
path={path}
depth={depth + 1}
treeData={node.children}
onToggle={onToggle}
@ -153,30 +194,52 @@ const NestedCollection = ({ collection, filterTerm }: NestedCollectionProps) =>
const [treeData, setTreeData] = useState<TreeNodeData[]>(getTreeData(collection, entries));
const [useFilter, setUseFilter] = useState(true);
const [prevRootIsActive, setPrevRootIsActive] = useState(false);
const [prevCollection, setPrevCollection] = useState<Collection | null>(null);
const [prevEntries, setPrevEntries] = useState<Entry[] | null>(null);
const [prevFilterTerm, setPrevFilterTerm] = useState<string | null>(null);
const [prevPath, setPrevPath] = useState<string | null>(null);
const { pathname } = useLocation();
const rootIsActive = useMemo(
() => pathname.startsWith(`/collections/${collection.name}`),
[collection.name, pathname],
);
const path = useMemo(() => `/${filterTerm}`, [filterTerm]);
useEffect(() => {
if (collection !== prevCollection || entries !== prevEntries || filterTerm !== prevFilterTerm) {
if (
rootIsActive !== prevRootIsActive ||
collection !== prevCollection ||
entries !== prevEntries ||
path !== prevPath
) {
const expanded: Record<string, boolean> = {};
walk(treeData, node => {
if (!rootIsActive) {
expanded[node.path] = false;
return;
}
if (node.expanded) {
expanded[node.path] = true;
}
});
const newTreeData = getTreeData(collection, entries);
const path = `/${filterTerm}`;
walk(newTreeData, node => {
if (
expanded[node.path] ||
(useFilter &&
path.startsWith(node.path) &&
pathname.startsWith(`/collections/${collection.name}`))
) {
if (!rootIsActive) {
node.expanded = false;
return;
}
if (node.isRoot) {
node.expanded = true;
return;
}
if (expanded[node.path] || (useFilter && path.startsWith(node.path))) {
node.expanded = true;
}
});
@ -184,17 +247,21 @@ const NestedCollection = ({ collection, filterTerm }: NestedCollectionProps) =>
setTreeData(newTreeData);
}
setPrevRootIsActive(rootIsActive);
setPrevCollection(collection);
setPrevEntries(entries);
setPrevFilterTerm(filterTerm);
setPrevPath(path);
}, [
collection,
entries,
filterTerm,
path,
pathname,
prevCollection,
prevEntries,
prevFilterTerm,
prevPath,
prevRootIsActive,
rootIsActive,
treeData,
useFilter,
]);
@ -212,7 +279,15 @@ const NestedCollection = ({ collection, filterTerm }: NestedCollectionProps) =>
[treeData],
);
return <TreeNode collection={collection} treeData={treeData} onToggle={onToggle} />;
return (
<TreeNode
collection={collection}
treeData={treeData}
onToggle={onToggle}
rootIsActive={rootIsActive}
path={path}
/>
);
};
export default NestedCollection;

View File

@ -0,0 +1,50 @@
.CMS_SortControl_root {
@apply hidden
lg:block;
}
.CMS_SortControl_option {
}
.CMS_SortControl_list {
@apply flex
flex-col
gap-2;
}
.CMS_SortControl_list-label {
@apply text-lg
font-bold
text-gray-800
dark:text-white;
}
.CMS_SortControl_list-option {
@apply ml-0.5
font-medium
flex
items-center
text-gray-800
dark:text-gray-300;
}
.CMS_SortControl_list-option-label {
@apply ml-2
text-base
font-medium
text-gray-800
dark:text-gray-300;
}
.CMS_SortControl_list-option-sorted-icon {
@apply ml-2
w-6
h-6
text-blue-500;
}
.CMS_SortControl_list-option-not-sorted {
@apply ml-2
w-6
h-6;
}

View File

@ -8,6 +8,7 @@ import {
SORT_DIRECTION_DESCENDING,
SORT_DIRECTION_NONE,
} from '@staticcms/core/constants';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import Menu from '../common/menu/Menu';
import MenuGroup from '../common/menu/MenuGroup';
import MenuItemButton from '../common/menu/MenuItemButton';
@ -20,6 +21,19 @@ import type {
} from '@staticcms/core/interface';
import type { FC, MouseEvent } from 'react';
import './SortControl.css';
export const classes = generateClassNames('SortControl', [
'root',
'option',
'list',
'list-label',
'list-option',
'list-option-label',
'list-option-sorted-icon',
'list-option-not-sorted',
]);
function nextSortDirection(direction: SortDirection) {
switch (direction) {
case SORT_DIRECTION_ASCENDING:
@ -69,44 +83,32 @@ const SortControl = ({
if (variant === 'list') {
return (
<div key="filter-by-list" className="flex flex-col gap-2">
<h3
className="
text-lg
font-bold
text-gray-800
dark:text-white
"
>
{t('collection.collectionTop.sortBy')}
</h3>
<div key="filter-by-list" className={classes.list}>
<h3 className={classes['list-label']}>{t('collection.collectionTop.sortBy')}</h3>
{fields.map(field => {
const sortDir = sort?.[field.name]?.direction ?? SORT_DIRECTION_NONE;
const nextSortDir = nextSortDirection(sortDir);
return (
<div
key={field.name}
className="
ml-0.5
font-medium
flex
items-center
text-gray-800
dark:text-gray-300
"
className={classes['list-option']}
onClick={handleSortClick(field.name, nextSortDir)}
>
<label className="ml-2 text-md font-medium text-gray-800 dark:text-gray-300">
{field.label ?? field.name}
</label>
<label className={classes['list-option-label']}>{field.label ?? field.name}</label>
{field.name === selectedSort.key ? (
selectedSort.direction === SORT_DIRECTION_ASCENDING ? (
<KeyboardArrowUpIcon key="checkmark" className="ml-2 w-6 h-6 text-blue-500" />
<KeyboardArrowUpIcon
key="checkmark"
className={classes['list-option-sorted-icon']}
/>
) : (
<KeyboardArrowDownIcon key="checkmark" className="ml-2 w-6 h-6 text-blue-500" />
<KeyboardArrowDownIcon
key="checkmark"
className={classes['list-option-sorted-icon']}
/>
)
) : (
<div key="not-checked" className="ml-2 w-6 h-6" />
<div key="not-checked" className={classes['list-option-not-sorted']} />
)}
</div>
);
@ -119,7 +121,7 @@ const SortControl = ({
<Menu
label={t('collection.collectionTop.sortBy')}
variant={selectedSort.key ? 'contained' : 'outlined'}
rootClassName="hidden lg:block"
rootClassName={classes.root}
>
<MenuGroup>
{fields.map(field => {
@ -137,6 +139,7 @@ const SortControl = ({
: KeyboardArrowDownIcon
: undefined
}
className={classes.option}
>
{field.label ?? field.name}
</MenuItemButton>

View File

@ -0,0 +1,22 @@
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
const entriesClasses = generateClassNames('Entries', [
'root',
'group',
'group-content-wrapper',
'group-content',
'group-button',
'entry-listing',
'entry-listing-loading',
'entry-listing-grid',
'entry-listing-grid-container',
'entry-listing-cards',
'entry-listing-cards-grid-wrapper',
'entry-listing-cards-grid',
'entry-listing-table',
'entry-listing-table-content',
'entry-listing-table-row',
'entry-listing-local-backup',
]);
export default entriesClasses;

View File

@ -0,0 +1,104 @@
.CMS_Entries_root {
@apply py-2
px-3
rounded-md
bg-yellow-300/75
dark:bg-yellow-800/75
text-sm;
}
.CMS_Entries_group {
@apply pb-3;
}
.CMS_Entries_group-content-wrapper {
@apply -m-1;
}
.CMS_Entries_group-content {
@apply flex
gap-2
p-1
overflow-x-auto;
}
.CMS_Entries_group-button {
@apply whitespace-nowrap;
}
.CMS_Entries_entry-listing {
@apply pb-3
overflow-hidden;
}
.CMS_Entries_entry-listing-loading {
@apply absolute
inset-0
flex
items-center
justify-center
bg-slate-50/50
dark:bg-slate-900/50;
}
.CMS_Entries_entry-listing-grid {
@apply relative
h-full
flex-grow;
}
.CMS_Entries_entry-listing-grid-container {
@apply relative
h-full;
}
.CMS_Entries_entry-listing-cards {
@apply relative
w-card-grid
h-full
overflow-hidden
-ml-1;
}
.CMS_Entries_entry-listing-cards-grid-wrapper {
@apply overflow-hidden;
}
.CMS_Entries_entry-listing-cards-grid {
@apply !overflow-x-hidden
overflow-y-auto;
}
.CMS_Entries_entry-listing-table {
@apply relative
max-h-full
h-full
overflow-hidden
p-1.5
bg-white
shadow-sm
border
border-gray-100
dark:bg-slate-800
dark:border-gray-700/40
dark:shadow-md
rounded-xl;
}
.CMS_Entries_entry-listing-table-content {
@apply relative
h-full
overflow-auto;
}
.CMS_Entries_entry-listing-table-row {
@apply hover:bg-gray-200
dark:hover:bg-slate-700/70;
}
.CMS_Entries_entry-listing-local-backup {
@apply w-5
h-5
text-blue-600
dark:text-blue-300;
}

View File

@ -2,12 +2,15 @@ import React, { useMemo } from 'react';
import { translate } from 'react-polyglot';
import Loader from '@staticcms/core/components/common/progress/Loader';
import entriesClasses from './Entries.classes';
import EntryListing from './EntryListing';
import type { ViewStyle } from '@staticcms/core/constants/views';
import type { Collection, Collections, Entry, TranslatedProps } from '@staticcms/core/interface';
import type Cursor from '@staticcms/core/lib/util/Cursor';
import './Entries.css';
export interface BaseEntriesProps {
entries: Entry[];
page?: number;
@ -81,20 +84,7 @@ const Entries = ({
);
}
return (
<div
className="
py-2
px-3
rounded-md
bg-yellow-300/75
dark:bg-yellow-800/75
text-sm
"
>
{t('collection.entries.noEntries')}
</div>
);
return <div className={entriesClasses.root}>{t('collection.entries.noEntries')}</div>;
};
export default translate()(Entries);

View File

@ -6,11 +6,13 @@ import { loadEntries, traverseCollectionCursor } from '@staticcms/core/actions/e
import useEntries from '@staticcms/core/lib/hooks/useEntries';
import useGroups from '@staticcms/core/lib/hooks/useGroups';
import { Cursor } from '@staticcms/core/lib/util';
import classNames from '@staticcms/core/lib/util/classNames.util';
import { selectCollectionEntriesCursor } from '@staticcms/core/reducers/selectors/cursors';
import { selectEntriesLoaded, selectIsFetching } from '@staticcms/core/reducers/selectors/entries';
import { useAppDispatch } from '@staticcms/core/store/hooks';
import Button from '../../common/button/Button';
import Entries from './Entries';
import entriesClasses from './Entries.classes';
import type { ViewStyle } from '@staticcms/core/constants/views';
import type { Collection, Entry, GroupOfEntries, TranslatedProps } from '@staticcms/core/interface';
@ -115,25 +117,9 @@ const EntriesCollection = ({
if (groups && groups.length > 0) {
return (
<>
<div
className="
pb-3
"
>
<div
className="
-m-1
"
>
<div
className="
flex
gap-2
p-1
overflow-x-auto
hide-scrollbar
"
>
<div className={entriesClasses.group}>
<div className={entriesClasses['group-content-wrapper']}>
<div className={classNames(entriesClasses['group-content'], 'CMS_Scrollbar_hide')}>
{groups.map((group, index) => {
const title = getGroupTitle(group, t);
return (
@ -141,7 +127,7 @@ const EntriesCollection = ({
key={index}
variant={index === selectedGroup ? 'contained' : 'text'}
onClick={handleGroupClick(index)}
className="whitespace-nowrap"
className={entriesClasses['group-button']}
>
{title}
</Button>

View File

@ -0,0 +1,40 @@
.CMS_EntryCard_root {
@apply h-full
w-full
relative
overflow-visible;
}
.CMS_EntryCard_content-wrapper {
@apply absolute
-inset-1
pr-2;
}
.CMS_EntryCard_content {
@apply p-1
h-full
w-full;
}
.CMS_EntryCard_card {
@apply h-full;
}
.CMS_EntryCard_card-content {
@apply flex
w-full
items-center
justify-between;
}
.CMS_EntryCard_card-summary {
@apply truncate;
}
.CMS_EntryCard_local-backup-icon {
@apply w-5
h-5
text-blue-600
dark:text-blue-300;
}

View File

@ -9,6 +9,7 @@ import {
selectTemplateName,
} from '@staticcms/core/lib/util/collection.util';
import localForage from '@staticcms/core/lib/util/localForage';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import { selectConfig } from '@staticcms/core/reducers/selectors/config';
import { selectTheme } from '@staticcms/core/reducers/selectors/globalUI';
import { useAppSelector } from '@staticcms/core/store/hooks';
@ -27,6 +28,18 @@ import type {
} from '@staticcms/core/interface';
import type { FC } from 'react';
import './EntryCard.css';
export const classes = generateClassNames('EntryCard', [
'root',
'content-wrapper',
'content',
'card',
'card-content',
'card-summary',
'local-backup-icon',
]);
export interface EntryCardProps {
entry: Entry;
imageFieldName?: string | null | undefined;
@ -113,9 +126,9 @@ const EntryCard: FC<TranslatedProps<EntryCardProps>> = ({
if (PreviewCardComponent) {
return (
<div className="h-full w-full relative overflow-visible">
<div className="absolute -inset-1 pr-2">
<div className="p-1 h-full w-full">
<div className={classes.root}>
<div className={classes['content-wrapper']}>
<div className={classes.content}>
<Card>
<CardActionArea to={path}>
<PreviewCardComponent
@ -136,10 +149,10 @@ const EntryCard: FC<TranslatedProps<EntryCardProps>> = ({
}
return (
<div className="h-full w-full relative overflow-visible">
<div className="absolute -inset-1 pr-2">
<div className="p-1 h-full w-full">
<Card className="h-full" title={summary}>
<div className={classes.root}>
<div className={classes['content-wrapper']}>
<div className={classes.content}>
<Card className={classes.card} title={summary}>
<CardActionArea to={path}>
{image && imageField ? (
<CardMedia
@ -151,16 +164,11 @@ const EntryCard: FC<TranslatedProps<EntryCardProps>> = ({
/>
) : null}
<CardContent>
<div className="flex w-full items-center justify-between">
<div className="truncate">{summary}</div>
<div className={classes['card-content']}>
<div className={classes['card-summary']}>{summary}</div>
{hasLocalBackup ? (
<InfoIcon
className="
w-5
h-5
text-blue-600
dark:text-blue-300
"
className={classes['local-backup-icon']}
title={t('ui.localBackup.hasLocalBackup')}
/>
) : null}

View File

@ -4,6 +4,7 @@ import { translate } from 'react-polyglot';
import { VIEW_STYLE_TABLE } from '@staticcms/core/constants/views';
import { selectFields, selectInferredField } from '@staticcms/core/lib/util/collection.util';
import { toTitleCaseFromKey } from '@staticcms/core/lib/util/string.util';
import entriesClasses from './Entries.classes';
import EntryListingGrid from './EntryListingGrid';
import EntryListingTable from './EntryListingTable';
@ -154,7 +155,7 @@ const EntryListing: FC<TranslatedProps<EntryListingProps>> = ({
if (viewStyle === VIEW_STYLE_TABLE) {
return (
<div className="pb-3 overflow-hidden">
<div className={entriesClasses['entry-listing']}>
<EntryListingTable
key="table"
entryData={entryData}

View File

@ -12,6 +12,7 @@ import { getPreviewCard } from '@staticcms/core/lib/registry';
import classNames from '@staticcms/core/lib/util/classNames.util';
import { selectTemplateName } from '@staticcms/core/lib/util/collection.util';
import { isNotNullish } from '@staticcms/core/lib/util/null.util';
import entriesClasses from './Entries.classes';
import EntryCard from './EntryCard';
import type { CollectionEntryData } from '@staticcms/core/interface';
@ -138,15 +139,7 @@ const EntryListingCardGrid: FC<EntryListingCardGridProps> = ({
}, [cardHeights, prevCardHeights.length]);
return (
<div
className="
relative
w-card-grid
h-full
overflow-hidden
-ml-1
"
>
<div className={entriesClasses['entry-listing-cards']}>
<AutoSizer onResize={handleResize}>
{({ height = 0, width = 0 }) => {
const calculatedWidth = width - 4;
@ -161,11 +154,7 @@ const EntryListingCardGrid: FC<EntryListingCardGridProps> = ({
return (
<div
key={version}
className={classNames(
`
overflow-hidden
`,
)}
className={entriesClasses['entry-listing-cards-grid-wrapper']}
style={{
width: calculatedWidth,
height,
@ -212,11 +201,8 @@ const EntryListingCardGrid: FC<EntryListingCardGridProps> = ({
outerRef={scrollContainerRef}
onScroll={onScroll}
className={classNames(
`
!overflow-x-hidden
overflow-y-auto
styled-scrollbars
`,
entriesClasses['entry-listing-cards-grid'],
'CMS_Scrollbar_root',
)}
style={{ position: 'unset' }}
overscanRowCount={5}

View File

@ -3,6 +3,7 @@ import React, { useCallback, useEffect, useRef } from 'react';
import { isNotNullish } from '@staticcms/core/lib/util/null.util';
import { selectIsFetching } from '@staticcms/core/reducers/selectors/globalUI';
import { useAppSelector } from '@staticcms/core/store/hooks';
import entriesClasses from './Entries.classes';
import EntryListingCardGrid from './EntryListingCardGrid';
import type { CollectionEntryData } from '@staticcms/core/interface';
@ -57,8 +58,8 @@ const EntryListingGrid: FC<EntryListingGridProps> = ({
}, [handleScroll]);
return (
<div className="relative h-full flex-grow">
<div ref={gridContainerRef} className="relative h-full">
<div className={entriesClasses['entry-listing-grid']}>
<div ref={gridContainerRef} className={entriesClasses['entry-listing-grid-container']}>
<EntryListingCardGrid
key="grid"
entryData={entryData}
@ -68,18 +69,7 @@ const EntryListingGrid: FC<EntryListingGridProps> = ({
/>
</div>
{isLoadingEntries ? (
<div
key="loading"
className="
absolute
inset-0
flex
items-center
justify-center
bg-slate-50/50
dark:bg-slate-900/50
"
>
<div key="loading" className={entriesClasses['entry-listing-loading']}>
{t('collection.entries.loadingEntries')}
</div>
) : null}

View File

@ -1,10 +1,12 @@
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { useVirtual } from 'react-virtual';
import classNames from '@staticcms/core/lib/util/classNames.util';
import { isNotNullish } from '@staticcms/core/lib/util/null.util';
import { selectIsFetching } from '@staticcms/core/reducers/selectors/globalUI';
import { useAppSelector } from '@staticcms/core/store/hooks';
import Table from '../../common/table/Table';
import entriesClasses from './Entries.classes';
import EntryRow from './EntryRow';
import type { CollectionEntryData } from '@staticcms/core/interface';
@ -69,32 +71,14 @@ const EntryListingTable: FC<EntryListingTableProps> = ({
}, [clientHeight, fetchMoreOnBottomReached, scrollHeight, scrollTop]);
return (
<div
className="
relative
max-h-full
h-full
overflow-hidden
p-1.5
bg-white
shadow-sm
border
border-gray-100
dark:bg-slate-800
dark:border-gray-700/40
dark:shadow-md
rounded-xl
"
>
<div className={entriesClasses['entry-listing-table']}>
<div
ref={tableContainerRef}
className="
relative
h-full
overflow-auto
styled-scrollbars
styled-scrollbars-secondary
"
className={classNames(
entriesClasses['entry-listing-table-content'],
'CMS_Scrollbar_root',
'CMS_Scrollbar_secondary',
)}
>
<Table
columns={
@ -128,18 +112,7 @@ const EntryListingTable: FC<EntryListingTableProps> = ({
</Table>
</div>
{isLoadingEntries ? (
<div
key="loading"
className="
absolute
inset-0
flex
items-center
justify-center
bg-slate-50/50
dark:bg-slate-900/50
"
>
<div key="loading" className={entriesClasses['entry-listing-loading']}>
{t('collection.entries.loadingEntries')}
</div>
) : null}

View File

@ -15,6 +15,7 @@ import { selectTheme } from '@staticcms/core/reducers/selectors/globalUI';
import { useAppSelector } from '@staticcms/core/store/hooks';
import TableCell from '../../common/table/TableCell';
import TableRow from '../../common/table/TableRow';
import entriesClasses from './Entries.classes';
import type { BackupEntry, Collection, Entry, TranslatedProps } from '@staticcms/core/interface';
import type { FC } from 'react';
@ -75,13 +76,7 @@ const EntryRow: FC<TranslatedProps<EntryRowProps>> = ({
}, [collection.name, entry.slug]);
return (
<TableRow
className="
hover:bg-gray-200
dark:hover:bg-slate-700/70
"
to={path}
>
<TableRow className={entriesClasses['entry-listing-table-row']} to={path}>
{collectionLabel ? (
<TableCell key="collectionLabel" to={path}>
{collectionLabel}
@ -120,12 +115,7 @@ const EntryRow: FC<TranslatedProps<EntryRowProps>> = ({
<TableCell key="unsavedChanges" to={path} shrink>
{hasLocalBackup ? (
<InfoIcon
className="
w-5
h-5
text-blue-600
dark:text-blue-300
"
className={entriesClasses['entry-listing-local-backup']}
title={t('ui.localBackup.hasLocalBackup')}
/>
) : null}

View File

@ -0,0 +1,10 @@
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
const mobileCollectionControlsClasses = generateClassNames('MobileCollectionControls', [
'root',
'content',
'toggle',
'toggle-icon',
]);
export default mobileCollectionControlsClasses;

View File

@ -0,0 +1,37 @@
.CMS_MobileCollectionControls_root {
@apply w-[80%]
max-w-[240px];
& .MuiBackdrop-root {
@apply w-full;
}
& .MuiDrawer-paper {
@apply box-border
w-[80%]
max-w-[240px];
}
}
.CMS_MobileCollectionControls_content {
@apply px-5
py-4
flex
flex-col
gap-6
h-full
w-full
overflow-y-auto
bg-white
dark:bg-slate-800;
}
.CMS_MobileCollectionControls_toggle {
@apply flex
lg:!hidden;
}
.CMS_MobileCollectionControls_toggle-icon {
@apply w-5
h-5;
}

View File

@ -2,6 +2,7 @@ import { FilterList as FilterListIcon } from '@styled-icons/material/FilterList'
import React, { useCallback, useState } from 'react';
import IconButton from '../../common/button/IconButton';
import mobileCollectionControlsClasses from './MobileCollectionControls.classes';
import MobileCollectionControlsDrawer from './MobileCollectionControlsDrawer';
import type { FC } from 'react';
@ -9,6 +10,8 @@ import type { FilterControlProps } from '../FilterControl';
import type { GroupControlProps } from '../GroupControl';
import type { SortControlProps } from '../SortControl';
import './MobileCollectionControls.css';
export type MobileCollectionControlsProps = Omit<FilterControlProps, 'variant'> &
Omit<GroupControlProps, 'variant'> &
Omit<SortControlProps, 'variant'> & {
@ -25,8 +28,12 @@ const MobileCollectionControls: FC<MobileCollectionControlsProps> = props => {
return (
<>
<IconButton className="flex lg:hidden" variant="text" onClick={toggleMobileMenu}>
<FilterListIcon className="w-5 h-5" />
<IconButton
className={mobileCollectionControlsClasses.toggle}
variant="text"
onClick={toggleMobileMenu}
>
<FilterListIcon className={mobileCollectionControlsClasses['toggle-icon']} />
</IconButton>
<MobileCollectionControlsDrawer
{...props}

View File

@ -1,16 +1,16 @@
import Drawer from '@mui/material/Drawer';
import React, { useMemo } from 'react';
import classNames from '@staticcms/core/lib/util/classNames.util';
import FilterControl from '../FilterControl';
import GroupControl from '../GroupControl';
import SortControl from '../SortControl';
import mobileCollectionControlsClasses from './MobileCollectionControls.classes';
import type { FilterControlProps } from '../FilterControl';
import type { GroupControlProps } from '../GroupControl';
import type { SortControlProps } from '../SortControl';
const DRAWER_WIDTH = 240;
export type MobileCollectionControlsDrawerProps = Omit<FilterControlProps, 'variant'> &
Omit<GroupControlProps, 'variant'> &
Omit<SortControlProps, 'variant'> & {
@ -53,34 +53,11 @@ const MobileCollectionControlsDrawer = ({
ModalProps={{
keepMounted: true, // Better open performance on mobile.
}}
sx={{
width: '80%',
maxWidth: DRAWER_WIDTH,
'& .MuiBackdrop-root': {
width: '100%',
},
'& .MuiDrawer-paper': {
boxSizing: 'border-box',
width: '80%',
maxWidth: DRAWER_WIDTH,
},
}}
slotProps={{ root: { className: mobileCollectionControlsClasses.root } }}
>
<div
onClick={onMobileOpenToggle}
className="
px-5
py-4
flex
flex-col
gap-6
h-full
w-full
overflow-y-auto
bg-white
dark:bg-slate-800
styled-scrollbars
"
className={classNames(mobileCollectionControlsClasses.content, 'CMS_Scrollbar_root')}
>
{showSortControl ? (
<SortControl fields={fields} sort={sort} onSortClick={onSortClick} variant="list" />

View File

@ -0,0 +1,26 @@
.CMS_Alert_root {
@apply w-[50%]
min-w-[300px]
max-w-[600px];
}
.CMS_Alert_title {
@apply px-6
py-4
text-xl;
}
.CMS_Alert_content {
@apply px-6
pb-4
text-sm
text-slate-500
dark:text-slate-400;
}
.CMS_Alert_actions {
@apply p-2
flex
justify-end
gap-2;
}

View File

@ -2,12 +2,23 @@ import React, { useCallback, useMemo, useState } from 'react';
import { translate } from 'react-polyglot';
import AlertEvent from '@staticcms/core/lib/util/events/AlertEvent';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import { useWindowEvent } from '@staticcms/core/lib/util/window.util';
import Button from '../button/Button';
import Modal from '../modal/Modal';
import type { TranslateProps } from 'react-polyglot';
import './Alert.css';
export const classes = generateClassNames('Alert', [
'root',
'title',
'content',
'actions',
'confirm-button',
]);
interface AlertProps {
title: string | { key: string; options?: Record<string, unknown> };
body: string | { key: string; options?: Record<string, unknown> };
@ -67,44 +78,19 @@ const AlertDialog = ({ t }: TranslateProps) => {
<Modal
open
onClose={handleClose}
className="
w-[50%]
min-w-[300px]
max-w-[600px]
"
className={classes.root}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
>
<div
className="
px-6
py-4
text-xl
bold
"
<div className={classes.title}>{title}</div>
<div className={classes.content}>{body}</div>
<div className={classes.actions}>
<Button
onClick={handleClose}
variant="contained"
color={color}
className={classes['confirm-button']}
>
{title}
</div>
<div
className="
px-6
pb-4
text-sm
text-slate-500
dark:text-slate-400
"
>
{body}
</div>
<div
className="
p-2
flex
justify-end
gap-2
"
>
<Button onClick={handleClose} variant="contained" color={color}>
{okay}
</Button>
</div>

View File

@ -0,0 +1,123 @@
.CMS_Autocomplete_root {
@apply relative
w-full;
&.CMS_Autocomplete_disabled {
& .CMS_Autocomplete_input {
@apply text-gray-300
dark:text-gray-500;
}
& .CMS_Autocomplete_button-icon {
@apply text-gray-300/75
dark:text-gray-600/75;
}
}
}
.CMS_Autocomplete_input {
@apply w-full
bg-transparent
border-none
py-2
pl-3
pr-10
text-sm
leading-5
focus:ring-0
outline-none
flex-grow
truncate
text-gray-800
dark:text-gray-100;
}
.CMS_Autocomplete_button-wrapper {
@apply absolute
inset-y-0
right-0
flex
items-center
pr-2
gap-1;
}
.CMS_Autocomplete_button {
}
.CMS_Autocomplete_button-icon {
@apply h-5
w-5
text-gray-400;
}
.CMS_Autocomplete_options {
@apply max-h-60
w-full
overflow-auto
rounded-md
bg-white
py-1
text-base
shadow-md
ring-1
ring-black
ring-opacity-5
focus:outline-none
sm:text-sm
z-30
dark:bg-slate-700
dark:shadow-lg;
}
.CMS_Autocomplete_nothing {
@apply relative
cursor-default
select-none
py-2
px-4
text-gray-800
dark:text-gray-300;
}
.CMS_Autocomplete_option {
@apply relative
select-none
py-2
pl-10
pr-4
cursor-pointer
text-gray-800
dark:text-gray-100;
}
.CMS_Autocomplete_option-selected {
@apply bg-gray-100
dark:bg-slate-600;
}
.CMS_Autocomplete_option-selected {
& .CMS_Autocomplete_option-label {
@apply font-medium;
}
}
.CMS_Autocomplete_option-label {
@apply block
font-normal;
}
.CMS_Autocomplete_checkmark {
@apply absolute
inset-y-0
left-0
flex
items-center
pl-3
text-blue-500;
}
.CMS_Autocomplete_checkmark-icon {
@apply h-5
w-5;
}

View File

@ -1,262 +1,262 @@
import { Combobox, Transition } from '@headlessui/react';
import { Popper } from '@mui/base/Popper';
import { useAutocomplete } from '@mui/base/useAutocomplete';
import { unstable_useForkRef as useForkRef } from '@mui/utils';
import { Check as CheckIcon } from '@styled-icons/material/Check';
import { Close as CloseIcon } from '@styled-icons/material/Close';
import { KeyboardArrowDown as KeyboardArrowDownIcon } from '@styled-icons/material/KeyboardArrowDown';
import React, { Fragment, forwardRef, useCallback } from 'react';
import React, { useCallback, useMemo, useRef, useState } from 'react';
import useDebouncedCallback from '@staticcms/core/lib/hooks/useDebouncedCallback';
import classNames from '@staticcms/core/lib/util/classNames.util';
import { isNullish } from '@staticcms/core/lib/util/null.util';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import IconButton from '../button/IconButton';
import type { ReactNode, Ref } from 'react';
import type { MouseEvent, ReactNode, Ref } from 'react';
export interface Option<T> {
import './Autocomplete.css';
export const classes = generateClassNames('Autocomplete', [
'root',
'focused',
'disabled',
'input',
'button-wrapper',
'button',
'button-icon',
'options',
'nothing',
'option',
'option-selected',
'option-label',
'checkmark',
'checkmark-icon',
]);
export interface Option {
label: string;
value: T;
value: string;
}
function getOptionLabelAndValue<T>(option: T | Option<T>): Option<T> {
function getOptionLabelAndValue(option: string | Option): Option {
if (option && typeof option === 'object' && 'label' in option && 'value' in option) {
return option;
}
return { label: String(option), value: option };
return { label: option, value: option };
}
export type AutocompleteChangeEventHandler<T> = (value: T | T[]) => void;
export type AutocompleteChangeEventHandler = (value: string | string[]) => void;
export interface AutocompleteProps<T> {
export interface AutocompleteProps {
label: ReactNode | ReactNode[];
value: T | T[] | null;
options: T[] | Option<T>[];
value: string | string[] | null;
options: string[] | Option[];
disabled?: boolean;
required?: boolean;
displayValue: (item: T | T[] | null) => string;
inputRef?: Ref<HTMLInputElement>;
displayValue: (item: string | string[] | null) => string;
onQuery: (query: string) => void;
onChange: (value: T | T[] | undefined) => void;
onChange: (value: string | string[] | undefined) => void;
}
const Autocomplete = function <T>(
{
const Autocomplete = ({
label,
value,
options,
inputRef,
disabled,
required,
displayValue,
onQuery,
onChange,
}: AutocompleteProps<T>,
ref: Ref<HTMLInputElement>,
) {
const handleChange = useCallback(
(selectedValue: T) => {
if (Array.isArray(value)) {
const newValue = [...value];
const index = newValue.indexOf(selectedValue);
if (index > -1) {
newValue.splice(index, 1);
} else {
newValue.push(selectedValue);
}
onQuery,
}: AutocompleteProps) => {
const [inputValue, setInputValue] = useState('');
onChange(newValue);
const debouncedOnQuery = useDebouncedCallback(onQuery, 200);
const handleInputChange = useCallback(
(newInputValue: string) => {
setInputValue(newInputValue);
debouncedOnQuery(newInputValue);
},
[debouncedOnQuery],
);
const handleChange = useCallback(
(selectedValue: Option | readonly Option[] | null) => {
if (selectedValue === null) {
if (Array.isArray(value)) {
onChange([]);
return;
}
onChange(selectedValue);
onChange(undefined);
return;
}
if ('value' in selectedValue) {
onChange(selectedValue.value);
return;
}
onChange(selectedValue.map(option => option.value));
},
[onChange, value],
);
const clear = useCallback(() => {
const clear = useCallback(
(event: MouseEvent) => {
event.stopPropagation();
onChange(Array.isArray(value) ? [] : undefined);
}, [onChange, value]);
setInputValue('');
debouncedOnQuery('');
},
[debouncedOnQuery, onChange, value],
);
const finalOptions = useMemo(() => options.map(getOptionLabelAndValue), [options]);
const optionsByValue = useMemo(
() =>
finalOptions.reduce((acc, option) => {
acc[option.value] = option;
return acc;
}, {} as Record<string, Option>),
[finalOptions],
);
const finalValue = useMemo(() => {
if (isNullish(value)) {
return value;
}
if (typeof value === 'string') {
return optionsByValue[value];
}
return value.map(v => optionsByValue[v]).filter(v => Boolean(v));
}, [optionsByValue, value]);
const {
getRootProps,
getInputProps,
getListboxProps,
getOptionProps,
groupedOptions,
focused,
popupOpen,
anchorEl,
setAnchorEl,
} = useAutocomplete({
options: finalOptions,
value: finalValue,
inputValue,
multiple: Array.isArray(value),
disabled,
openOnFocus: true,
onChange: (_event, selectedValue) => handleChange(selectedValue),
onInputChange: (_event, newQueryInput) => handleInputChange(newQueryInput),
filterOptions: options => options,
clearOnBlur: false,
clearOnEscape: false,
});
const ref = useRef<HTMLDivElement>();
const rootRef = useForkRef(ref, setAnchorEl);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const localInputRef = (getInputProps() as any)
.ref as React.MutableRefObject<HTMLInputElement | null>;
const finalInputRef = useForkRef(localInputRef, inputRef);
const handleDropdownButtonClick = useCallback(() => {
localInputRef.current?.blur();
localInputRef.current?.click();
}, [localInputRef]);
const width = anchorEl?.clientWidth;
return (
<div className="relative w-full">
<Combobox value={value} onChange={handleChange} disabled={disabled}>
<div className="relative mt-1">
<React.Fragment>
<div
className="
flex
flex-col
flex-start
text-sm
font-medium
relative
min-h-8
p-0
w-full
text-gray-800
dark:text-gray-100
"
data-testid="autocomplete-input-wrapper"
{...getRootProps()}
ref={rootRef}
className={classNames(
classes.root,
focused && classes.focused,
disabled && classes.disabled,
)}
data-testid="autocomplete"
>
{label}
<Combobox.Input<'input', T | T[] | null>
ref={ref}
className={classNames(
`
w-full
bg-transparent
border-none
py-2
pl-3
pr-10
text-sm
leading-5
focus:ring-0
outline-none
flex-grow
truncate
`,
disabled
? `
text-gray-300
dark:text-gray-500
`
: `
text-gray-800
dark:text-gray-100
`,
)}
<input
{...getInputProps()}
ref={finalInputRef}
className={classes.input}
data-testid="autocomplete-input"
displayValue={displayValue}
onChange={event => onQuery(event.target.value)}
/>
<div className="absolute inset-y-0 right-0 flex items-center pr-2 gap-1">
<Combobox.Button>
<KeyboardArrowDownIcon
className={classNames(
`
h-5
w-5
text-gray-400
`,
disabled &&
`
text-gray-300/75
dark:text-gray-600/75
`,
)}
aria-hidden="true"
/>
</Combobox.Button>
<div className={classes['button-wrapper']}>
<IconButton
variant="text"
size="small"
disabled={disabled}
className={classes.button}
onClick={handleDropdownButtonClick}
>
<KeyboardArrowDownIcon className={classes['button-icon']} aria-hidden="true" />
</IconButton>
{!required ? (
<IconButton variant="text" disabled={disabled} onClick={clear}>
<CloseIcon
className={classNames(
`
h-5
w-5
text-gray-400
`,
disabled &&
`
text-gray-300/75
dark:text-gray-600/75
`,
)}
aria-hidden="true"
/>
<IconButton
variant="text"
size="small"
disabled={disabled}
className={classes.button}
onClick={clear}
>
<CloseIcon className={classes['button-icon']} aria-hidden="true" />
</IconButton>
) : null}
</div>
</div>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
afterLeave={() => onQuery('')}
{anchorEl && (
<Popper open={popupOpen} anchorEl={anchorEl} style={{ width }}>
<ul
{...getListboxProps()}
className={classNames(classes.options, 'CMS_Scrollbar_root', 'CMS_Scrollbar_secondary')}
>
<Combobox.Options
data-testid="autocomplete-options"
className={`
absolute
mt-1
max-h-60
w-full
overflow-auto
rounded-md
bg-white
py-1
text-base
shadow-md
ring-1
ring-black
ring-opacity-5
focus:outline-none
sm:text-sm
z-30
dark:bg-slate-700
dark:shadow-lg
`}
>
{options.length === 0 ? (
<div className="relative cursor-default select-none py-2 px-4 text-gray-800 dark:text-gray-300">
Nothing found.
</div>
) : (
options.map((option, index) => {
const { label: optionLabel, value: optionValue } = getOptionLabelAndValue(option);
{groupedOptions.length > 0 ? (
groupedOptions.map((option, index) => {
const { label: optionLabel, value: optionValue } = getOptionLabelAndValue(
option as Option,
);
const selected = Array.isArray(value)
? value.includes(optionValue)
: value === optionValue;
return (
<Combobox.Option
<li
key={index}
{...getOptionProps({ option: option as Option, index })}
className={classNames(classes.option, selected && classes['option-selected'])}
data-testid={`autocomplete-option-${optionValue}`}
className={({ active }) =>
classNames(
`
relative
select-none
py-2
pl-10
pr-4
cursor-pointer
text-gray-800
dark:text-gray-100
`,
(selected || active) &&
`
bg-gray-100
dark:bg-slate-600
`,
)
}
value={optionValue}
>
<span
className={classNames(
`
block
`,
selected ? 'font-medium' : 'font-normal',
)}
>
{optionLabel}
</span>
<span className={classes['option-label']}>{optionLabel}</span>
{selected ? (
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-blue-500">
<CheckIcon className="h-5 w-5" aria-hidden="true" />
<span className={classes.checkmark}>
<CheckIcon className={classes['checkmark-icon']} aria-hidden="true" />
</span>
) : null}
</Combobox.Option>
</li>
);
})
) : (
<div className={classes.nothing}>Nothing found.</div>
)}
</Combobox.Options>
</Transition>
</div>
</Combobox>
</div>
</ul>
</Popper>
)}
</React.Fragment>
);
};
export default forwardRef(Autocomplete) as <T>(
props: AutocompleteProps<T> & { ref: Ref<HTMLButtonElement> },
) => JSX.Element;
export default Autocomplete;

View File

@ -0,0 +1,11 @@
.CMS_Button_start-icon {
@apply w-5
h-5
mr-2;
}
.CMS_Button_end-icon {
@apply w-5
h-5
ml-2;
}

View File

@ -2,10 +2,12 @@ import React, { useMemo } from 'react';
import { Link } from 'react-router-dom';
import classNames from '@staticcms/core/lib/util/classNames.util';
import useButtonClassNames from './useButtonClassNames';
import useButtonClassNames, { buttonClasses } from './useButtonClassNames';
import type { CSSProperties, FC, MouseEventHandler, ReactNode, Ref } from 'react';
import './Button.css';
export interface BaseBaseProps {
variant?: 'contained' | 'outlined' | 'text';
color?: 'primary' | 'secondary' | 'success' | 'error' | 'warning';
@ -58,16 +60,16 @@ const Button: FC<ButtonLinkProps> = ({
const buttonClassName = useButtonClassNames(variant, color, size, rounded);
const buttonClassNames = useMemo(
() => classNames(buttonClassName, className),
() => classNames(className, buttonClassName),
[buttonClassName, className],
);
const content = useMemo(
() => (
<>
{StartIcon ? <StartIcon className="w-5 h-5 mr-2" /> : null}
{StartIcon ? <StartIcon className={buttonClasses['start-icon']} /> : null}
{children}
{EndIcon ? <EndIcon className="w-5 h-5 ml-2" /> : null}
{EndIcon ? <EndIcon className={buttonClasses['end-icon']} /> : null}
</>
),
[EndIcon, StartIcon, children],

View File

@ -0,0 +1,9 @@
.CMS_IconButton_root {
&.CMS_IconButton_sm {
@apply px-0.5;
}
&.CMS_IconButton_md {
@apply px-1.5;
}
}

View File

@ -1,11 +1,16 @@
import React from 'react';
import Button from './Button';
import classNames from '@staticcms/core/lib/util/classNames.util';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import Button from './Button';
import type { FC } from 'react';
import type { ButtonLinkProps } from './Button';
import './IconButton.css';
export const classes = generateClassNames('IconButton', ['root', 'sm', 'md']);
export type IconButtonProps = Omit<ButtonLinkProps, 'children'> & {
children: FC<{ className?: string }>;
};
@ -13,7 +18,12 @@ export type IconButtonProps = Omit<ButtonLinkProps, 'children'> & {
const IconButton = ({ children, size = 'medium', className, ...otherProps }: ButtonLinkProps) => {
return (
<Button
className={classNames(size === 'small' && 'px-0.5', size === 'medium' && 'px-1.5', className)}
className={classNames(
className,
classes.root,
size === 'small' && classes.sm,
size === 'medium' && classes.md,
)}
size={size}
{...otherProps}
>

View File

@ -1,31 +1,58 @@
import { useMemo } from 'react';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import type { BaseBaseProps } from './Button';
export const buttonClasses = generateClassNames('Button', [
'root-sm',
'root',
'root-rounded-no-padding',
'root-rounded-sm',
'root-rounded',
'contained-primary',
'contained-secondary',
'contained-success',
'contained-error',
'contained-warning',
'outlined-primary',
'outlined-secondary',
'outlined-success',
'outlined-error',
'outlined-warning',
'text-primary',
'text-secondary',
'text-success',
'text-error',
'text-warning',
'start-icon',
'end-icon',
]);
const classes: Record<
Required<BaseBaseProps>['variant'],
Record<Required<BaseBaseProps>['color'], string>
> = {
contained: {
primary: 'btn-contained-primary',
secondary: 'btn-contained-secondary',
success: 'btn-contained-success',
error: 'btn-contained-error',
warning: 'btn-contained-warning',
primary: 'CMS_Button_contained-primary',
secondary: 'CMS_Button_contained-secondary',
success: 'CMS_Button_contained-success',
error: 'CMS_Button_contained-error',
warning: 'CMS_Button_contained-warning',
},
outlined: {
primary: 'btn-outlined-primary',
secondary: 'btn-outlined-secondary',
success: 'btn-outlined-success',
error: 'btn-outlined-error',
warning: 'btn-outlined-warning',
primary: 'CMS_Button_outlined-primary',
secondary: 'CMS_Button_outlined-secondary',
success: 'CMS_Button_outlined-success',
error: 'CMS_Button_outlined-error',
warning: 'CMS_Button_outlined-warning',
},
text: {
primary: 'btn-text-primary',
secondary: 'btn-text-secondary',
success: 'btn-text-success',
error: 'btn-text-error',
warning: 'btn-text-warning',
primary: 'CMS_Button_text-primary',
secondary: 'CMS_Button_text-secondary',
success: 'CMS_Button_text-success',
error: 'CMS_Button_text-error',
warning: 'CMS_Button_text-warning',
},
};
@ -35,11 +62,11 @@ export default function useButtonClassNames(
size: Required<BaseBaseProps>['size'],
rounded: boolean | 'no-padding',
) {
let mainClass = size === 'small' ? 'btn-sm' : 'btn';
let mainClass = size === 'small' ? 'CMS_Button_root-sm' : 'CMS_Button_root';
if (rounded === 'no-padding') {
mainClass = 'btn-rounded-no-padding';
mainClass = 'CMS_Button_root-rounded-no-padding';
} else if (rounded) {
mainClass = size === 'small' ? 'btn-rounded-sm' : 'btn-rounded';
mainClass = size === 'small' ? 'CMS_Button_root-rounded-sm' : 'CMS_Button_root-rounded';
}
return useMemo(() => `${mainClass} ${classes[variant][color]}`, [color, mainClass, variant]);

View File

@ -0,0 +1,5 @@
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
const cardClasses = generateClassNames('Card', ['root', 'header', 'content', 'media', 'actions']);
export default cardClasses;

View File

@ -0,0 +1,55 @@
.CMS_Card_root {
@apply bg-white
border
border-gray-100
rounded-lg
shadow-sm
dark:bg-slate-800
dark:border-gray-700/40
dark:shadow-md
flex
flex-col
h-full;
}
.CMS_Card_header {
@apply mb-2
text-2xl
font-bold
tracking-tight
text-gray-800
dark:text-white;
}
.CMS_Card_content {
@apply w-full
p-5
font-normal
text-gray-800
dark:text-gray-300;
}
.CMS_Card_media {
@apply rounded-t-lg
bg-cover
bg-no-repeat
bg-center
w-full
object-cover;
}
.CMS_Card_actions {
@apply h-full
w-full
relative
flex
flex-col
rounded-lg
justify-start
hover:bg-gray-200
dark:hover:bg-slate-700/70
focus:outline-none
focus:ring-4
focus:ring-gray-200
dark:focus:ring-slate-700;
}

View File

@ -1,9 +1,12 @@
import React from 'react';
import classNames from '@staticcms/core/lib/util/classNames.util';
import cardClasses from './Card.classes';
import type { ReactNode } from 'react';
import './Card.css';
interface CardProps {
children: ReactNode | ReactNode[];
className?: string;
@ -12,25 +15,7 @@ interface CardProps {
const Card = ({ children, className, title }: CardProps) => {
return (
<div
className={classNames(
`
bg-white
border
border-gray-100
rounded-lg
shadow-sm
dark:bg-slate-800
dark:border-gray-700/40
dark:shadow-md
flex
flex-col
h-full
`,
className,
)}
title={title}
>
<div className={classNames(cardClasses.root, className)} title={title}>
{children}
</div>
);

View File

@ -1,6 +1,8 @@
import React from 'react';
import { Link } from 'react-router-dom';
import cardClasses from './Card.classes';
import type { ReactNode } from 'react';
interface CardActionAreaProps {
@ -10,24 +12,7 @@ interface CardActionAreaProps {
const CardActionArea = ({ to, children }: CardActionAreaProps) => {
return (
<Link
to={to}
className="
h-full
w-full
relative
flex
flex-col
rounded-lg
justify-start
hover:bg-gray-200
dark:hover:bg-slate-700/70
focus:outline-none
focus:ring-4
focus:ring-gray-200
dark:focus:ring-slate-700
"
>
<Link to={to} className={cardClasses.actions}>
{children}
</Link>
);

View File

@ -1,5 +1,7 @@
import React from 'react';
import cardClasses from './Card.classes';
import type { ReactNode } from 'react';
interface CardContentProps {
@ -7,7 +9,7 @@ interface CardContentProps {
}
const CardContent = ({ children }: CardContentProps) => {
return <p className="w-full p-5 font-normal text-gray-800 dark:text-gray-300">{children}</p>;
return <p className={cardClasses.content}>{children}</p>;
};
export default CardContent;

View File

@ -1,5 +1,7 @@
import React from 'react';
import cardClasses from './Card.classes';
import type { ReactNode } from 'react';
interface CardHeaderProps {
@ -7,11 +9,7 @@ interface CardHeaderProps {
}
const CardHeader = ({ children }: CardHeaderProps) => {
return (
<h5 className="mb-2 text-2xl font-bold tracking-tight text-gray-800 dark:text-white">
{children}
</h5>
);
return <h5 className={cardClasses.header}>{children}</h5>;
};
export default CardHeader;

View File

@ -1,6 +1,7 @@
import React from 'react';
import Image from '../image/Image';
import cardClasses from './Card.classes';
import type {
BaseField,
@ -31,7 +32,7 @@ const CardMedia = <EF extends BaseField = UnknownField>({
}: CardMediaProps<EF>) => {
return (
<Image
className="rounded-t-lg bg-cover bg-no-repeat bg-center w-full object-cover"
className={cardClasses.media}
style={{
width: width ? `${width}px` : undefined,
height: height ? `${height}px` : undefined,

View File

@ -0,0 +1,66 @@
.CMS_Checkbox_root {
@apply relative
inline-flex
items-center
cursor-pointer;
&.CMS_Checkbox_disabled {
@apply cursor-default;
& .CMS_Checkbox_input {
& + .CMS_Checkbox_custom-input {
@apply bg-blue-600/25
after:border-gray-500/75;
}
}
& .CMS_Checkbox_custom-input {
@apply bg-gray-100/75
dark:bg-gray-700/75;
}
}
}
.CMS_Checkbox_input {
@apply sr-only;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
-webkit-tap-highlight-color: transparent;
&:focus + .CMS_Checkbox_custom-input {
@apply ring-4
ring-blue-300
dark:ring-blue-800;
}
&:checked + .CMS_Checkbox_custom-input {
@apply after:translate-x-full
bg-blue-600
after:border-white;
}
}
.CMS_Checkbox_custom-input {
@apply w-6
h-6
text-blue-600
border-gray-300
rounded
focus:ring-blue-500
dark:focus:ring-blue-600
dark:ring-offset-gray-800
focus:ring-2
dark:border-gray-600
select-none
flex
items-center
justify-center
bg-gray-100
dark:bg-gray-700;
}
.CMS_Checkbox_checkmark {
@apply w-5
h-5
text-white;
}

View File

@ -2,9 +2,20 @@ import { Check as CheckIcon } from '@styled-icons/material/Check';
import React, { useCallback, useRef } from 'react';
import classNames from '@staticcms/core/lib/util/classNames.util';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import type { ChangeEventHandler, FC, KeyboardEvent, MouseEvent } from 'react';
import './Checkbox.css';
export const classes = generateClassNames('Checkbox', [
'root',
'disabled',
'input',
'custom-input',
'checkmark',
]);
export interface CheckboxProps {
checked: boolean;
disabled?: boolean;
@ -35,15 +46,7 @@ const Checkbox: FC<CheckboxProps> = ({ checked, disabled = false, onChange }) =>
return (
<label
className={classNames(
`
relative
inline-flex
items-center
cursor-pointer
`,
disabled && 'cursor-default',
)}
className={classNames(classes.root, disabled && classes.disabled)}
onClick={handleNoop}
onKeyDown={handleKeydown}
>
@ -52,59 +55,16 @@ const Checkbox: FC<CheckboxProps> = ({ checked, disabled = false, onChange }) =>
ref={inputRef}
type="checkbox"
checked={checked}
className="sr-only peer hide-tap"
className={classes.input}
disabled={disabled}
onChange={onChange}
onClick={handleNoop}
onKeyDown={handleKeydown}
/>
<div
className={classNames(
`
w-6
h-6
peer
peer-focus:ring-4
peer-focus:ring-blue-300
dark:peer-focus:ring-blue-800
peer-checked:after:translate-x-full
text-blue-600
border-gray-300
rounded
focus:ring-blue-500
dark:focus:ring-blue-600
dark:ring-offset-gray-800
focus:ring-2
dark:border-gray-600
select-none
flex
items-center
justify-center
`,
disabled
? `
peer-checked:bg-blue-600/25
peer-checked:after:border-gray-500/75
bg-gray-100/75
dark:bg-gray-700/75
`
: `
peer-checked:bg-blue-600
peer-checked:after:border-white
bg-gray-100
dark:bg-gray-700
`,
)}
onClick={handleClick}
onKeyDown={handleKeydown}
>
<div className={classes['custom-input']} onClick={handleClick} onKeyDown={handleKeydown}>
{checked ? (
<CheckIcon
className="
w-5
h-5
text-white
"
className={classes.checkmark}
onClick={handleClick}
onKeyDown={handleKeydown}
/>

View File

@ -0,0 +1,26 @@
.CMS_Confirm_root {
@apply w-[50%]
min-w-[300px]
max-w-[600px];
}
.CMS_Confirm_title {
@apply px-6
py-4
text-xl;
}
.CMS_Confirm_content {
@apply px-6
pb-4
text-sm
text-slate-500
dark:text-slate-400;
}
.CMS_Confirm_actions {
@apply p-2
flex
justify-end
gap-2;
}

View File

@ -2,12 +2,24 @@ import React, { useCallback, useMemo, useState } from 'react';
import { translate } from 'react-polyglot';
import ConfirmEvent from '@staticcms/core/lib/util/events/ConfirmEvent';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import { useWindowEvent } from '@staticcms/core/lib/util/window.util';
import Button from '../button/Button';
import Modal from '../modal/Modal';
import type { TranslateProps } from 'react-polyglot';
import './Confirm.css';
export const classes = generateClassNames('Confirm', [
'root',
'title',
'content',
'actions',
'confirm-button',
'cancel-button',
]);
interface ConfirmProps {
title: string | { key: string; options?: Record<string, unknown> };
body: string | { key: string; options?: Record<string, unknown> };
@ -83,47 +95,27 @@ const ConfirmDialog = ({ t }: TranslateProps) => {
<Modal
open
onClose={handleCancel}
className="
w-[50%]
min-w-[300px]
max-w-[600px]
"
className={classes.root}
aria-labelledby="confirm-dialog-title"
aria-describedby="confirm-dialog-description"
>
<div
className="
px-6
py-4
text-xl
bold
"
<div className={classes.title}>{title}</div>
<div className={classes.content}>{body}</div>
<div className={classes.actions}>
<Button
onClick={handleCancel}
variant="text"
color="secondary"
className={classes['cancel-button']}
>
{title}
</div>
<div
className="
px-6
pb-4
text-sm
text-slate-500
dark:text-slate-400
"
>
{body}
</div>
<div
className="
p-2
flex
justify-end
gap-2
"
>
<Button onClick={handleCancel} variant="text" color="secondary">
{cancel}
</Button>
<Button onClick={handleConfirm} variant="contained" color={color}>
<Button
onClick={handleConfirm}
variant="contained"
color={color}
className={classes['confirm-button']}
>
{confirm}
</Button>
</div>

View File

@ -0,0 +1,8 @@
.CMS_ErrorMessage_root {
@apply flex
w-full
text-xs
text-red-500
px-3
pt-2;
}

View File

@ -1,10 +1,15 @@
import React from 'react';
import classNames from '@staticcms/core/lib/util/classNames.util';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import type { FieldError } from '@staticcms/core/interface';
import type { FC } from 'react';
import './ErrorMessage.css';
export const classes = generateClassNames('ErrorMessage', ['root']);
export interface ErrorMessageProps {
errors: FieldError[];
className?: string;
@ -12,21 +17,7 @@ export interface ErrorMessageProps {
const ErrorMessage: FC<ErrorMessageProps> = ({ errors, className }) => {
return errors.length ? (
<div
key="error"
data-testid="error"
className={classNames(
`
flex
w-full
text-xs
text-red-500
px-3
pt-2
`,
className,
)}
>
<div key="error" data-testid="error" className={classNames(classes.root, className)}>
{errors[0].message}
</div>
) : null;

View File

@ -0,0 +1,59 @@
.CMS_Field_root {
@apply relative
flex
items-center
gap-2
border-b
border-slate-400
focus-within:border-blue-800
dark:focus-within:border-blue-100;
&:not(.CMS_Field_disabled):not(.CMS_Field_no-highlight) {
@apply focus-within:bg-slate-100
dark:focus-within:bg-slate-800
hover:bg-slate-100
dark:hover:bg-slate-800;
}
&:not(.CMS_Field_no-padding) {
@apply pb-3;
& > .CMS_Field_end-adornment {
@apply -mb-3;
}
}
}
.CMS_Field_cursor-pointer {
@apply cursor-pointer;
}
.CMS_Field_cursor-text {
@apply cursor-text;
}
.CMS_Field_cursor-default {
@apply cursor-default;
}
.CMS_Field_wrapper {
@apply flex
flex-col
w-full;
&.CMS_Field_for-single-list {
@apply mr-14;
}
}
.CMS_Field_inline-wrapper {
@apply flex
items-center
justify-center
p-3
pb-0;
}
.CMS_Field_end-adornment {
@apply pr-2;
}

View File

@ -2,6 +2,7 @@ import React, { useMemo } from 'react';
import useCursor from '@staticcms/core/lib/hooks/useCursor';
import classNames from '@staticcms/core/lib/util/classNames.util';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import ErrorMessage from './ErrorMessage';
import Hint from './Hint';
import Label from './Label';
@ -9,6 +10,25 @@ import Label from './Label';
import type { FieldError } from '@staticcms/core/interface';
import type { FC, MouseEvent, ReactNode } from 'react';
import './Field.css';
export const classes = generateClassNames('Field', [
'root',
'inline',
'wrapper',
'inline-wrapper',
'disabled',
'no-highlight',
'no-padding',
'cursor-pointer',
'cursor-text',
'cursor-default',
'error',
'valid',
'for-single-list',
'end-adornment',
]);
export interface FieldProps {
label?: string;
inputRef?: React.MutableRefObject<HTMLElement | null>;
@ -98,53 +118,34 @@ const Field: FC<FieldProps> = ({
const rootClassNames = useMemo(
() =>
classNames(
`
relative
flex
items-center
gap-2
border-b
border-slate-400
focus-within:border-blue-800
dark:focus-within:border-blue-100
`,
classes.root,
rootClassName,
!noHightlight &&
!disabled &&
`
focus-within:bg-slate-100
dark:focus-within:bg-slate-800
hover:bg-slate-100
dark:hover:bg-slate-800
`,
!noPadding && 'pb-3',
finalCursor === 'pointer' && 'cursor-pointer',
finalCursor === 'text' && 'cursor-text',
finalCursor === 'default' && 'cursor-default',
!hasErrors && 'group/active',
disabled && classes.disabled,
noHightlight && classes['no-highlight'],
noPadding && classes['no-padding'],
finalCursor === 'pointer' && classes['cursor-pointer'],
finalCursor === 'text' && classes['cursor-text'],
finalCursor === 'default' && classes['cursor-default'],
hasErrors ? classes.error : `group/active`,
),
[rootClassName, noHightlight, disabled, noPadding, finalCursor, hasErrors],
);
const wrapperClassNames = useMemo(
() =>
classNames(
`
flex
flex-col
w-full
`,
wrapperClassName,
forSingleList && 'mr-14',
),
classNames(classes.wrapper, wrapperClassName, forSingleList && classes['for-single-list']),
[forSingleList, wrapperClassName],
);
if (variant === 'inline') {
return (
<div data-testid="inline-field" className={rootClassNames} onClick={handleOnClick}>
<div
data-testid="inline-field"
className={`${rootClassNames} ${classes.inline}`}
onClick={handleOnClick}
>
<div data-testid="inline-field-wrapper" className={wrapperClassNames}>
<div className="flex items-center justify-center p-3 pb-0">
<div className={classes['inline-wrapper']}>
{renderedLabel}
{renderedHint}
{children}
@ -163,18 +164,7 @@ const Field: FC<FieldProps> = ({
{renderedHint}
{renderedErrorMessage}
</div>
{endAdornment ? (
<div
className={classNames(
`
pr-2
`,
!noPadding && '-mb-3',
)}
>
{endAdornment}
</div>
) : null}
{endAdornment ? <div className={classes['end-adornment']}>{endAdornment}</div> : null}
</div>
);
};

View File

@ -0,0 +1,44 @@
.CMS_Hint_root {
@apply w-full
flex
text-xs
italic;
&:not(.CMS_Hint_error) {
&.CMS_Hint_disabled {
@apply text-slate-300
dark:text-slate-600;
}
&:not(.CMS_Hint_disabled) {
@apply text-slate-500
dark:text-slate-400;
}
}
&:not(.CMS_Hint_disabled) {
@apply group-focus-within/active:text-blue-500
group-hover/active:text-blue-500;
}
&:not(.CMS_Hint_inline) {
@apply px-3
pt-1;
}
}
.CMS_Hint_cursor-pointer {
@apply cursor-pointer;
}
.CMS_Hint_cursor-text {
@apply cursor-text;
}
.CMS_Hint_cursor-default {
@apply cursor-default;
}
.CMS_Hint_error {
@apply text-red-500;
}

View File

@ -2,9 +2,22 @@ import React from 'react';
import useCursor from '@staticcms/core/lib/hooks/useCursor';
import classNames from '@staticcms/core/lib/util/classNames.util';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import type { FC } from 'react';
import './Hint.css';
export const classes = generateClassNames('Hint', [
'root',
'inline',
'disabled',
'cursor-pointer',
'cursor-text',
'cursor-default',
'error',
]);
export interface HintProps {
children: string;
hasErrors: boolean;
@ -28,32 +41,13 @@ const Hint: FC<HintProps> = ({
<div
data-testid="hint"
className={classNames(
`
w-full
flex
text-xs
italic
`,
!disabled &&
`
group-focus-within/active:text-blue-500
group-hover/active:text-blue-500
`,
finalCursor === 'pointer' && 'cursor-pointer',
finalCursor === 'text' && 'cursor-text',
finalCursor === 'default' && 'cursor-default',
hasErrors
? 'text-red-500'
: disabled
? `
text-slate-300
dark:text-slate-600
`
: `
text-slate-500
dark:text-slate-400
`,
variant === 'default' && 'px-3 pt-1',
classes.root,
disabled && classes.disabled,
finalCursor === 'pointer' && classes['cursor-pointer'],
finalCursor === 'text' && classes['cursor-text'],
finalCursor === 'default' && classes['cursor-default'],
hasErrors && classes.error,
variant === 'inline' && classes.inline,
className,
)}
>

View File

@ -0,0 +1,45 @@
.CMS_Label_root {
@apply w-full
flex
text-xs
font-bold
dark:font-semibold;
&:not(.CMS_Label_error) {
&.CMS_Label_disabled {
@apply text-slate-300
dark:text-slate-600;
}
&:not(.CMS_Label_disabled) {
@apply text-slate-500
dark:text-slate-400;
}
}
&:not(.CMS_Label_disabled) {
@apply group-focus-within/active:text-blue-500
group-hover/active:text-blue-500;
}
&:not(.CMS_Label_inline) {
@apply px-3
pt-3;
}
}
.CMS_Label_cursor-pointer {
@apply cursor-pointer;
}
.CMS_Label_cursor-text {
@apply cursor-text;
}
.CMS_Label_cursor-default {
@apply cursor-default;
}
.CMS_Label_error {
@apply text-red-500;
}

View File

@ -2,9 +2,22 @@ import React from 'react';
import useCursor from '@staticcms/core/lib/hooks/useCursor';
import classNames from '@staticcms/core/lib/util/classNames.util';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import type { FC } from 'react';
import './Label.css';
export const classes = generateClassNames('Label', [
'root',
'disabled',
'cursor-pointer',
'cursor-text',
'cursor-default',
'error',
'inline',
]);
export interface LabelProps {
htmlFor?: string;
children: string;
@ -33,33 +46,13 @@ const Label: FC<LabelProps> = ({
htmlFor={htmlFor}
data-testid={dataTestId ?? 'label'}
className={classNames(
`
w-full
flex
text-xs
font-bold
dark:font-semibold
`,
!disabled &&
`
group-focus-within/active:text-blue-500
group-hover/active:text-blue-500
`,
finalCursor === 'pointer' && 'cursor-pointer',
finalCursor === 'text' && 'cursor-text',
finalCursor === 'default' && 'cursor-default',
hasErrors
? 'text-red-500'
: disabled
? `
text-slate-300
dark:text-slate-600
`
: `
text-slate-500
dark:text-slate-400
`,
variant === 'default' && 'px-3 pt-3',
classes.root,
disabled && classes.disabled,
finalCursor === 'pointer' && classes['cursor-pointer'],
finalCursor === 'text' && classes['cursor-text'],
finalCursor === 'default' && classes['cursor-default'],
hasErrors && classes.error,
variant === 'inline' && classes.inline,
className,
)}
>

View File

@ -0,0 +1,16 @@
.CMS_Image_root {
&:not(.CMS_Image_empty) {
@apply object-cover
max-w-full
overflow-hidden;
}
&.CMS_Image_empty {
@apply p-10
rounded-md
border
max-w-full
border-gray-200/75
dark:border-slate-600/75;
}
}

View File

@ -1,11 +1,12 @@
import React from 'react';
import { Image as ImageIcon } from '@styled-icons/material-outlined/Image';
import React from 'react';
import useMediaAsset from '@staticcms/core/lib/hooks/useMediaAsset';
import classNames from '@staticcms/core/lib/util/classNames.util';
import { isEmpty } from '@staticcms/core/lib/util/string.util';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import { selectEditingDraft } from '@staticcms/core/reducers/selectors/entryDraft';
import { useAppSelector } from '@staticcms/core/store/hooks';
import { isEmpty } from '@staticcms/core/lib/util/string.util';
import type {
BaseField,
@ -16,6 +17,10 @@ import type {
} from '@staticcms/core/interface';
import type { CSSProperties } from 'react';
import './Image.css';
export const classes = generateClassNames('Image', ['root', 'empty']);
export interface ImageProps<EF extends BaseField> {
src?: string;
alt?: string;
@ -42,18 +47,7 @@ const Image = <EF extends BaseField = UnknownField>({
const assetSource = useMediaAsset(src, collection, field, entry ?? editingDraft);
if (isEmpty(src)) {
return (
<ImageIcon
className="
p-10
rounded-md
border
max-w-full
border-gray-200/75
dark:border-slate-600/75
"
/>
);
return <ImageIcon className={classNames(classes.root, classes.empty, className)} />;
}
return (
@ -63,7 +57,7 @@ const Image = <EF extends BaseField = UnknownField>({
src={assetSource}
alt={alt}
data-testid={dataTestId ?? 'image'}
className={classNames('object-cover max-w-full overflow-hidden', className)}
className={classNames(classes.root, className)}
style={style}
/>
);

View File

@ -0,0 +1,69 @@
.CMS_Menu_root {
@apply flex;
&:not(.CMS_Menu_hide-label) {
&:not(.CMS_Menu_hide-dropdown-icon) {
& .CMS_Menu_dropdown {
& .CMS_Menu_dropdown-start-icon {
@apply mr-1.5;
}
}
}
& .CMS_Menu_dropdown {
& .CMS_Menu_dropdown-icon {
@apply ml-2;
}
}
}
&.CMS_Menu_hide-dropdown-icon-mobile {
& .CMS_Menu_dropdown {
& .CMS_Menu_dropdown-start-icon {
@apply !mr-0
md:!mr-1.5;
}
& .CMS_Menu_dropdown-icon {
@apply !hidden
md:!block;
}
}
}
}
.CMS_Menu_dropdown {
@apply whitespace-nowrap;
}
.CMS_Menu_dropdown-start-icon {
@apply -ml-0.5
h-5
w-5;
}
.CMS_Menu_dropdown-icon {
@apply -mr-0.5
h-5
w-5;
}
.CMS_Menu_menu {
@apply absolute
right-0
z-40
w-56
origin-top-right
rounded-md
bg-white
dark:bg-slate-800
shadow-md
border
border-gray-200
focus:outline-none
divide-y
divide-gray-100
dark:border-gray-700
dark:divide-gray-600
dark:shadow-lg;
}

View File

@ -1,14 +1,30 @@
import ClickAwayListener from '@mui/base/ClickAwayListener';
import MenuUnstyled from '@mui/base/MenuUnstyled';
import { Dropdown } from '@mui/base/Dropdown';
import { Menu as BaseMenu } from '@mui/base/Menu';
import { MenuButton } from '@mui/base/MenuButton';
import { KeyboardArrowDown as KeyboardArrowDownIcon } from '@styled-icons/material/KeyboardArrowDown';
import React, { useCallback, useMemo } from 'react';
import React, { useMemo } from 'react';
import classNames from '@staticcms/core/lib/util/classNames.util';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import useButtonClassNames from '../button/useButtonClassNames';
import type { FC, ReactNode } from 'react';
import type { BaseBaseProps } from '../button/Button';
import './Menu.css';
export const classes = generateClassNames('Menu', [
'root',
'hide-dropdown-icon',
'hide-label',
'hide-dropdown-icon-mobile',
'dropdown',
'dropdown-start-icon',
'dropdown-icon',
'label',
'menu',
]);
export interface MenuProps {
label: ReactNode;
startIcon?: FC<{ className?: string }>;
@ -24,8 +40,8 @@ export interface MenuProps {
hideDropdownIcon?: boolean;
hideDropdownIconOnMobile?: boolean;
hideLabel?: boolean;
keepMounted?: boolean;
disabled?: boolean;
keepMounted?: boolean;
'data-testid'?: string;
}
@ -44,103 +60,56 @@ const Menu = ({
hideDropdownIcon = false,
hideDropdownIconOnMobile = false,
hideLabel = false,
keepMounted = false,
disabled = false,
keepMounted = false,
'data-testid': dataTestId,
}: MenuProps) => {
const [anchorEl, setAnchorEl] = React.useState<HTMLButtonElement | null>(null);
const isOpen = Boolean(anchorEl);
const handleButtonClick = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
if (isOpen) {
setAnchorEl(null);
} else {
setAnchorEl(event.currentTarget);
}
},
[isOpen],
);
const handleClose = useCallback(() => {
setAnchorEl(null);
}, []);
const calculatedButtonClassName = useButtonClassNames(variant, color, size, rounded);
const menuButtonClassNames = useMemo(
() => classNames(calculatedButtonClassName, buttonClassName, 'whitespace-nowrap'),
() => classNames(calculatedButtonClassName, buttonClassName, classes.dropdown),
[calculatedButtonClassName, buttonClassName],
);
return (
<ClickAwayListener mouseEvent="onMouseDown" touchEvent="onTouchStart" onClickAway={handleClose}>
<div className={classNames('flex', rootClassName)}>
<button
type="button"
onClick={handleButtonClick}
aria-controls={isOpen ? 'simple-menu' : undefined}
aria-expanded={isOpen || undefined}
<Dropdown>
<div
className={classNames(
classes.root,
hideLabel && classes['hide-label'],
hideDropdownIcon && classes['hide-dropdown-icon'],
hideDropdownIconOnMobile && classes['hide-dropdown-icon-mobile'],
rootClassName,
)}
>
<MenuButton
aria-haspopup="menu"
data-testid={dataTestId}
className={menuButtonClassNames}
disabled={disabled}
>
{StartIcon ? (
<StartIcon
className={classNames(
`-ml-0.5 h-5 w-5`,
!hideLabel && !hideDropdownIcon && 'mr-1.5',
hideDropdownIconOnMobile && '!mr-0 md:!mr-1.5',
iconClassName,
)}
/>
<StartIcon className={classNames(classes['dropdown-start-icon'], iconClassName)} />
) : null}
{!hideLabel ? (
<div className={classNames(classes.label, labelClassName)}>{label}</div>
) : null}
{!hideLabel ? <div className={labelClassName}>{label}</div> : null}
{!hideDropdownIcon ? (
<KeyboardArrowDownIcon
className={classNames(
`-mr-0.5 h-5 w-5`,
!hideLabel && 'ml-2',
hideDropdownIconOnMobile && '!hidden md:!block',
)}
aria-hidden="true"
/>
<KeyboardArrowDownIcon className={classes['dropdown-icon']} aria-hidden="true" />
) : null}
</button>
<MenuUnstyled
open={isOpen}
anchorEl={anchorEl}
keepMounted={keepMounted}
</MenuButton>
<BaseMenu
slotProps={{
root: {
className: `
absolute
right-0
z-40
w-56
origin-top-right
rounded-md
bg-white
dark:bg-slate-800
shadow-md
border
border-gray-200
focus:outline-none
divide-y
divide-gray-100
dark:border-gray-700
dark:divide-gray-600
dark:shadow-lg
`,
onClick: handleClose,
className: classes.menu,
keepMounted,
},
}}
>
{children}
</MenuUnstyled>
</BaseMenu>
</div>
</ClickAwayListener>
</Dropdown>
);
};

View File

@ -0,0 +1,6 @@
.CMS_MenuGroup_root {
@apply py-1
border-b
border-gray-200
dark:border-slate-700;
}

View File

@ -1,24 +1,19 @@
import React from 'react';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import type { ReactNode } from 'react';
import './MenuGroup.css';
export const classes = generateClassNames('MenuGroup', ['root']);
export interface MenuGroupProps {
children: ReactNode | ReactNode[];
}
const MenuGroup = ({ children }: MenuGroupProps) => {
return (
<div
className="
py-1
border-b
border-gray-200
dark:border-slate-700
"
>
{children}
</div>
);
return <div className={classes.root}>{children}</div>;
};
export default MenuGroup;

View File

@ -0,0 +1,84 @@
.CMS_MenuItemButton_root {
@apply px-4
py-2
text-sm
w-full
text-left
flex
items-center
justify-between
cursor-pointer;
&:not(.CMS_MenuItemButton_disabled) {
&.CMS_MenuItemButton_active {
@apply bg-slate-200
dark:bg-slate-600;
}
}
&.CMS_MenuItemButton_default {
@apply text-gray-800
dark:text-gray-300;
&.CMS_MenuItemButton_disabled {
@apply text-gray-500
dark:text-gray-700;
}
&:not(.CMS_MenuItemButton_disabled) {
@apply hover:bg-gray-200
dark:hover:bg-slate-600;
}
}
&.CMS_MenuItemButton_warning {
@apply text-yellow-600
dark:text-yellow-500;
&.CMS_MenuItemButton_disabled {
@apply text-yellow-300
dark:hover:bg-yellow-800;
}
&:not(.CMS_MenuItemButton_disabled) {
@apply hover:text-white
hover:bg-yellow-500
dark:hover:text-yellow-100
dark:hover:bg-yellow-600;
}
}
&.CMS_MenuItemButton_error {
@apply text-red-500
dark:text-red-500;
&.CMS_MenuItemButton_disabled {
@apply text-red-200
dark:hover:bg-red-800;
}
&:not(.CMS_MenuItemButton_disabled) {
@apply hover:text-white
hover:bg-red-500
dark:hover:text-red-100
dark:hover:bg-red-600;
}
}
}
.CMS_MenuItemButton_content {
@apply flex
items-center
gap-2
flex-grow;
}
.CMS_MenuItemButton_start-icon {
@apply h-5
w-5;
}
.CMS_MenuItemButton_end-icon {
@apply h-5
w-5;
}

View File

@ -1,10 +1,25 @@
import { MenuItem } from '@mui/base/MenuItem';
import React from 'react';
import MenuItemUnstyled from '@mui/base/MenuItemUnstyled';
import classNames from '@staticcms/core/lib/util/classNames.util';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import type { FC, MouseEvent, ReactNode } from 'react';
import './MenuItemButton.css';
export const classes = generateClassNames('MenuItemButton', [
'root',
'disabled',
'active',
'default',
'warning',
'error',
'content',
'start-icon',
'end-icon',
]);
export interface MenuItemButtonProps {
active?: boolean;
onClick: (event: MouseEvent) => void;
@ -29,50 +44,17 @@ const MenuItemButton = ({
'data-testid': dataTestId,
}: MenuItemButtonProps) => {
return (
<MenuItemUnstyled
<MenuItem
slotProps={{
root: {
className: classNames(
className,
active ? 'bg-slate-200 dark:bg-slate-600' : '',
`
px-4
py-2
text-sm
w-full
text-left
disabled:text-gray-300
flex
items-center
justify-between
cursor-pointer
dark:disabled:text-gray-800
`,
color === 'default' &&
`
text-gray-800
dark:text-gray-300
hover:bg-gray-200
dark:hover:bg-slate-600
`,
color === 'warning' &&
`
text-yellow-600
dark:text-yellow-500
hover:text-white
hover:bg-yellow-500
dark:hover:text-yellow-100
dark:hover:bg-yellow-600
`,
color === 'error' &&
`
text-red-500
dark:text-red-500
hover:text-white
hover:bg-red-500
dark:hover:text-red-100
dark:hover:bg-red-600
`,
classes.root,
disabled && classes.disabled,
active && classes.active,
color === 'default' && classes.default,
color === 'warning' && classes.warning,
color === 'error' && classes.error,
),
},
}}
@ -80,12 +62,12 @@ const MenuItemButton = ({
disabled={disabled}
data-testid={dataTestId}
>
<div className="flex items-center gap-2 flex-grow">
{StartIcon ? <StartIcon className="h-5 w-5" /> : null}
<div className={classes.content}>
{StartIcon ? <StartIcon className={classes['start-icon']} /> : null}
{children}
</div>
{EndIcon ? <EndIcon className="h-5 w-5" /> : null}
</MenuItemUnstyled>
{EndIcon ? <EndIcon className={classes['end-icon']} /> : null}
</MenuItem>
);
};

View File

@ -0,0 +1,36 @@
.CMS_MenuItemLink_root {
@apply px-4
py-2
text-sm
text-gray-800
dark:text-gray-300
w-full
text-left
flex
items-center
justify-between
hover:bg-slate-100
dark:hover:bg-slate-900;
&.CMS_MenuItemLink_active {
@apply bg-slate-100
dark:bg-slate-900;
}
}
.CMS_MenuItemLink_content {
@apply flex
items-center
gap-2
flex-grow;
}
.CMS_MenuItemLink_start-icon {
@apply h-5
w-5;
}
.CMS_MenuItemLink_end-icon {
@apply h-5
w-5;
}

View File

@ -1,11 +1,22 @@
import { MenuItem } from '@mui/base/MenuItem';
import React from 'react';
import { NavLink } from 'react-router-dom';
import MenuItemUnstyled from '@mui/base/MenuItemUnstyled';
import classNames from '@staticcms/core/lib/util/classNames.util';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import type { FC, ReactNode } from 'react';
import './MenuItemLink.css';
export const classes = generateClassNames('MenuItemLink', [
'root',
'active',
'content',
'start-icon',
'end-icon',
]);
export interface MenuItemLinkProps {
href: string;
children: ReactNode;
@ -24,38 +35,21 @@ const MenuItemLink = ({
endIcon: EndIcon,
}: MenuItemLinkProps) => {
return (
<MenuItemUnstyled
component={NavLink}
to={href}
<NavLink to={href}>
<MenuItem
slotProps={{
root: {
className: classNames(
className,
active ? 'bg-slate-100 dark:bg-slate-900' : '',
`
px-4
py-2
text-sm
text-gray-800
dark:text-gray-300
w-full
text-left
flex
items-center
justify-between
hover:bg-slate-100
dark:hover:bg-slate-900
`,
),
className: classNames(className, classes.root, active && classes.active),
},
}}
>
<div className="flex items-center gap-2 flex-grow">
{StartIcon ? <StartIcon className="h-5 w-5" /> : null}
<div className={classes.content}>
{StartIcon ? <StartIcon className={classes['start-icon']} /> : null}
{children}
</div>
{EndIcon ? <EndIcon className="h-5 w-5" /> : null}
</MenuItemUnstyled>
{EndIcon ? <EndIcon className={classes['end-icon']} /> : null}
</MenuItem>
</NavLink>
);
};

View File

@ -1,6 +1,7 @@
import React from 'react';
import classNames from '@staticcms/core/lib/util/classNames.util';
import modalClasses from './Modal.classes';
const Backdrop = React.forwardRef<
HTMLDivElement,
@ -9,18 +10,7 @@ const Backdrop = React.forwardRef<
const { open, className, ownerState: _ownerState, ...other } = props;
return (
<div
className={classNames(
`
fixed
inset-0
bg-black
bg-opacity-50
dark:bg-opacity-60
z-50
`,
open && 'MuiBackdrop-open',
className,
)}
className={classNames(modalClasses.backdrop, open && 'MuiBackdrop-open', className)}
ref={ref}
{...other}
/>

View File

@ -0,0 +1,5 @@
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
const modalClasses = generateClassNames('Modal', ['root', 'content', 'backdrop']);
export default modalClasses;

View File

@ -0,0 +1,34 @@
.CMS_Modal_root {
@apply fixed
inset-0
overflow-y-auto
z-50
flex
min-h-full
items-center
justify-center
text-center;
}
.CMS_Modal_backdrop {
@apply fixed
inset-0
bg-black
bg-opacity-50
dark:bg-opacity-60
z-50;
}
.CMS_Modal_content {
@apply transform
overflow-visible
rounded-lg
text-left
align-middle
shadow-xl
transition-all
bg-white
dark:bg-slate-800
z-[51]
outline-none;
}

View File

@ -1,11 +1,14 @@
import ModalUnstyled from '@mui/base/ModalUnstyled';
import { Modal as BaseModal } from '@mui/base/Modal';
import React, { useCallback } from 'react';
import classNames from '@staticcms/core/lib/util/classNames.util';
import Backdrop from './Backdrop';
import modalClasses from './Modal.classes';
import type { FC, ReactNode } from 'react';
import './Modal.css';
interface ModalProps {
open: boolean;
children: ReactNode;
@ -19,7 +22,7 @@ const Modal: FC<ModalProps> = ({ open, children, className, onClose }) => {
}, [onClose]);
return (
<ModalUnstyled
<BaseModal
open={open}
onClose={handleClose}
slots={{
@ -27,42 +30,12 @@ const Modal: FC<ModalProps> = ({ open, children, className, onClose }) => {
}}
slotProps={{
root: {
className: `
fixed
inset-0
overflow-y-auto
z-50
flex
min-h-full
items-center
justify-center
text-center
styled-scrollbars
`,
className: modalClasses.root,
},
}}
>
<div
className={classNames(
`
transform
overflow-visible
rounded-lg
text-left
align-middle
shadow-xl
transition-all
bg-white
dark:bg-slate-800
z-[51]
outline-none
`,
className,
)}
>
{children}
</div>
</ModalUnstyled>
<div className={classNames(modalClasses.content, className)}>{children}</div>
</BaseModal>
);
};

View File

@ -0,0 +1,42 @@
.CMS_Pill_root {
@apply text-xs
font-medium
px-3
py-1
rounded-lg
truncate;
&.CMS_Pill_no-wrap {
@apply whitespace-nowrap;
}
&.CMS_Pill_primary {
@apply bg-blue-700
text-gray-100
dark:bg-blue-700
dark:text-gray-100;
}
&.CMS_Pill_default {
@apply bg-gray-200
text-gray-800
dark:bg-gray-700
dark:text-gray-100;
}
&.CMS_Pill_disabled {
&.CMS_Pill_primary {
@apply bg-blue-300/75
text-gray-100/75
dark:bg-blue-700/25
dark:text-gray-500;
}
&.CMS_Pill_default {
@apply bg-gray-100
text-gray-400/75
dark:bg-gray-800/75
dark:text-gray-500;
}
}
}

View File

@ -1,9 +1,20 @@
import React, { useMemo } from 'react';
import classNames from '@staticcms/core/lib/util/classNames.util';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import type { FC, ReactNode } from 'react';
import './Pill.css';
export const classes = generateClassNames('Pill', [
'root',
'no-wrap',
'primary',
'default',
'disabled',
]);
interface PillProps {
children: ReactNode | ReactNode[];
noWrap?: boolean;
@ -22,48 +33,18 @@ const Pill: FC<PillProps> = ({
const colorClassNames = useMemo(() => {
switch (color) {
case 'primary':
return disabled
? `
bg-blue-300/75
text-gray-100/75
dark:bg-blue-700/25
dark:text-gray-500
`
: `
bg-blue-700
text-gray-100
dark:bg-blue-700
dark:text-gray-100
`;
return classes.primary;
default:
return disabled
? `
bg-gray-100
text-gray-400/75
dark:bg-gray-800/75
dark:text-gray-500
`
: `
bg-gray-200
text-gray-800
dark:bg-gray-700
dark:text-gray-100
`;
return classes.default;
}
}, [color, disabled]);
}, [color]);
return (
<span
className={classNames(
`
text-xs
font-medium
px-3
py-1
rounded-lg
truncate
`,
noWrap && 'whitespace-nowrap',
classes.root,
noWrap && classes['no-wrap'],
disabled && classes.disabled,
colorClassNames,
className,
)}

View File

@ -0,0 +1,21 @@
.CMS_CircularProgress_svg {
@apply mr-2
text-gray-200
animate-spin
dark:text-gray-600
fill-blue-600;
&.CMS_CircularProgress_md {
@apply w-8
h-8;
}
&.CMS_CircularProgress_sm {
@apply w-5
h-5;
}
}
.CMS_CircularProgress_sr-label {
@apply sr-only;
}

View File

@ -1,9 +1,20 @@
import React from 'react';
import classNames from '@staticcms/core/lib/util/classNames.util';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import type { FC } from 'react';
import './CircularProgress.css';
export const classes = generateClassNames('CircularProgress', [
'root',
'svg',
'md',
'sm',
'sr-label',
]);
export interface CircularProgressProps {
className?: string;
'data-testid'?: string;
@ -16,27 +27,13 @@ const CircularProgress: FC<CircularProgressProps> = ({
size = 'medium',
}) => {
return (
<div role="status" className={className} data-testid={dataTestId}>
<div role="status" className={classNames(classes.root, className)} data-testid={dataTestId}>
<svg
aria-hidden="true"
className={classNames(
`
mr-2
text-gray-200
animate-spin
dark:text-gray-600
fill-blue-600
`,
size === 'medium' &&
`
w-8
h-8
`,
size === 'small' &&
`
w-5
h-5
`,
classes.svg,
size === 'medium' && classes.md,
size === 'small' && classes.sm,
)}
viewBox="0 0 100 101"
fill="none"
@ -51,7 +48,7 @@ const CircularProgress: FC<CircularProgressProps> = ({
fill="currentFill"
/>
</svg>
<span className="sr-only">Loading...</span>
<span className={classes['sr-label']}>Loading...</span>
</div>
);
};

View File

@ -0,0 +1,11 @@
.CMS_Loader_root {
@apply absolute
inset-0
flex
flex-col
gap-2
items-center
justify-center
bg-slate-50
dark:bg-slate-900;
}

View File

@ -1,7 +1,12 @@
import React, { useEffect, useMemo, useState } from 'react';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import CircularProgress from './CircularProgress';
import './Loader.css';
export const classes = generateClassNames('Loader', ['root']);
export interface LoaderProps {
children: string | string[] | undefined;
}
@ -35,19 +40,7 @@ const Loader = ({ children }: LoaderProps) => {
}, [children, currentItem]);
return (
<div
className="
absolute
inset-0
flex
flex-col
gap-2
items-center
justify-center
bg-slate-50
dark:bg-slate-900
"
>
<div className={classes.root}>
<CircularProgress />
<div>{text}</div>
</div>

View File

@ -0,0 +1,29 @@
.CMS_SelectOption_root {
@apply relative
select-none
py-2
px-4
cursor-pointer
text-gray-800
hover:bg-blue-500
dark:text-gray-100;
&.CMS_SelectOption_selected {
@apply bg-blue-400/75;
& .CMS_SelectOption_label {
@apply font-medium;
}
}
&:not(.CMS_SelectOption_selected) {
& .CMS_SelectOption_label {
@apply font-normal;
}
}
}
.CMS_SelectOption_label {
@apply block
truncate;
}

View File

@ -1,11 +1,16 @@
import OptionUnstyled from '@mui/base/OptionUnstyled';
import { Option as BaseOption } from '@mui/base/Option';
import React, { useMemo } from 'react';
import classNames from '@staticcms/core/lib/util/classNames.util';
import { isNotNullish } from '@staticcms/core/lib/util/null.util';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import type { ReactNode } from 'react';
import './Option.css';
export const classes = generateClassNames('SelectOption', ['root', 'selected', 'label']);
export interface OptionProps<T> {
selectedValue: T | null | T[];
value: T | null;
@ -28,31 +33,17 @@ const Option = function <T>({
);
return (
<OptionUnstyled
<BaseOption
value={value}
data-testid={dataTestId}
slotProps={{
root: {
className: classNames(
`
relative
select-none
py-2
px-4
cursor-pointer
text-gray-800
hover:bg-blue-500
dark:text-gray-100
`,
selected ? 'bg-blue-400/75' : '',
),
className: classNames(classes.root, selected && classes.selected),
},
}}
>
<span className={classNames('block truncate', selected ? 'font-medium' : 'font-normal')}>
{children}
</span>
</OptionUnstyled>
<span className={classes.label}>{children}</span>
</BaseOption>
);
};

View File

@ -0,0 +1,81 @@
.CMS_Select_root {
@apply relative
w-full;
&.CMS_Select_disabled {
& .CMS_Select_input {
@apply text-gray-300/75
dark:text-gray-600/75;
}
& .CMS_Select_value {
& .CMS_Select_dropdown {
& .CMS_Select_dropdown-icon {
@apply text-gray-300/75
dark:text-gray-600/75;
}
}
}
}
}
.CMS_Select_value {
@apply w-full;
}
.CMS_Select_label {
@apply flex
w-select-widget-label;
}
.CMS_Select_label-text {
@apply truncate;
}
.CMS_Select_dropdown {
@apply pointer-events-none
absolute
inset-y-0
right-0
flex
items-center
pr-2;
}
.CMS_Select_dropdown-icon {
@apply h-5
w-5
text-gray-400;
}
.CMS_Select_input {
@apply flex
items-center
text-sm
font-medium
relative
min-h-8
px-4
py-1.5
w-full
text-gray-800
dark:text-gray-100;
}
.CMS_Select_popper {
@apply max-h-60
overflow-auto
rounded-md
bg-white
py-1
text-base
shadow-md
ring-1
ring-black
ring-opacity-5
focus:outline-none
sm:text-sm
z-[100]
dark:bg-slate-700
dark:shadow-lg;
}

View File

@ -1,14 +1,31 @@
import SelectUnstyled from '@mui/base/SelectUnstyled';
import { Select as BaseSelect } from '@mui/base/Select';
import ClickAwayListener from '@mui/material/ClickAwayListener';
import { KeyboardArrowDown as KeyboardArrowDownIcon } from '@styled-icons/material/KeyboardArrowDown';
import React, { forwardRef, useCallback, useState } from 'react';
import useElementSize from '@staticcms/core/lib/hooks/useElementSize';
import classNames from '@staticcms/core/lib/util/classNames.util';
import { isNotEmpty } from '@staticcms/core/lib/util/string.util';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import Option from './Option';
import type { FocusEvent, KeyboardEvent, MouseEvent, ReactNode, Ref } from 'react';
import './Select.css';
export const classes = generateClassNames('Select', [
'root',
'disabled',
'input',
'value',
'label',
'label-text',
'dropdown',
'dropdown-icon',
'input',
'popper',
]);
export interface Option {
label: string;
value: number | string;
@ -31,6 +48,7 @@ export interface SelectProps {
options: (number | string)[] | Option[];
required?: boolean;
disabled?: boolean;
rootClassName?: string;
onChange: SelectChangeEventHandler;
onOpenChange?: (open: boolean) => void;
}
@ -44,6 +62,7 @@ const Select = forwardRef(
options,
required = false,
disabled,
rootClassName,
onChange,
onOpenChange,
}: SelectProps,
@ -82,88 +101,47 @@ const Select = forwardRef(
[onChange, value],
);
const handleClick = useCallback(
(event: MouseEvent) => {
event.stopPropagation();
event.preventDefault();
handleOpenChange(!open);
},
[handleOpenChange, open],
);
const handleClickAway = useCallback(() => {
handleOpenChange(false);
}, [handleOpenChange]);
return (
<div className="relative w-full">
<ClickAwayListener onClickAway={handleClickAway}>
<div className={classNames(classes.root, rootClassName)}>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<SelectUnstyled<any>
<BaseSelect<any>
renderValue={() => {
return (
<div className="w-full">
<div className="flex flex-start w-select-widget-label">
<span className="truncate">{label ?? placeholder}</span>
<div className={classes.value}>
<div className={classes.label}>
<span className={classes['label-text']}>{label ?? placeholder}</span>
</div>
<span
className="
pointer-events-none
absolute
inset-y-0
right-0
flex
items-center
pr-2
"
>
<span className={classes.dropdown}>
<KeyboardArrowDownIcon
className={classNames(
`
h-5
w-5
text-gray-400
`,
disabled &&
`
text-gray-300/75
dark:text-gray-600/75
`,
)}
className={classes['dropdown-icon']}
aria-hidden="true"
/>
</span>
</div>
);
}}
ref={ref}
onClick={handleClick}
slotProps={{
root: {
ref,
className: classNames(
`
flex
items-center
text-sm
font-medium
relative
min-h-8
px-4
py-1.5
w-full
text-gray-800
dark:text-gray-100
`,
disabled &&
`
text-gray-300/75
dark:text-gray-600/75
`,
),
className: classes.input,
},
popper: {
className: `
max-h-60
overflow-auto
rounded-md
bg-white
py-1
text-base
shadow-md
ring-1
ring-black
ring-opacity-5
focus:outline-none
sm:text-sm
z-[100]
dark:bg-slate-700
dark:shadow-lg
`,
className: classes.popper,
style: { width: ref ? width : 'auto' },
disablePortal: false,
},
@ -172,7 +150,6 @@ const Select = forwardRef(
disabled={disabled}
onChange={handleChange}
listboxOpen={open}
onListboxOpenChange={handleOpenChange}
data-testid="select-input"
>
{!Array.isArray(value) && !required ? (
@ -194,8 +171,9 @@ const Select = forwardRef(
</Option>
);
})}
</SelectUnstyled>
</BaseSelect>
</div>
</ClickAwayListener>
);
},
);

View File

@ -0,0 +1,65 @@
.CMS_Switch_root {
@apply relative
inline-flex
items-center
cursor-pointer;
&.CMS_Switch_disabled {
@apply cursor-default;
& .CMS_Switch_toggle {
@apply peer-checked:bg-blue-600/25
after:bg-gray-500/75
after:border-gray-500/75
peer-checked:after:border-gray-500/75;
}
}
&:not(.CMS_Switch_disabled) {
& .CMS_Switch_toggle {
@apply after:bg-white
after:border-gray-300;
}
}
}
.CMS_Switch_input {
@apply sr-only;
&:focus + .CMS_Switch_toggle {
@apply ring-4
ring-blue-300
dark:ring-blue-800;
}
&:checked + .CMS_Switch_toggle {
@apply bg-blue-600
after:border-white
after:translate-x-full;
}
}
.CMS_Switch_toggle {
@apply w-11
h-6
bg-slate-200
rounded-full
dark:bg-slate-700
after:content-['']
after:absolute after:top-0.5
after:left-[2px]
after:border
after:rounded-full
after:h-5
after:w-5
after:transition-all
dark:border-gray-600;
}
.CMS_Switch_label {
@apply ml-3
text-sm
font-medium
text-gray-800
dark:text-gray-300;
}

View File

@ -1,18 +1,31 @@
import React, { forwardRef, useCallback } from 'react';
import classNames from '@staticcms/core/lib/util/classNames.util';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import type { ChangeEvent, ChangeEventHandler } from 'react';
import './Switch.css';
export const classes = generateClassNames('Switch', [
'root',
'disabled',
'input',
'toggle',
'label',
]);
export interface SwitchProps {
label?: string;
value: boolean;
disabled?: boolean;
rootClassName?: string;
inputClassName?: string;
onChange?: ChangeEventHandler<HTMLInputElement>;
}
const Switch = forwardRef<HTMLInputElement | null, SwitchProps>(
({ label, value, disabled, onChange }, ref) => {
({ label, value, disabled, rootClassName, inputClassName, onChange }, ref) => {
const handleChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
onChange?.(event);
@ -21,78 +34,19 @@ const Switch = forwardRef<HTMLInputElement | null, SwitchProps>(
);
return (
<label
className={classNames(
`
relative
inline-flex
items-center
cursor-pointer
`,
disabled && 'cursor-default',
)}
>
<label className={classNames(classes.root, disabled && classes.disabled, rootClassName)}>
<input
data-testid="switch-input"
ref={ref}
type="checkbox"
checked={value}
className="sr-only peer"
className={classNames(classes.input, inputClassName)}
disabled={disabled}
onChange={handleChange}
onClick={() => false}
/>
<div
className={classNames(
`
w-11
h-6
bg-slate-200
rounded-full
peer
peer-focus:ring-4
peer-focus:ring-blue-300
dark:peer-focus:ring-blue-800
dark:bg-slate-700
peer-checked:after:translate-x-full
after:content-['']
after:absolute after:top-0.5
after:left-[2px]
after:border
after:rounded-full
after:h-5
after:w-5
after:transition-all
dark:border-gray-600
`,
disabled
? `
peer-checked:bg-blue-600/25
after:bg-gray-500/75
after:border-gray-500/75
peer-checked:after:border-gray-500/75
`
: `
peer-checked:bg-blue-600
after:bg-white
after:border-gray-300
peer-checked:after:border-white
`,
)}
/>
{label ? (
<span
className="
ml-3
text-sm
font-medium
text-gray-800
dark:text-gray-300
"
>
{label}
</span>
) : null}
<div className={classes.toggle} />
{label ? <span className={classes.label}>{label}</span> : null}
</label>
);
},

View File

@ -0,0 +1,20 @@
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
const tableClasses = generateClassNames('Table', [
'root',
'table',
'header',
'header-row',
'header-cell',
'header-cell-content',
'body',
'body-row',
'body-cell',
'body-cell-has-link',
'body-cell-emphasis',
'body-cell-shrink',
'body-cell-content',
'body-cell-link',
]);
export default tableClasses;

View File

@ -0,0 +1,96 @@
.CMS_Table_root {
@apply z-[2];
}
.CMS_Table_table {
@apply w-full
text-sm
text-left
text-gray-500
dark:text-gray-300;
}
.CMS_Table_header {
@apply text-xs;
}
.CMS_Table_header-row {
@apply shadow-sm;
}
.CMS_Table_header-cell {
@apply font-bold
sticky
top-0
border-0
p-0;
}
.CMS_Table_header-cell-content {
@apply px-4
py-3
text-gray-800
border-gray-100
border-b
bg-white
dark:text-white
dark:border-gray-700
dark:bg-slate-800
text-[14px]
truncate
w-full;
}
.CMS_Table_body-row {
@apply border-t
first:border-t-0
border-gray-100
dark:border-gray-700
bg-white
hover:bg-slate-50
dark:bg-slate-800
dark:hover:bg-slate-700
focus:outline-none
focus:bg-gray-100
focus:dark:bg-slate-700;
}
.CMS_Table_body-cell {
@apply text-gray-500
dark:text-gray-300;
&.CMS_Table_body-cell-has-link {
@apply p-0;
}
&:not(.CMS_Table_body-cell-has-link) {
@apply px-4
py-3;
}
&.CMS_Table_body-cell-emphasis {
@apply font-medium
text-gray-800
whitespace-nowrap
dark:text-white;
}
&.CMS_Table_body-cell-shrink {
@apply w-0;
}
}
.CMS_Table_body-cell-content {
@apply h-[44px]
truncate
w-full;
}
.CMS_Table_body-cell-link {
@apply w-full
h-full
flex
px-4
py-3
whitespace-nowrap;
}

View File

@ -1,9 +1,12 @@
import React from 'react';
import tableClasses from './Table.classes';
import TableHeaderCell from './TableHeaderCell';
import type { ReactNode } from 'react';
import './Table.css';
interface TableCellProps {
columns: ReactNode[];
children: ReactNode[];
@ -11,20 +14,16 @@ interface TableCellProps {
const TableCell = ({ columns, children }: TableCellProps) => {
return (
<div
className="
z-[2]
"
>
<table className="w-full text-sm text-left text-gray-500 dark:text-gray-300">
<thead className="text-xs">
<tr className="shadow-sm">
<div className={tableClasses.root}>
<table className={tableClasses.table}>
<thead className={tableClasses.header}>
<tr className={tableClasses['header-row']}>
{columns.map((column, index) => (
<TableHeaderCell key={index}>{column}</TableHeaderCell>
))}
</tr>
</thead>
<tbody>{children}</tbody>
<tbody className={tableClasses.body}>{children}</tbody>
</table>
</div>
);

View File

@ -2,6 +2,7 @@ import React, { useMemo } from 'react';
import { Link } from 'react-router-dom';
import classNames from '@staticcms/core/lib/util/classNames.util';
import tableClasses from './Table.classes';
import type { ReactNode } from 'react';
@ -16,18 +17,7 @@ const TableCell = ({ children, emphasis = false, to, shrink = false }: TableCell
const content = useMemo(() => {
if (to) {
return (
<Link
to={to}
className="
w-full
h-full
flex
px-4
py-3
whitespace-nowrap
"
tabIndex={-1}
>
<Link to={to} className={tableClasses['body-cell-link']} tabIndex={-1}>
{children}
</Link>
);
@ -39,24 +29,13 @@ const TableCell = ({ children, emphasis = false, to, shrink = false }: TableCell
return (
<td
className={classNames(
!to ? 'px-4 py-3' : 'p-0',
`
text-gray-500
dark:text-gray-300
`,
emphasis && 'font-medium text-gray-800 whitespace-nowrap dark:text-white',
shrink && 'w-0',
tableClasses['body-cell'],
to && tableClasses['body-cell-has-link'],
emphasis && tableClasses['body-cell-emphasis'],
shrink && tableClasses['body-cell-shrink'],
)}
>
<div
className="
h-[44px]
truncate
w-full
"
>
{content}
</div>
<div className={tableClasses['body-cell-content']}>{content}</div>
</td>
);
};

View File

@ -1,7 +1,7 @@
import React from 'react';
import classNames from '@staticcms/core/lib/util/classNames.util';
import { isEmpty } from '@staticcms/core/lib/util/string.util';
import tableClasses from './Table.classes';
import type { ReactNode } from 'react';
@ -11,34 +11,8 @@ interface TableHeaderCellProps {
const TableHeaderCell = ({ children }: TableHeaderCellProps) => {
return (
<th
scope="col"
className={classNames(
`
font-bold
sticky
top-0
border-0
p-0
`,
)}
>
<div
className="
px-4
py-3
text-gray-800
border-gray-100
border-b
bg-white
dark:text-white
dark:border-gray-700
dark:bg-slate-800
text-[14px]
truncate
w-full
"
>
<th scope="col" className={tableClasses['header-cell']}>
<div className={tableClasses['header-cell-content']}>
{typeof children === 'string' && isEmpty(children) ? <>&nbsp;</> : children}
</div>
</th>

View File

@ -2,6 +2,7 @@ import React, { useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import classNames from '@staticcms/core/lib/util/classNames.util';
import tableClasses from './Table.classes';
import type { KeyboardEvent, ReactNode } from 'react';
@ -28,22 +29,7 @@ const TableRow = ({ children, className, to }: TableRowProps) => {
return (
<tr
className={classNames(
`
border-t
first:border-t-0
border-gray-100
dark:border-gray-700
bg-white
hover:bg-slate-50
dark:bg-slate-800
dark:hover:bg-slate-700
focus:outline-none
focus:bg-gray-100
focus:dark:bg-slate-700
`,
className,
)}
className={classNames(tableClasses['body-row'], className)}
tabIndex={to ? 0 : -1}
onKeyDown={handleKeyDown}
>

View File

@ -0,0 +1,18 @@
.CMS_TextArea_root {
@apply flex
w-full;
}
.CMS_TextArea_input {
@apply w-full
min-h-[80px]
px-3
bg-transparent
outline-none
text-sm
font-medium
text-gray-800
dark:text-gray-100
disabled:text-gray-300
dark:disabled:text-gray-500;
}

View File

@ -1,13 +1,21 @@
import InputUnstyled from '@mui/base/InputUnstyled';
import React, { forwardRef, useCallback, useState } from 'react';
import { Input } from '@mui/base/Input';
import React, { forwardRef, useCallback, useLayoutEffect, useState } from 'react';
import classNames from '@staticcms/core/lib/util/classNames.util';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import type { ChangeEventHandler, RefObject } from 'react';
import './TextArea.css';
export const classes = generateClassNames('TextArea', ['root', 'input']);
export interface TextAreaProps {
value: string;
disabled?: boolean;
placeholder?: string;
className?: string;
rootClassName?: string;
inputClassName?: string;
'data-testid'?: string;
onChange: ChangeEventHandler<HTMLInputElement>;
}
@ -20,7 +28,18 @@ function getHeight(rawHeight: string): number {
}
const TextArea = forwardRef<HTMLInputElement | null, TextAreaProps>(
({ value, disabled, placeholder, className, 'data-testid': dataTestId, onChange }, ref) => {
(
{
value,
disabled,
placeholder,
rootClassName,
inputClassName,
'data-testid': dataTestId,
onChange,
},
ref,
) => {
const [lastAutogrowHeight, setLastAutogrowHeight] = useState(MIN_TEXT_AREA_HEIGHT);
const autoGrow = useCallback(() => {
@ -43,16 +62,22 @@ const TextArea = forwardRef<HTMLInputElement | null, TextAreaProps>(
return;
}
textarea.style.height = `${newHeight}px`;
setLastAutogrowHeight(newHeight);
if (newHeight > MIN_TEXT_AREA_HEIGHT - MIN_BOTTOM_PADDING) {
textarea.style.paddingBottom = `${MIN_BOTTOM_PADDING}px`;
newHeight += MIN_BOTTOM_PADDING;
}
textarea.style.height = `${newHeight}px`;
setLastAutogrowHeight(newHeight);
}, [lastAutogrowHeight, ref]);
useLayoutEffect(() => {
autoGrow();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<InputUnstyled
<Input
multiline
minRows={4}
onInput={autoGrow}
@ -62,28 +87,12 @@ const TextArea = forwardRef<HTMLInputElement | null, TextAreaProps>(
data-testid={dataTestId ?? 'textarea-input'}
slotProps={{
root: {
className: `
flex
w-full
${className}
`,
className: classNames(classes.root, rootClassName),
},
input: {
ref,
placeholder,
className: `
w-full
min-h-[80px]
px-3
bg-transparent
outline-none
text-sm
font-medium
text-gray-800
dark:text-gray-100
disabled:text-gray-300
dark:disabled:text-gray-500
`,
className: classNames(classes.input, inputClassName),
},
}}
/>

Some files were not shown because too many files have changed in this diff Show More