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

View File

@ -12,3 +12,5 @@ export const translate = () => (Component: FC) => {
return React.createElement(Component, { t, ...props }); 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 { changeTheme } from '../actions/globalUI';
import { invokeEvent } from '../lib/registry'; import { invokeEvent } from '../lib/registry';
import { getDefaultPath } from '../lib/util/collection.util'; import { getDefaultPath } from '../lib/util/collection.util';
import { generateClassNames } from '../lib/util/theming.util';
import { selectTheme } from '../reducers/selectors/globalUI'; import { selectTheme } from '../reducers/selectors/globalUI';
import { useAppDispatch, useAppSelector } from '../store/hooks'; import { useAppDispatch, useAppSelector } from '../store/hooks';
import CollectionRoute from './collections/CollectionRoute'; import CollectionRoute from './collections/CollectionRoute';
@ -37,6 +38,10 @@ import type { RootState } from '@staticcms/core/store';
import type { ComponentType } from 'react'; import type { ComponentType } from 'react';
import type { ConnectedProps } from 'react-redux'; import type { ConnectedProps } from 'react-redux';
import './App.css';
export const classes = generateClassNames('App', ['root', 'content']);
TopBarProgress.config({ TopBarProgress.config({
barColors: { barColors: {
0: '#000', 0: '#000',
@ -267,8 +272,8 @@ const App = ({
<ScrollSync key="scroll-sync" enabled={scrollSyncEnabled}> <ScrollSync key="scroll-sync" enabled={scrollSyncEnabled}>
<> <>
<div key="back-to-top-anchor" id="back-to-top-anchor" /> <div key="back-to-top-anchor" id="back-to-top-anchor" />
<div key="cms-root" id="cms-root" className="h-full"> <div key="cms-root" id="cms-root" className={classes.root}>
<div key="cms-wrapper" className="cms-wrapper"> <div key="cms-wrapper" className={classes.content}>
<Snackbars key="snackbars" /> <Snackbars key="snackbars" />
{content} {content}
<Alert key="alert" /> <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 yaml from 'yaml';
import { localForage } from '@staticcms/core/lib/util'; import { localForage } from '@staticcms/core/lib/util';
import { generateClassNames } from '../lib/util/theming.util';
import type { Config, TranslatedProps } from '@staticcms/core/interface'; import type { Config, TranslatedProps } from '@staticcms/core/interface';
import type { ComponentClass, ReactNode } from 'react'; 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?'; const ISSUE_URL = 'https://github.com/StaticJsCMS/static-cms/issues/new?';
function getIssueTemplate(version: string, provider: string, browser: string, config: string) { 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 this.props.children;
} }
return ( return (
<div <div key="error-boundary-container" className={classes.root}>
key="error-boundary-container" <div className={classes.header}>
className=" <h1 className={classes.title}>{t('ui.errorBoundary.title')}</h1>
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>
<p> <p>
<span>{t('ui.errorBoundary.details')}</span> <span>{t('ui.errorBoundary.details')}</span>
<a <a
@ -175,10 +170,7 @@ class ErrorBoundary extends Component<TranslatedProps<ErrorBoundaryProps>, Error
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
data-testid="issue-url" data-testid="issue-url"
className=" className={classes['report-link']}
text-blue-500
hover:underline
"
> >
{t('ui.errorBoundary.reportIt')} {t('ui.errorBoundary.reportIt')}
</a> </a>
@ -193,19 +185,11 @@ class ErrorBoundary extends Component<TranslatedProps<ErrorBoundaryProps>, Error
</p> </p>
</div> </div>
<hr /> <hr />
<div <div className={classes.content}>
className=" <h2 className={classes['details-title']}>{t('ui.errorBoundary.detailsHeading')}</h2>
flex
flex-col
py-2
px-4
gap-2
"
>
<h2 className="text-xl bold">{t('ui.errorBoundary.detailsHeading')}</h2>
<p> <p>
{errorMessage.split('\n').map((item, index) => [ {errorMessage.split('\n').map((item, index) => [
<span key={`error-line-${index}`} className="whitespace-pre"> <span key={`error-line-${index}`} className={classes['error-line']}>
{item} {item}
</span>, </span>,
<br key={`error-break-${index}`} />, <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 TopBarProgress from 'react-topbar-progress-indicator';
import classNames from '../lib/util/classNames.util'; import classNames from '../lib/util/classNames.util';
import { generateClassNames } from '../lib/util/theming.util';
import BottomNavigation from './navbar/BottomNavigation'; import BottomNavigation from './navbar/BottomNavigation';
import Navbar from './navbar/Navbar'; import Navbar from './navbar/Navbar';
import Sidebar from './navbar/Sidebar'; import Sidebar from './navbar/Sidebar';
@ -9,6 +10,16 @@ import Sidebar from './navbar/Sidebar';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import type { Breadcrumb, Collection } from '../interface'; 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({ TopBarProgress.config({
barColors: { barColors: {
0: '#000', 0: '#000',
@ -46,20 +57,15 @@ const MainView = ({
showQuickCreate={showQuickCreate} showQuickCreate={showQuickCreate}
navbarActions={navbarActions} navbarActions={navbarActions}
/> />
<div className="flex bg-slate-50 dark:bg-slate-900"> <div className={classes.root}>
{showLeftNav ? <Sidebar /> : null} {showLeftNav ? <Sidebar /> : null}
<div <div
id="main-view" id="main-view"
className={classNames( className={classNames(
showLeftNav ? ' w-full left-0 md:w-main' : 'w-full', classes.body,
!noMargin && 'px-5 py-4', showLeftNav && classes['show-left-nav'],
noScroll ? 'overflow-hidden' : 'overflow-y-auto', noMargin && classes['no-margin'],
` noScroll && classes['no-scroll'],
h-main-mobile
md:h-main
relative
styled-scrollbars
`,
)} )}
> >
{children} {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 React, { useMemo } from 'react';
import ViewStyleControl from '../common/view-style/ViewStyleControl'; import ViewStyleControl from '../common/view-style/ViewStyleControl';
import collectionClasses from './Collection.classes';
import FilterControl from './FilterControl'; import FilterControl from './FilterControl';
import GroupControl from './GroupControl'; import GroupControl from './GroupControl';
import MobileCollectionControls from './mobile/MobileCollectionControls'; import MobileCollectionControls from './mobile/MobileCollectionControls';
@ -61,20 +62,7 @@ const CollectionControls = ({
return ( return (
<> <>
<div <div className={collectionClasses.controls}>
className="
flex
items-center
relative
z-20
w-full
justify-end
gap-1.5
sm:w-auto
sm:justify-normal
lg:gap-2
"
>
<ViewStyleControl viewStyle={viewStyle} onChangeViewStyle={onChangeViewStyle} /> <ViewStyleControl viewStyle={viewStyle} onChangeViewStyle={onChangeViewStyle} />
{showGroupControl || showFilterControl || showFilterControl ? ( {showGroupControl || showFilterControl || showFilterControl ? (
<MobileCollectionControls <MobileCollectionControls

View File

@ -4,6 +4,7 @@ import { useParams } from 'react-router-dom';
import useEntries from '@staticcms/core/lib/hooks/useEntries'; import useEntries from '@staticcms/core/lib/hooks/useEntries';
import useIcon from '@staticcms/core/lib/hooks/useIcon'; import useIcon from '@staticcms/core/lib/hooks/useIcon';
import useNewEntryUrl from '@staticcms/core/lib/hooks/useNewEntryUrl';
import { import {
selectEntryCollectionTitle, selectEntryCollectionTitle,
selectFolderEntryExtension, selectFolderEntryExtension,
@ -11,7 +12,7 @@ import {
import { isNotEmpty } from '@staticcms/core/lib/util/string.util'; import { isNotEmpty } from '@staticcms/core/lib/util/string.util';
import { addFileTemplateFields } from '@staticcms/core/lib/widgets/stringTemplate'; import { addFileTemplateFields } from '@staticcms/core/lib/widgets/stringTemplate';
import Button from '../common/button/Button'; 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 { Collection, Entry, TranslatedProps } from '@staticcms/core/interface';
import type { FC } from 'react'; import type { FC } from 'react';
@ -63,54 +64,19 @@ const CollectionHeader: FC<TranslatedProps<CollectionHeaderProps>> = ({ collecti
}, [collection, collectionLabel, entries, filterTerm]); }, [collection, collectionLabel, entries, filterTerm]);
return ( return (
<> <div className={collectionClasses['header-wrapper']}>
<div <h2 className={collectionClasses.header}>
className=" <div className={collectionClasses['header-icon']}>{icon}</div>
flex <div className={collectionClasses['header-label']}>{pluralLabel}</div>
flex-grow </h2>
gap-4 {newEntryUrl ? (
justify-normal <Button to={newEntryUrl} className={collectionClasses['new-entry-button']}>
xs:justify-between {t('collection.collectionTop.newButton', {
sm:justify-normal collectionLabel: collectionLabelSingular || pluralLabel,
w-full })}
truncate </Button>
" ) : null}
> </div>
<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>
</h2>
{newEntryUrl ? (
<Button to={newEntryUrl} className="hidden md:flex">
{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 { Search as SearchIcon } from '@styled-icons/material/Search';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { translate } from 'react-polyglot'; 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 { Collection, Collections, TranslatedProps } from '@staticcms/core/interface';
import type { ChangeEvent, FocusEvent, KeyboardEvent, MouseEvent } from 'react'; 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 { interface CollectionSearchProps {
collections: Collections; collections: Collections;
collection?: Collection; collection?: Collection;
@ -136,39 +152,21 @@ const CollectionSearch = ({
[submitSearch], [submitSearch],
); );
const handleClick = useCallback((event: MouseEvent) => { const handleClick = useCallback((event: MouseEvent<HTMLInputElement>) => {
event.stopPropagation(); event.stopPropagation();
setAnchorEl(event.currentTarget);
}, []); }, []);
return ( return (
<div> <div className={classes.root}>
<div className="relative"> <div className={classes.content}>
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none"> <div className={classes['icon-wrapper']}>
<SearchIcon className="w-5 h-5 text-gray-500 dark:text-gray-400" /> <SearchIcon className={classes.icon} />
</div> </div>
<input <input
type="text" type="text"
id="first_name" id="first_name"
className=" className={classes.input}
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
"
placeholder={t('collection.sidebar.searchAll')} placeholder={t('collection.sidebar.searchAll')}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onBlur={handleBlur} onBlur={handleBlur}
@ -178,57 +176,20 @@ const CollectionSearch = ({
onClick={handleClick} onClick={handleClick}
/> />
</div> </div>
<PopperUnstyled <BasePopper
open={open} open={open}
component="div"
placement="top" placement="top"
anchorEl={anchorEl} anchorEl={anchorEl}
tabIndex={0} tabIndex={0}
className=" className={classes['search-in']}
absolute slots={{
overflow-auto root: 'div',
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
"
> >
<div <div key="edit-content" contentEditable={false} className={classes['search-in-content']}>
key="edit-content" <div className={classes['search-in-label']}>{t('collection.sidebar.searchIn')}</div>
contentEditable={false}
className="
flex
flex-col
min-w-[200px]
"
>
<div <div
className=" className={classes['search-in-option']}
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
"
onClick={e => handleSuggestionClick(e, -1)} onClick={e => handleSuggestionClick(e, -1)}
onMouseDown={e => e.preventDefault()} onMouseDown={e => e.preventDefault()}
> >
@ -239,52 +200,13 @@ const CollectionSearch = ({
key={idx} key={idx}
onClick={e => handleSuggestionClick(e, idx)} onClick={e => handleSuggestionClick(e, idx)}
onMouseDown={e => e.preventDefault()} onMouseDown={e => e.preventDefault()}
className=" className={classes['search-in-option']}
cursor-pointer
hover:bg-blue-500
hover:color-gray-100
py-2
px-3
"
> >
{collection.label} {collection.label}
</div> </div>
))} ))}
</div> </div>
</PopperUnstyled> </BasePopper>
{/* <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> */}
</div> </div>
); );
}; };

View File

@ -21,6 +21,7 @@ import {
selectViewStyle, selectViewStyle,
} from '@staticcms/core/reducers/selectors/entries'; } from '@staticcms/core/reducers/selectors/entries';
import Card from '../common/card/Card'; import Card from '../common/card/Card';
import collectionClasses from './Collection.classes';
import CollectionControls from './CollectionControls'; import CollectionControls from './CollectionControls';
import CollectionHeader from './CollectionHeader'; import CollectionHeader from './CollectionHeader';
import EntriesCollection from './entries/EntriesCollection'; import EntriesCollection from './entries/EntriesCollection';
@ -37,6 +38,8 @@ import type { RootState } from '@staticcms/core/store';
import type { ComponentType } from 'react'; import type { ComponentType } from 'react';
import type { ConnectedProps } from 'react-redux'; import type { ConnectedProps } from 'react-redux';
import './Collection.css';
const CollectionView = ({ const CollectionView = ({
collection, collection,
collections, collections,
@ -183,11 +186,11 @@ const CollectionView = ({
const collectionDescription = collection?.description; const collectionDescription = collection?.description;
return ( return (
<div className="flex flex-col h-full px-5 pt-4 overflow-hidden"> <div className={collectionClasses.root}>
<div className="flex items-center mb-4 flex-row gap-4 sm:gap-0"> <div className={collectionClasses.content}>
{isSearchResults ? ( {isSearchResults ? (
<> <>
<div className="flex-grow"> <div className={collectionClasses['search-query']}>
<div>{t(searchResultKey, { searchTerm, collection: collection?.label })}</div> <div>{t(searchResultKey, { searchTerm, collection: collection?.label })}</div>
</div> </div>
<CollectionControls viewStyle={viewStyle} onChangeViewStyle={changeViewStyle} /> <CollectionControls viewStyle={viewStyle} onChangeViewStyle={changeViewStyle} />
@ -212,8 +215,8 @@ const CollectionView = ({
)} )}
</div> </div>
{collectionDescription ? ( {collectionDescription ? (
<div className="flex mb-4"> <div className={collectionClasses.description}>
<Card className="flex-grow px-3.5 py-2.5 text-sm">{collectionDescription}</Card> <Card className={collectionClasses['description-card']}>{collectionDescription}</Card>
</div> </div>
) : null} ) : null}
{entries} {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 React, { useCallback, useMemo } from 'react';
import { translate } from 'react-polyglot'; import { translate } from 'react-polyglot';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import Menu from '../common/menu/Menu'; import Menu from '../common/menu/Menu';
import MenuGroup from '../common/menu/MenuGroup'; import MenuGroup from '../common/menu/MenuGroup';
import MenuItemButton from '../common/menu/MenuItemButton'; 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 { FilterMap, TranslatedProps, ViewFilter } from '@staticcms/core/interface';
import type { FC, MouseEvent } from 'react'; 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 { export interface FilterControlProps {
filter: Record<string, FilterMap> | undefined; filter: Record<string, FilterMap> | undefined;
viewFilters: ViewFilter[] | undefined; viewFilters: ViewFilter[] | undefined;
@ -35,31 +48,15 @@ const FilterControl = ({
if (variant === 'list') { if (variant === 'list') {
return ( return (
<div key="filter-by-list" className="flex flex-col gap-2"> <div key="filter-by-list" className={classes['list-root']}>
<h3 <h3 className={classes['list-label']}>{t('collection.collectionTop.filterBy')}</h3>
className="
text-lg
font-bold
text-gray-800
dark:text-white
"
>
{t('collection.collectionTop.filterBy')}
</h3>
{viewFilters.map(viewFilter => { {viewFilters.map(viewFilter => {
const checked = Boolean(viewFilter.id && filter[viewFilter?.id]?.active) ?? false; const checked = Boolean(viewFilter.id && filter[viewFilter?.id]?.active) ?? false;
const labelId = `filter-list-label-${viewFilter.label}`; const labelId = `filter-list-label-${viewFilter.label}`;
return ( return (
<div <div
key={viewFilter.id} key={viewFilter.id}
className=" className={classes['list-filter']}
ml-1.5
font-medium
flex
items-center
text-gray-800
dark:text-gray-300
"
onClick={handleFilterClick(viewFilter)} onClick={handleFilterClick(viewFilter)}
> >
<input <input
@ -67,13 +64,10 @@ const FilterControl = ({
id={labelId} id={labelId}
type="checkbox" type="checkbox"
value="" value=""
className=""
checked={checked} checked={checked}
readOnly readOnly
/> />
<label className="ml-2 text-md font-medium text-gray-800 dark:text-gray-300"> <label className={classes['list-filter-label']}>{viewFilter.label}</label>
{viewFilter.label}
</label>
</div> </div>
); );
})} })}
@ -86,26 +80,27 @@ const FilterControl = ({
key="filter-by-menu" key="filter-by-menu"
label={t('collection.collectionTop.filterBy')} label={t('collection.collectionTop.filterBy')}
variant={anyActive ? 'contained' : 'outlined'} variant={anyActive ? 'contained' : 'outlined'}
rootClassName="hidden lg:block" rootClassName={classes.root}
> >
<MenuGroup> <MenuGroup>
{viewFilters.map(viewFilter => { {viewFilters.map(viewFilter => {
const checked = Boolean(viewFilter.id && filter[viewFilter?.id]?.active) ?? false; const checked = Boolean(viewFilter.id && filter[viewFilter?.id]?.active) ?? false;
const labelId = `filter-list-label-${viewFilter.label}`; const labelId = `filter-list-label-${viewFilter.label}`;
return ( return (
<MenuItemButton key={viewFilter.id} onClick={handleFilterClick(viewFilter)}> <MenuItemButton
key={viewFilter.id}
onClick={handleFilterClick(viewFilter)}
className={classes.filter}
>
<input <input
key={`${labelId}-${checked}`} key={`${labelId}-${checked}`}
id={labelId} id={labelId}
type="checkbox" type="checkbox"
value="" value=""
className=""
checked={checked} checked={checked}
readOnly readOnly
/> />
<label className="ml-2 text-sm font-medium text-gray-800 dark:text-gray-300"> <label className={classes['filter-label']}>{viewFilter.label}</label>
{viewFilter.label}
</label>
</MenuItemButton> </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 React, { useCallback, useMemo } from 'react';
import { translate } from 'react-polyglot'; import { translate } from 'react-polyglot';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import Menu from '../common/menu/Menu'; import Menu from '../common/menu/Menu';
import MenuGroup from '../common/menu/MenuGroup'; import MenuGroup from '../common/menu/MenuGroup';
import MenuItemButton from '../common/menu/MenuItemButton'; 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 { GroupMap, TranslatedProps, ViewGroup } from '@staticcms/core/interface';
import type { FC, MouseEvent } from 'react'; 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 { export interface GroupControlProps {
group: Record<string, GroupMap> | undefined; group: Record<string, GroupMap> | undefined;
viewGroups: ViewGroup[] | undefined; viewGroups: ViewGroup[] | undefined;
@ -36,39 +50,21 @@ const GroupControl = ({
if (variant === 'list') { if (variant === 'list') {
return ( return (
<div key="filter-by-list" className="flex flex-col gap-2"> <div key="filter-by-list" className={classes.list}>
<h3 <h3 className={classes['list-label']}>{t('collection.collectionTop.groupBy')}</h3>
className="
text-lg
font-bold
text-gray-800
dark:text-white
"
>
{t('collection.collectionTop.groupBy')}
</h3>
{viewGroups.map(viewGroup => { {viewGroups.map(viewGroup => {
const active = Boolean(viewGroup.id && group[viewGroup?.id]?.active) ?? false; const active = Boolean(viewGroup.id && group[viewGroup?.id]?.active) ?? false;
return ( return (
<div <div
key={viewGroup.id} key={viewGroup.id}
className=" className={classes['list-option']}
ml-0.5
font-medium
flex
items-center
text-gray-800
dark:text-gray-300
"
onClick={handleGroupClick(viewGroup)} onClick={handleGroupClick(viewGroup)}
> >
<label className="ml-2 text-md font-medium text-gray-800 dark:text-gray-300"> <label className={classes['list-option-label']}>{viewGroup.label}</label>
{viewGroup.label}
</label>
{active ? ( {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> </div>
); );
@ -81,7 +77,7 @@ const GroupControl = ({
<Menu <Menu
label={t('collection.collectionTop.groupBy')} label={t('collection.collectionTop.groupBy')}
variant={activeGroup ? 'contained' : 'outlined'} variant={activeGroup ? 'contained' : 'outlined'}
rootClassName="hidden lg:block" rootClassName={classes.root}
> >
<MenuGroup> <MenuGroup>
{viewGroups.map(viewGroup => ( {viewGroups.map(viewGroup => (
@ -89,6 +85,7 @@ const GroupControl = ({
key={viewGroup.id} key={viewGroup.id}
onClick={() => onGroupClick?.(viewGroup)} onClick={() => onGroupClick?.(viewGroup)}
endIcon={viewGroup.id === activeGroup?.id ? CheckIcon : undefined} endIcon={viewGroup.id === activeGroup?.id ? CheckIcon : undefined}
className={classes.option}
> >
{viewGroup.label} {viewGroup.label}
</MenuItemButton> </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 { Article as ArticleIcon } from '@styled-icons/material/Article';
import { ChevronRight as ChevronRightIcon } from '@styled-icons/material/ChevronRight'; import { ChevronRight as ChevronRightIcon } from '@styled-icons/material/ChevronRight';
import sortBy from 'lodash/sortBy'; 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 { useLocation } from 'react-router-dom';
import useEntries from '@staticcms/core/lib/hooks/useEntries'; import useEntries from '@staticcms/core/lib/hooks/useEntries';
import classNames from '@staticcms/core/lib/util/classNames.util'; import classNames from '@staticcms/core/lib/util/classNames.util';
import { getTreeData } from '@staticcms/core/lib/util/nested.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 NavLink from '../navbar/NavLink';
import type { Collection, Entry } from '@staticcms/core/interface'; import type { Collection, Entry } from '@staticcms/core/interface';
import type { TreeNodeData } from '@staticcms/core/lib/util/nested.util'; import type { TreeNodeData } from '@staticcms/core/lib/util/nested.util';
import type { MouseEvent } from 'react'; 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) { function getNodeTitle(node: TreeNodeData) {
const title = node.isRoot const title = node.isRoot
? node.title ? node.title
@ -23,28 +40,46 @@ function getNodeTitle(node: TreeNodeData) {
interface TreeNodeProps { interface TreeNodeProps {
collection: Collection; collection: Collection;
treeData: TreeNodeData[]; treeData: TreeNodeData[];
rootIsActive: boolean;
path: string;
depth?: number; depth?: number;
onToggle: ({ node, expanded }: { node: TreeNodeData; expanded: boolean }) => void; 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 collectionName = collection.name;
const handleClick = useCallback( const handleClick = useCallback(
(event: MouseEvent | undefined, node: TreeNodeData, expanded: boolean) => { (event: MouseEvent | undefined, node: TreeNodeData, expanded: boolean) => {
if (!rootIsActive) {
return;
}
event?.stopPropagation(); event?.stopPropagation();
event?.preventDefault(); event?.preventDefault();
if (event) { if (event) {
onToggle({ node, expanded }); onToggle({ node, expanded });
} else { } else {
onToggle({ node, expanded: true }); onToggle({ node, expanded: path === node.path ? expanded : true });
} }
}, },
[onToggle], [onToggle, path, rootIsActive],
); );
const sortedData = sortBy(treeData, getNodeTitle); const sortedData = sortBy(treeData, getNodeTitle);
if (depth !== 0 && !rootIsActive) {
return null;
}
return ( return (
<> <>
{sortedData.map(node => { {sortedData.map(node => {
@ -62,36 +97,42 @@ const TreeNode = ({ collection, treeData, depth = 0, onToggle }: TreeNodeProps)
return ( return (
<Fragment key={node.path}> <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 <NavLink
to={to} to={to}
onClick={() => handleClick(undefined, node, !node.expanded)} onClick={() => handleClick(undefined, node, !node.expanded)}
data-testid={node.path} 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> <div>{title}</div>
{hasChildren && ( {hasChildren && (
<ChevronRightIcon <ChevronRightIcon
onClick={event => handleClick(event, node, !node.expanded)} onClick={event => handleClick(event, node, !node.expanded)}
className={classNames( className={classes['node-children-icon']}
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
`,
)}
/> />
)} )}
</div> </div>
</NavLink> </NavLink>
<div className="mt-2 space-y-1.5"> <div className={classes['node-children']}>
{node.expanded && ( {node.expanded && (
<TreeNode <TreeNode
rootIsActive={rootIsActive}
collection={collection} collection={collection}
path={path}
depth={depth + 1} depth={depth + 1}
treeData={node.children} treeData={node.children}
onToggle={onToggle} onToggle={onToggle}
@ -153,30 +194,52 @@ const NestedCollection = ({ collection, filterTerm }: NestedCollectionProps) =>
const [treeData, setTreeData] = useState<TreeNodeData[]>(getTreeData(collection, entries)); const [treeData, setTreeData] = useState<TreeNodeData[]>(getTreeData(collection, entries));
const [useFilter, setUseFilter] = useState(true); const [useFilter, setUseFilter] = useState(true);
const [prevRootIsActive, setPrevRootIsActive] = useState(false);
const [prevCollection, setPrevCollection] = useState<Collection | null>(null); const [prevCollection, setPrevCollection] = useState<Collection | null>(null);
const [prevEntries, setPrevEntries] = useState<Entry[] | 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 { pathname } = useLocation();
const rootIsActive = useMemo(
() => pathname.startsWith(`/collections/${collection.name}`),
[collection.name, pathname],
);
const path = useMemo(() => `/${filterTerm}`, [filterTerm]);
useEffect(() => { useEffect(() => {
if (collection !== prevCollection || entries !== prevEntries || filterTerm !== prevFilterTerm) { if (
rootIsActive !== prevRootIsActive ||
collection !== prevCollection ||
entries !== prevEntries ||
path !== prevPath
) {
const expanded: Record<string, boolean> = {}; const expanded: Record<string, boolean> = {};
walk(treeData, node => { walk(treeData, node => {
if (!rootIsActive) {
expanded[node.path] = false;
return;
}
if (node.expanded) { if (node.expanded) {
expanded[node.path] = true; expanded[node.path] = true;
} }
}); });
const newTreeData = getTreeData(collection, entries); const newTreeData = getTreeData(collection, entries);
const path = `/${filterTerm}`;
walk(newTreeData, node => { walk(newTreeData, node => {
if ( if (!rootIsActive) {
expanded[node.path] || node.expanded = false;
(useFilter && return;
path.startsWith(node.path) && }
pathname.startsWith(`/collections/${collection.name}`))
) { if (node.isRoot) {
node.expanded = true;
return;
}
if (expanded[node.path] || (useFilter && path.startsWith(node.path))) {
node.expanded = true; node.expanded = true;
} }
}); });
@ -184,17 +247,21 @@ const NestedCollection = ({ collection, filterTerm }: NestedCollectionProps) =>
setTreeData(newTreeData); setTreeData(newTreeData);
} }
setPrevRootIsActive(rootIsActive);
setPrevCollection(collection); setPrevCollection(collection);
setPrevEntries(entries); setPrevEntries(entries);
setPrevFilterTerm(filterTerm); setPrevPath(path);
}, [ }, [
collection, collection,
entries, entries,
filterTerm, filterTerm,
path,
pathname, pathname,
prevCollection, prevCollection,
prevEntries, prevEntries,
prevFilterTerm, prevPath,
prevRootIsActive,
rootIsActive,
treeData, treeData,
useFilter, useFilter,
]); ]);
@ -212,7 +279,15 @@ const NestedCollection = ({ collection, filterTerm }: NestedCollectionProps) =>
[treeData], [treeData],
); );
return <TreeNode collection={collection} treeData={treeData} onToggle={onToggle} />; return (
<TreeNode
collection={collection}
treeData={treeData}
onToggle={onToggle}
rootIsActive={rootIsActive}
path={path}
/>
);
}; };
export default NestedCollection; 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_DESCENDING,
SORT_DIRECTION_NONE, SORT_DIRECTION_NONE,
} from '@staticcms/core/constants'; } from '@staticcms/core/constants';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import Menu from '../common/menu/Menu'; import Menu from '../common/menu/Menu';
import MenuGroup from '../common/menu/MenuGroup'; import MenuGroup from '../common/menu/MenuGroup';
import MenuItemButton from '../common/menu/MenuItemButton'; import MenuItemButton from '../common/menu/MenuItemButton';
@ -20,6 +21,19 @@ import type {
} from '@staticcms/core/interface'; } from '@staticcms/core/interface';
import type { FC, MouseEvent } from 'react'; 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) { function nextSortDirection(direction: SortDirection) {
switch (direction) { switch (direction) {
case SORT_DIRECTION_ASCENDING: case SORT_DIRECTION_ASCENDING:
@ -69,44 +83,32 @@ const SortControl = ({
if (variant === 'list') { if (variant === 'list') {
return ( return (
<div key="filter-by-list" className="flex flex-col gap-2"> <div key="filter-by-list" className={classes.list}>
<h3 <h3 className={classes['list-label']}>{t('collection.collectionTop.sortBy')}</h3>
className="
text-lg
font-bold
text-gray-800
dark:text-white
"
>
{t('collection.collectionTop.sortBy')}
</h3>
{fields.map(field => { {fields.map(field => {
const sortDir = sort?.[field.name]?.direction ?? SORT_DIRECTION_NONE; const sortDir = sort?.[field.name]?.direction ?? SORT_DIRECTION_NONE;
const nextSortDir = nextSortDirection(sortDir); const nextSortDir = nextSortDirection(sortDir);
return ( return (
<div <div
key={field.name} key={field.name}
className=" className={classes['list-option']}
ml-0.5
font-medium
flex
items-center
text-gray-800
dark:text-gray-300
"
onClick={handleSortClick(field.name, nextSortDir)} onClick={handleSortClick(field.name, nextSortDir)}
> >
<label className="ml-2 text-md font-medium text-gray-800 dark:text-gray-300"> <label className={classes['list-option-label']}>{field.label ?? field.name}</label>
{field.label ?? field.name}
</label>
{field.name === selectedSort.key ? ( {field.name === selectedSort.key ? (
selectedSort.direction === SORT_DIRECTION_ASCENDING ? ( 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> </div>
); );
@ -119,7 +121,7 @@ const SortControl = ({
<Menu <Menu
label={t('collection.collectionTop.sortBy')} label={t('collection.collectionTop.sortBy')}
variant={selectedSort.key ? 'contained' : 'outlined'} variant={selectedSort.key ? 'contained' : 'outlined'}
rootClassName="hidden lg:block" rootClassName={classes.root}
> >
<MenuGroup> <MenuGroup>
{fields.map(field => { {fields.map(field => {
@ -137,6 +139,7 @@ const SortControl = ({
: KeyboardArrowDownIcon : KeyboardArrowDownIcon
: undefined : undefined
} }
className={classes.option}
> >
{field.label ?? field.name} {field.label ?? field.name}
</MenuItemButton> </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 { translate } from 'react-polyglot';
import Loader from '@staticcms/core/components/common/progress/Loader'; import Loader from '@staticcms/core/components/common/progress/Loader';
import entriesClasses from './Entries.classes';
import EntryListing from './EntryListing'; import EntryListing from './EntryListing';
import type { ViewStyle } from '@staticcms/core/constants/views'; import type { ViewStyle } from '@staticcms/core/constants/views';
import type { Collection, Collections, Entry, TranslatedProps } from '@staticcms/core/interface'; import type { Collection, Collections, Entry, TranslatedProps } from '@staticcms/core/interface';
import type Cursor from '@staticcms/core/lib/util/Cursor'; import type Cursor from '@staticcms/core/lib/util/Cursor';
import './Entries.css';
export interface BaseEntriesProps { export interface BaseEntriesProps {
entries: Entry[]; entries: Entry[];
page?: number; page?: number;
@ -81,20 +84,7 @@ const Entries = ({
); );
} }
return ( return <div className={entriesClasses.root}>{t('collection.entries.noEntries')}</div>;
<div
className="
py-2
px-3
rounded-md
bg-yellow-300/75
dark:bg-yellow-800/75
text-sm
"
>
{t('collection.entries.noEntries')}
</div>
);
}; };
export default translate()(Entries); 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 useEntries from '@staticcms/core/lib/hooks/useEntries';
import useGroups from '@staticcms/core/lib/hooks/useGroups'; import useGroups from '@staticcms/core/lib/hooks/useGroups';
import { Cursor } from '@staticcms/core/lib/util'; 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 { selectCollectionEntriesCursor } from '@staticcms/core/reducers/selectors/cursors';
import { selectEntriesLoaded, selectIsFetching } from '@staticcms/core/reducers/selectors/entries'; import { selectEntriesLoaded, selectIsFetching } from '@staticcms/core/reducers/selectors/entries';
import { useAppDispatch } from '@staticcms/core/store/hooks'; import { useAppDispatch } from '@staticcms/core/store/hooks';
import Button from '../../common/button/Button'; import Button from '../../common/button/Button';
import Entries from './Entries'; import Entries from './Entries';
import entriesClasses from './Entries.classes';
import type { ViewStyle } from '@staticcms/core/constants/views'; import type { ViewStyle } from '@staticcms/core/constants/views';
import type { Collection, Entry, GroupOfEntries, TranslatedProps } from '@staticcms/core/interface'; import type { Collection, Entry, GroupOfEntries, TranslatedProps } from '@staticcms/core/interface';
@ -115,25 +117,9 @@ const EntriesCollection = ({
if (groups && groups.length > 0) { if (groups && groups.length > 0) {
return ( return (
<> <>
<div <div className={entriesClasses.group}>
className=" <div className={entriesClasses['group-content-wrapper']}>
pb-3 <div className={classNames(entriesClasses['group-content'], 'CMS_Scrollbar_hide')}>
"
>
<div
className="
-m-1
"
>
<div
className="
flex
gap-2
p-1
overflow-x-auto
hide-scrollbar
"
>
{groups.map((group, index) => { {groups.map((group, index) => {
const title = getGroupTitle(group, t); const title = getGroupTitle(group, t);
return ( return (
@ -141,7 +127,7 @@ const EntriesCollection = ({
key={index} key={index}
variant={index === selectedGroup ? 'contained' : 'text'} variant={index === selectedGroup ? 'contained' : 'text'}
onClick={handleGroupClick(index)} onClick={handleGroupClick(index)}
className="whitespace-nowrap" className={entriesClasses['group-button']}
> >
{title} {title}
</Button> </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, selectTemplateName,
} from '@staticcms/core/lib/util/collection.util'; } from '@staticcms/core/lib/util/collection.util';
import localForage from '@staticcms/core/lib/util/localForage'; 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 { selectConfig } from '@staticcms/core/reducers/selectors/config';
import { selectTheme } from '@staticcms/core/reducers/selectors/globalUI'; import { selectTheme } from '@staticcms/core/reducers/selectors/globalUI';
import { useAppSelector } from '@staticcms/core/store/hooks'; import { useAppSelector } from '@staticcms/core/store/hooks';
@ -27,6 +28,18 @@ import type {
} from '@staticcms/core/interface'; } from '@staticcms/core/interface';
import type { FC } from 'react'; 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 { export interface EntryCardProps {
entry: Entry; entry: Entry;
imageFieldName?: string | null | undefined; imageFieldName?: string | null | undefined;
@ -113,9 +126,9 @@ const EntryCard: FC<TranslatedProps<EntryCardProps>> = ({
if (PreviewCardComponent) { if (PreviewCardComponent) {
return ( return (
<div className="h-full w-full relative overflow-visible"> <div className={classes.root}>
<div className="absolute -inset-1 pr-2"> <div className={classes['content-wrapper']}>
<div className="p-1 h-full w-full"> <div className={classes.content}>
<Card> <Card>
<CardActionArea to={path}> <CardActionArea to={path}>
<PreviewCardComponent <PreviewCardComponent
@ -136,10 +149,10 @@ const EntryCard: FC<TranslatedProps<EntryCardProps>> = ({
} }
return ( return (
<div className="h-full w-full relative overflow-visible"> <div className={classes.root}>
<div className="absolute -inset-1 pr-2"> <div className={classes['content-wrapper']}>
<div className="p-1 h-full w-full"> <div className={classes.content}>
<Card className="h-full" title={summary}> <Card className={classes.card} title={summary}>
<CardActionArea to={path}> <CardActionArea to={path}>
{image && imageField ? ( {image && imageField ? (
<CardMedia <CardMedia
@ -151,16 +164,11 @@ const EntryCard: FC<TranslatedProps<EntryCardProps>> = ({
/> />
) : null} ) : null}
<CardContent> <CardContent>
<div className="flex w-full items-center justify-between"> <div className={classes['card-content']}>
<div className="truncate">{summary}</div> <div className={classes['card-summary']}>{summary}</div>
{hasLocalBackup ? ( {hasLocalBackup ? (
<InfoIcon <InfoIcon
className=" className={classes['local-backup-icon']}
w-5
h-5
text-blue-600
dark:text-blue-300
"
title={t('ui.localBackup.hasLocalBackup')} title={t('ui.localBackup.hasLocalBackup')}
/> />
) : null} ) : null}

View File

@ -4,6 +4,7 @@ import { translate } from 'react-polyglot';
import { VIEW_STYLE_TABLE } from '@staticcms/core/constants/views'; import { VIEW_STYLE_TABLE } from '@staticcms/core/constants/views';
import { selectFields, selectInferredField } from '@staticcms/core/lib/util/collection.util'; import { selectFields, selectInferredField } from '@staticcms/core/lib/util/collection.util';
import { toTitleCaseFromKey } from '@staticcms/core/lib/util/string.util'; import { toTitleCaseFromKey } from '@staticcms/core/lib/util/string.util';
import entriesClasses from './Entries.classes';
import EntryListingGrid from './EntryListingGrid'; import EntryListingGrid from './EntryListingGrid';
import EntryListingTable from './EntryListingTable'; import EntryListingTable from './EntryListingTable';
@ -154,7 +155,7 @@ const EntryListing: FC<TranslatedProps<EntryListingProps>> = ({
if (viewStyle === VIEW_STYLE_TABLE) { if (viewStyle === VIEW_STYLE_TABLE) {
return ( return (
<div className="pb-3 overflow-hidden"> <div className={entriesClasses['entry-listing']}>
<EntryListingTable <EntryListingTable
key="table" key="table"
entryData={entryData} 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 classNames from '@staticcms/core/lib/util/classNames.util';
import { selectTemplateName } from '@staticcms/core/lib/util/collection.util'; import { selectTemplateName } from '@staticcms/core/lib/util/collection.util';
import { isNotNullish } from '@staticcms/core/lib/util/null.util'; import { isNotNullish } from '@staticcms/core/lib/util/null.util';
import entriesClasses from './Entries.classes';
import EntryCard from './EntryCard'; import EntryCard from './EntryCard';
import type { CollectionEntryData } from '@staticcms/core/interface'; import type { CollectionEntryData } from '@staticcms/core/interface';
@ -138,15 +139,7 @@ const EntryListingCardGrid: FC<EntryListingCardGridProps> = ({
}, [cardHeights, prevCardHeights.length]); }, [cardHeights, prevCardHeights.length]);
return ( return (
<div <div className={entriesClasses['entry-listing-cards']}>
className="
relative
w-card-grid
h-full
overflow-hidden
-ml-1
"
>
<AutoSizer onResize={handleResize}> <AutoSizer onResize={handleResize}>
{({ height = 0, width = 0 }) => { {({ height = 0, width = 0 }) => {
const calculatedWidth = width - 4; const calculatedWidth = width - 4;
@ -161,11 +154,7 @@ const EntryListingCardGrid: FC<EntryListingCardGridProps> = ({
return ( return (
<div <div
key={version} key={version}
className={classNames( className={entriesClasses['entry-listing-cards-grid-wrapper']}
`
overflow-hidden
`,
)}
style={{ style={{
width: calculatedWidth, width: calculatedWidth,
height, height,
@ -212,11 +201,8 @@ const EntryListingCardGrid: FC<EntryListingCardGridProps> = ({
outerRef={scrollContainerRef} outerRef={scrollContainerRef}
onScroll={onScroll} onScroll={onScroll}
className={classNames( className={classNames(
` entriesClasses['entry-listing-cards-grid'],
!overflow-x-hidden 'CMS_Scrollbar_root',
overflow-y-auto
styled-scrollbars
`,
)} )}
style={{ position: 'unset' }} style={{ position: 'unset' }}
overscanRowCount={5} 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 { isNotNullish } from '@staticcms/core/lib/util/null.util';
import { selectIsFetching } from '@staticcms/core/reducers/selectors/globalUI'; import { selectIsFetching } from '@staticcms/core/reducers/selectors/globalUI';
import { useAppSelector } from '@staticcms/core/store/hooks'; import { useAppSelector } from '@staticcms/core/store/hooks';
import entriesClasses from './Entries.classes';
import EntryListingCardGrid from './EntryListingCardGrid'; import EntryListingCardGrid from './EntryListingCardGrid';
import type { CollectionEntryData } from '@staticcms/core/interface'; import type { CollectionEntryData } from '@staticcms/core/interface';
@ -57,8 +58,8 @@ const EntryListingGrid: FC<EntryListingGridProps> = ({
}, [handleScroll]); }, [handleScroll]);
return ( return (
<div className="relative h-full flex-grow"> <div className={entriesClasses['entry-listing-grid']}>
<div ref={gridContainerRef} className="relative h-full"> <div ref={gridContainerRef} className={entriesClasses['entry-listing-grid-container']}>
<EntryListingCardGrid <EntryListingCardGrid
key="grid" key="grid"
entryData={entryData} entryData={entryData}
@ -68,18 +69,7 @@ const EntryListingGrid: FC<EntryListingGridProps> = ({
/> />
</div> </div>
{isLoadingEntries ? ( {isLoadingEntries ? (
<div <div key="loading" className={entriesClasses['entry-listing-loading']}>
key="loading"
className="
absolute
inset-0
flex
items-center
justify-center
bg-slate-50/50
dark:bg-slate-900/50
"
>
{t('collection.entries.loadingEntries')} {t('collection.entries.loadingEntries')}
</div> </div>
) : null} ) : null}

View File

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

View File

@ -15,6 +15,7 @@ import { selectTheme } from '@staticcms/core/reducers/selectors/globalUI';
import { useAppSelector } from '@staticcms/core/store/hooks'; import { useAppSelector } from '@staticcms/core/store/hooks';
import TableCell from '../../common/table/TableCell'; import TableCell from '../../common/table/TableCell';
import TableRow from '../../common/table/TableRow'; import TableRow from '../../common/table/TableRow';
import entriesClasses from './Entries.classes';
import type { BackupEntry, Collection, Entry, TranslatedProps } from '@staticcms/core/interface'; import type { BackupEntry, Collection, Entry, TranslatedProps } from '@staticcms/core/interface';
import type { FC } from 'react'; import type { FC } from 'react';
@ -75,13 +76,7 @@ const EntryRow: FC<TranslatedProps<EntryRowProps>> = ({
}, [collection.name, entry.slug]); }, [collection.name, entry.slug]);
return ( return (
<TableRow <TableRow className={entriesClasses['entry-listing-table-row']} to={path}>
className="
hover:bg-gray-200
dark:hover:bg-slate-700/70
"
to={path}
>
{collectionLabel ? ( {collectionLabel ? (
<TableCell key="collectionLabel" to={path}> <TableCell key="collectionLabel" to={path}>
{collectionLabel} {collectionLabel}
@ -120,12 +115,7 @@ const EntryRow: FC<TranslatedProps<EntryRowProps>> = ({
<TableCell key="unsavedChanges" to={path} shrink> <TableCell key="unsavedChanges" to={path} shrink>
{hasLocalBackup ? ( {hasLocalBackup ? (
<InfoIcon <InfoIcon
className=" className={entriesClasses['entry-listing-local-backup']}
w-5
h-5
text-blue-600
dark:text-blue-300
"
title={t('ui.localBackup.hasLocalBackup')} title={t('ui.localBackup.hasLocalBackup')}
/> />
) : null} ) : 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 React, { useCallback, useState } from 'react';
import IconButton from '../../common/button/IconButton'; import IconButton from '../../common/button/IconButton';
import mobileCollectionControlsClasses from './MobileCollectionControls.classes';
import MobileCollectionControlsDrawer from './MobileCollectionControlsDrawer'; import MobileCollectionControlsDrawer from './MobileCollectionControlsDrawer';
import type { FC } from 'react'; import type { FC } from 'react';
@ -9,6 +10,8 @@ import type { FilterControlProps } from '../FilterControl';
import type { GroupControlProps } from '../GroupControl'; import type { GroupControlProps } from '../GroupControl';
import type { SortControlProps } from '../SortControl'; import type { SortControlProps } from '../SortControl';
import './MobileCollectionControls.css';
export type MobileCollectionControlsProps = Omit<FilterControlProps, 'variant'> & export type MobileCollectionControlsProps = Omit<FilterControlProps, 'variant'> &
Omit<GroupControlProps, 'variant'> & Omit<GroupControlProps, 'variant'> &
Omit<SortControlProps, 'variant'> & { Omit<SortControlProps, 'variant'> & {
@ -25,8 +28,12 @@ const MobileCollectionControls: FC<MobileCollectionControlsProps> = props => {
return ( return (
<> <>
<IconButton className="flex lg:hidden" variant="text" onClick={toggleMobileMenu}> <IconButton
<FilterListIcon className="w-5 h-5" /> className={mobileCollectionControlsClasses.toggle}
variant="text"
onClick={toggleMobileMenu}
>
<FilterListIcon className={mobileCollectionControlsClasses['toggle-icon']} />
</IconButton> </IconButton>
<MobileCollectionControlsDrawer <MobileCollectionControlsDrawer
{...props} {...props}

View File

@ -1,16 +1,16 @@
import Drawer from '@mui/material/Drawer'; import Drawer from '@mui/material/Drawer';
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import classNames from '@staticcms/core/lib/util/classNames.util';
import FilterControl from '../FilterControl'; import FilterControl from '../FilterControl';
import GroupControl from '../GroupControl'; import GroupControl from '../GroupControl';
import SortControl from '../SortControl'; import SortControl from '../SortControl';
import mobileCollectionControlsClasses from './MobileCollectionControls.classes';
import type { FilterControlProps } from '../FilterControl'; import type { FilterControlProps } from '../FilterControl';
import type { GroupControlProps } from '../GroupControl'; import type { GroupControlProps } from '../GroupControl';
import type { SortControlProps } from '../SortControl'; import type { SortControlProps } from '../SortControl';
const DRAWER_WIDTH = 240;
export type MobileCollectionControlsDrawerProps = Omit<FilterControlProps, 'variant'> & export type MobileCollectionControlsDrawerProps = Omit<FilterControlProps, 'variant'> &
Omit<GroupControlProps, 'variant'> & Omit<GroupControlProps, 'variant'> &
Omit<SortControlProps, 'variant'> & { Omit<SortControlProps, 'variant'> & {
@ -53,34 +53,11 @@ const MobileCollectionControlsDrawer = ({
ModalProps={{ ModalProps={{
keepMounted: true, // Better open performance on mobile. keepMounted: true, // Better open performance on mobile.
}} }}
sx={{ slotProps={{ root: { className: mobileCollectionControlsClasses.root } }}
width: '80%',
maxWidth: DRAWER_WIDTH,
'& .MuiBackdrop-root': {
width: '100%',
},
'& .MuiDrawer-paper': {
boxSizing: 'border-box',
width: '80%',
maxWidth: DRAWER_WIDTH,
},
}}
> >
<div <div
onClick={onMobileOpenToggle} onClick={onMobileOpenToggle}
className=" className={classNames(mobileCollectionControlsClasses.content, 'CMS_Scrollbar_root')}
px-5
py-4
flex
flex-col
gap-6
h-full
w-full
overflow-y-auto
bg-white
dark:bg-slate-800
styled-scrollbars
"
> >
{showSortControl ? ( {showSortControl ? (
<SortControl fields={fields} sort={sort} onSortClick={onSortClick} variant="list" /> <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 { translate } from 'react-polyglot';
import AlertEvent from '@staticcms/core/lib/util/events/AlertEvent'; 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 { useWindowEvent } from '@staticcms/core/lib/util/window.util';
import Button from '../button/Button'; import Button from '../button/Button';
import Modal from '../modal/Modal'; import Modal from '../modal/Modal';
import type { TranslateProps } from 'react-polyglot'; import type { TranslateProps } from 'react-polyglot';
import './Alert.css';
export const classes = generateClassNames('Alert', [
'root',
'title',
'content',
'actions',
'confirm-button',
]);
interface AlertProps { interface AlertProps {
title: string | { key: string; options?: Record<string, unknown> }; title: string | { key: string; options?: Record<string, unknown> };
body: string | { key: string; options?: Record<string, unknown> }; body: string | { key: string; options?: Record<string, unknown> };
@ -67,44 +78,19 @@ const AlertDialog = ({ t }: TranslateProps) => {
<Modal <Modal
open open
onClose={handleClose} onClose={handleClose}
className=" className={classes.root}
w-[50%]
min-w-[300px]
max-w-[600px]
"
aria-labelledby="alert-dialog-title" aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description" aria-describedby="alert-dialog-description"
> >
<div <div className={classes.title}>{title}</div>
className=" <div className={classes.content}>{body}</div>
px-6 <div className={classes.actions}>
py-4 <Button
text-xl onClick={handleClose}
bold 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} {okay}
</Button> </Button>
</div> </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 { Check as CheckIcon } from '@styled-icons/material/Check';
import { Close as CloseIcon } from '@styled-icons/material/Close'; import { Close as CloseIcon } from '@styled-icons/material/Close';
import { KeyboardArrowDown as KeyboardArrowDownIcon } from '@styled-icons/material/KeyboardArrowDown'; 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 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 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; 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) { if (option && typeof option === 'object' && 'label' in option && 'value' in option) {
return 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[]; label: ReactNode | ReactNode[];
value: T | T[] | null; value: string | string[] | null;
options: T[] | Option<T>[]; options: string[] | Option[];
disabled?: boolean; disabled?: boolean;
required?: boolean; required?: boolean;
displayValue: (item: T | T[] | null) => string; inputRef?: Ref<HTMLInputElement>;
displayValue: (item: string | string[] | null) => string;
onQuery: (query: string) => void; onQuery: (query: string) => void;
onChange: (value: T | T[] | undefined) => void; onChange: (value: string | string[] | undefined) => void;
} }
const Autocomplete = function <T>( const Autocomplete = ({
{ label,
label, value,
value, options,
options, inputRef,
disabled, disabled,
required, required,
displayValue, onChange,
onQuery, onQuery,
onChange, }: AutocompleteProps) => {
}: AutocompleteProps<T>, const [inputValue, setInputValue] = useState('');
ref: Ref<HTMLInputElement>,
) { const debouncedOnQuery = useDebouncedCallback(onQuery, 200);
const handleInputChange = useCallback(
(newInputValue: string) => {
setInputValue(newInputValue);
debouncedOnQuery(newInputValue);
},
[debouncedOnQuery],
);
const handleChange = useCallback( const handleChange = useCallback(
(selectedValue: T) => { (selectedValue: Option | readonly Option[] | null) => {
if (Array.isArray(value)) { if (selectedValue === null) {
const newValue = [...value]; if (Array.isArray(value)) {
const index = newValue.indexOf(selectedValue); onChange([]);
if (index > -1) { return;
newValue.splice(index, 1);
} else {
newValue.push(selectedValue);
} }
onChange(newValue); onChange(undefined);
return; return;
} }
onChange(selectedValue); if ('value' in selectedValue) {
onChange(selectedValue.value);
return;
}
onChange(selectedValue.map(option => option.value));
}, },
[onChange, value], [onChange, value],
); );
const clear = useCallback(() => { const clear = useCallback(
onChange(Array.isArray(value) ? [] : undefined); (event: MouseEvent) => {
}, [onChange, value]); event.stopPropagation();
onChange(Array.isArray(value) ? [] : undefined);
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 ( return (
<div className="relative w-full"> <React.Fragment>
<Combobox value={value} onChange={handleChange} disabled={disabled}> <div
<div className="relative mt-1"> {...getRootProps()}
<div ref={rootRef}
className=" className={classNames(
flex classes.root,
flex-col focused && classes.focused,
flex-start disabled && classes.disabled,
text-sm )}
font-medium data-testid="autocomplete"
relative >
min-h-8 {label}
p-0 <input
w-full {...getInputProps()}
text-gray-800 ref={finalInputRef}
dark:text-gray-100 className={classes.input}
" data-testid="autocomplete-input"
data-testid="autocomplete-input-wrapper" />
<div className={classes['button-wrapper']}>
<IconButton
variant="text"
size="small"
disabled={disabled}
className={classes.button}
onClick={handleDropdownButtonClick}
> >
{label} <KeyboardArrowDownIcon className={classes['button-icon']} aria-hidden="true" />
<Combobox.Input<'input', T | T[] | null> </IconButton>
ref={ref} {!required ? (
className={classNames( <IconButton
` variant="text"
w-full size="small"
bg-transparent disabled={disabled}
border-none className={classes.button}
py-2 onClick={clear}
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
`,
)}
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>
{!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>
) : null}
</div>
</div>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
afterLeave={() => onQuery('')}
>
<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 ? ( <CloseIcon className={classes['button-icon']} aria-hidden="true" />
<div className="relative cursor-default select-none py-2 px-4 text-gray-800 dark:text-gray-300"> </IconButton>
Nothing found. ) : null}
</div>
) : (
options.map((option, index) => {
const { label: optionLabel, value: optionValue } = getOptionLabelAndValue(option);
const selected = Array.isArray(value)
? value.includes(optionValue)
: value === optionValue;
return (
<Combobox.Option
key={index}
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>
{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>
) : null}
</Combobox.Option>
);
})
)}
</Combobox.Options>
</Transition>
</div> </div>
</Combobox> </div>
</div> {anchorEl && (
<Popper open={popupOpen} anchorEl={anchorEl} style={{ width }}>
<ul
{...getListboxProps()}
className={classNames(classes.options, 'CMS_Scrollbar_root', 'CMS_Scrollbar_secondary')}
>
{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 (
<li
key={index}
{...getOptionProps({ option: option as Option, index })}
className={classNames(classes.option, selected && classes['option-selected'])}
data-testid={`autocomplete-option-${optionValue}`}
>
<span className={classes['option-label']}>{optionLabel}</span>
{selected ? (
<span className={classes.checkmark}>
<CheckIcon className={classes['checkmark-icon']} aria-hidden="true" />
</span>
) : null}
</li>
);
})
) : (
<div className={classes.nothing}>Nothing found.</div>
)}
</ul>
</Popper>
)}
</React.Fragment>
); );
}; };
export default forwardRef(Autocomplete) as <T>( export default Autocomplete;
props: AutocompleteProps<T> & { ref: Ref<HTMLButtonElement> },
) => JSX.Element;

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

View File

@ -1,31 +1,58 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import type { BaseBaseProps } from './Button'; 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< const classes: Record<
Required<BaseBaseProps>['variant'], Required<BaseBaseProps>['variant'],
Record<Required<BaseBaseProps>['color'], string> Record<Required<BaseBaseProps>['color'], string>
> = { > = {
contained: { contained: {
primary: 'btn-contained-primary', primary: 'CMS_Button_contained-primary',
secondary: 'btn-contained-secondary', secondary: 'CMS_Button_contained-secondary',
success: 'btn-contained-success', success: 'CMS_Button_contained-success',
error: 'btn-contained-error', error: 'CMS_Button_contained-error',
warning: 'btn-contained-warning', warning: 'CMS_Button_contained-warning',
}, },
outlined: { outlined: {
primary: 'btn-outlined-primary', primary: 'CMS_Button_outlined-primary',
secondary: 'btn-outlined-secondary', secondary: 'CMS_Button_outlined-secondary',
success: 'btn-outlined-success', success: 'CMS_Button_outlined-success',
error: 'btn-outlined-error', error: 'CMS_Button_outlined-error',
warning: 'btn-outlined-warning', warning: 'CMS_Button_outlined-warning',
}, },
text: { text: {
primary: 'btn-text-primary', primary: 'CMS_Button_text-primary',
secondary: 'btn-text-secondary', secondary: 'CMS_Button_text-secondary',
success: 'btn-text-success', success: 'CMS_Button_text-success',
error: 'btn-text-error', error: 'CMS_Button_text-error',
warning: 'btn-text-warning', warning: 'CMS_Button_text-warning',
}, },
}; };
@ -35,11 +62,11 @@ export default function useButtonClassNames(
size: Required<BaseBaseProps>['size'], size: Required<BaseBaseProps>['size'],
rounded: boolean | 'no-padding', 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') { if (rounded === 'no-padding') {
mainClass = 'btn-rounded-no-padding'; mainClass = 'CMS_Button_root-rounded-no-padding';
} else if (rounded) { } 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]); 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 React from 'react';
import classNames from '@staticcms/core/lib/util/classNames.util'; import classNames from '@staticcms/core/lib/util/classNames.util';
import cardClasses from './Card.classes';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import './Card.css';
interface CardProps { interface CardProps {
children: ReactNode | ReactNode[]; children: ReactNode | ReactNode[];
className?: string; className?: string;
@ -12,25 +15,7 @@ interface CardProps {
const Card = ({ children, className, title }: CardProps) => { const Card = ({ children, className, title }: CardProps) => {
return ( return (
<div <div className={classNames(cardClasses.root, className)} title={title}>
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}
>
{children} {children}
</div> </div>
); );

View File

@ -1,6 +1,8 @@
import React from 'react'; import React from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import cardClasses from './Card.classes';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
interface CardActionAreaProps { interface CardActionAreaProps {
@ -10,24 +12,7 @@ interface CardActionAreaProps {
const CardActionArea = ({ to, children }: CardActionAreaProps) => { const CardActionArea = ({ to, children }: CardActionAreaProps) => {
return ( return (
<Link <Link to={to} className={cardClasses.actions}>
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
"
>
{children} {children}
</Link> </Link>
); );

View File

@ -1,5 +1,7 @@
import React from 'react'; import React from 'react';
import cardClasses from './Card.classes';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
interface CardContentProps { interface CardContentProps {
@ -7,7 +9,7 @@ interface CardContentProps {
} }
const CardContent = ({ children }: 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; export default CardContent;

View File

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

View File

@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import Image from '../image/Image'; import Image from '../image/Image';
import cardClasses from './Card.classes';
import type { import type {
BaseField, BaseField,
@ -31,7 +32,7 @@ const CardMedia = <EF extends BaseField = UnknownField>({
}: CardMediaProps<EF>) => { }: CardMediaProps<EF>) => {
return ( return (
<Image <Image
className="rounded-t-lg bg-cover bg-no-repeat bg-center w-full object-cover" className={cardClasses.media}
style={{ style={{
width: width ? `${width}px` : undefined, width: width ? `${width}px` : undefined,
height: height ? `${height}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 React, { useCallback, useRef } from 'react';
import classNames from '@staticcms/core/lib/util/classNames.util'; 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 type { ChangeEventHandler, FC, KeyboardEvent, MouseEvent } from 'react';
import './Checkbox.css';
export const classes = generateClassNames('Checkbox', [
'root',
'disabled',
'input',
'custom-input',
'checkmark',
]);
export interface CheckboxProps { export interface CheckboxProps {
checked: boolean; checked: boolean;
disabled?: boolean; disabled?: boolean;
@ -35,15 +46,7 @@ const Checkbox: FC<CheckboxProps> = ({ checked, disabled = false, onChange }) =>
return ( return (
<label <label
className={classNames( className={classNames(classes.root, disabled && classes.disabled)}
`
relative
inline-flex
items-center
cursor-pointer
`,
disabled && 'cursor-default',
)}
onClick={handleNoop} onClick={handleNoop}
onKeyDown={handleKeydown} onKeyDown={handleKeydown}
> >
@ -52,59 +55,16 @@ const Checkbox: FC<CheckboxProps> = ({ checked, disabled = false, onChange }) =>
ref={inputRef} ref={inputRef}
type="checkbox" type="checkbox"
checked={checked} checked={checked}
className="sr-only peer hide-tap" className={classes.input}
disabled={disabled} disabled={disabled}
onChange={onChange} onChange={onChange}
onClick={handleNoop} onClick={handleNoop}
onKeyDown={handleKeydown} onKeyDown={handleKeydown}
/> />
<div <div className={classes['custom-input']} onClick={handleClick} onKeyDown={handleKeydown}>
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}
>
{checked ? ( {checked ? (
<CheckIcon <CheckIcon
className=" className={classes.checkmark}
w-5
h-5
text-white
"
onClick={handleClick} onClick={handleClick}
onKeyDown={handleKeydown} 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 { translate } from 'react-polyglot';
import ConfirmEvent from '@staticcms/core/lib/util/events/ConfirmEvent'; 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 { useWindowEvent } from '@staticcms/core/lib/util/window.util';
import Button from '../button/Button'; import Button from '../button/Button';
import Modal from '../modal/Modal'; import Modal from '../modal/Modal';
import type { TranslateProps } from 'react-polyglot'; import type { TranslateProps } from 'react-polyglot';
import './Confirm.css';
export const classes = generateClassNames('Confirm', [
'root',
'title',
'content',
'actions',
'confirm-button',
'cancel-button',
]);
interface ConfirmProps { interface ConfirmProps {
title: string | { key: string; options?: Record<string, unknown> }; title: string | { key: string; options?: Record<string, unknown> };
body: string | { key: string; options?: Record<string, unknown> }; body: string | { key: string; options?: Record<string, unknown> };
@ -83,47 +95,27 @@ const ConfirmDialog = ({ t }: TranslateProps) => {
<Modal <Modal
open open
onClose={handleCancel} onClose={handleCancel}
className=" className={classes.root}
w-[50%]
min-w-[300px]
max-w-[600px]
"
aria-labelledby="confirm-dialog-title" aria-labelledby="confirm-dialog-title"
aria-describedby="confirm-dialog-description" aria-describedby="confirm-dialog-description"
> >
<div <div className={classes.title}>{title}</div>
className=" <div className={classes.content}>{body}</div>
px-6 <div className={classes.actions}>
py-4 <Button
text-xl onClick={handleCancel}
bold 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} {cancel}
</Button> </Button>
<Button onClick={handleConfirm} variant="contained" color={color}> <Button
onClick={handleConfirm}
variant="contained"
color={color}
className={classes['confirm-button']}
>
{confirm} {confirm}
</Button> </Button>
</div> </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 React from 'react';
import classNames from '@staticcms/core/lib/util/classNames.util'; 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 { FieldError } from '@staticcms/core/interface';
import type { FC } from 'react'; import type { FC } from 'react';
import './ErrorMessage.css';
export const classes = generateClassNames('ErrorMessage', ['root']);
export interface ErrorMessageProps { export interface ErrorMessageProps {
errors: FieldError[]; errors: FieldError[];
className?: string; className?: string;
@ -12,21 +17,7 @@ export interface ErrorMessageProps {
const ErrorMessage: FC<ErrorMessageProps> = ({ errors, className }) => { const ErrorMessage: FC<ErrorMessageProps> = ({ errors, className }) => {
return errors.length ? ( return errors.length ? (
<div <div key="error" data-testid="error" className={classNames(classes.root, className)}>
key="error"
data-testid="error"
className={classNames(
`
flex
w-full
text-xs
text-red-500
px-3
pt-2
`,
className,
)}
>
{errors[0].message} {errors[0].message}
</div> </div>
) : null; ) : 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 useCursor from '@staticcms/core/lib/hooks/useCursor';
import classNames from '@staticcms/core/lib/util/classNames.util'; import classNames from '@staticcms/core/lib/util/classNames.util';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import ErrorMessage from './ErrorMessage'; import ErrorMessage from './ErrorMessage';
import Hint from './Hint'; import Hint from './Hint';
import Label from './Label'; import Label from './Label';
@ -9,6 +10,25 @@ import Label from './Label';
import type { FieldError } from '@staticcms/core/interface'; import type { FieldError } from '@staticcms/core/interface';
import type { FC, MouseEvent, ReactNode } from 'react'; 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 { export interface FieldProps {
label?: string; label?: string;
inputRef?: React.MutableRefObject<HTMLElement | null>; inputRef?: React.MutableRefObject<HTMLElement | null>;
@ -98,53 +118,34 @@ const Field: FC<FieldProps> = ({
const rootClassNames = useMemo( const rootClassNames = useMemo(
() => () =>
classNames( classNames(
` classes.root,
relative
flex
items-center
gap-2
border-b
border-slate-400
focus-within:border-blue-800
dark:focus-within:border-blue-100
`,
rootClassName, rootClassName,
!noHightlight && disabled && classes.disabled,
!disabled && noHightlight && classes['no-highlight'],
` noPadding && classes['no-padding'],
focus-within:bg-slate-100 finalCursor === 'pointer' && classes['cursor-pointer'],
dark:focus-within:bg-slate-800 finalCursor === 'text' && classes['cursor-text'],
hover:bg-slate-100 finalCursor === 'default' && classes['cursor-default'],
dark:hover:bg-slate-800 hasErrors ? classes.error : `group/active`,
`,
!noPadding && 'pb-3',
finalCursor === 'pointer' && 'cursor-pointer',
finalCursor === 'text' && 'cursor-text',
finalCursor === 'default' && 'cursor-default',
!hasErrors && 'group/active',
), ),
[rootClassName, noHightlight, disabled, noPadding, finalCursor, hasErrors], [rootClassName, noHightlight, disabled, noPadding, finalCursor, hasErrors],
); );
const wrapperClassNames = useMemo( const wrapperClassNames = useMemo(
() => () =>
classNames( classNames(classes.wrapper, wrapperClassName, forSingleList && classes['for-single-list']),
`
flex
flex-col
w-full
`,
wrapperClassName,
forSingleList && 'mr-14',
),
[forSingleList, wrapperClassName], [forSingleList, wrapperClassName],
); );
if (variant === 'inline') { if (variant === 'inline') {
return ( 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 data-testid="inline-field-wrapper" className={wrapperClassNames}>
<div className="flex items-center justify-center p-3 pb-0"> <div className={classes['inline-wrapper']}>
{renderedLabel} {renderedLabel}
{renderedHint} {renderedHint}
{children} {children}
@ -163,18 +164,7 @@ const Field: FC<FieldProps> = ({
{renderedHint} {renderedHint}
{renderedErrorMessage} {renderedErrorMessage}
</div> </div>
{endAdornment ? ( {endAdornment ? <div className={classes['end-adornment']}>{endAdornment}</div> : null}
<div
className={classNames(
`
pr-2
`,
!noPadding && '-mb-3',
)}
>
{endAdornment}
</div>
) : null}
</div> </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 useCursor from '@staticcms/core/lib/hooks/useCursor';
import classNames from '@staticcms/core/lib/util/classNames.util'; import classNames from '@staticcms/core/lib/util/classNames.util';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import type { FC } from 'react'; 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 { export interface HintProps {
children: string; children: string;
hasErrors: boolean; hasErrors: boolean;
@ -28,32 +41,13 @@ const Hint: FC<HintProps> = ({
<div <div
data-testid="hint" data-testid="hint"
className={classNames( className={classNames(
` classes.root,
w-full disabled && classes.disabled,
flex finalCursor === 'pointer' && classes['cursor-pointer'],
text-xs finalCursor === 'text' && classes['cursor-text'],
italic finalCursor === 'default' && classes['cursor-default'],
`, hasErrors && classes.error,
!disabled && variant === 'inline' && classes.inline,
`
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',
className, 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 useCursor from '@staticcms/core/lib/hooks/useCursor';
import classNames from '@staticcms/core/lib/util/classNames.util'; import classNames from '@staticcms/core/lib/util/classNames.util';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import type { FC } from 'react'; 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 { export interface LabelProps {
htmlFor?: string; htmlFor?: string;
children: string; children: string;
@ -33,33 +46,13 @@ const Label: FC<LabelProps> = ({
htmlFor={htmlFor} htmlFor={htmlFor}
data-testid={dataTestId ?? 'label'} data-testid={dataTestId ?? 'label'}
className={classNames( className={classNames(
` classes.root,
w-full disabled && classes.disabled,
flex finalCursor === 'pointer' && classes['cursor-pointer'],
text-xs finalCursor === 'text' && classes['cursor-text'],
font-bold finalCursor === 'default' && classes['cursor-default'],
dark:font-semibold hasErrors && classes.error,
`, variant === 'inline' && classes.inline,
!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',
className, 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 { Image as ImageIcon } from '@styled-icons/material-outlined/Image';
import React from 'react';
import useMediaAsset from '@staticcms/core/lib/hooks/useMediaAsset'; import useMediaAsset from '@staticcms/core/lib/hooks/useMediaAsset';
import classNames from '@staticcms/core/lib/util/classNames.util'; 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 { selectEditingDraft } from '@staticcms/core/reducers/selectors/entryDraft';
import { useAppSelector } from '@staticcms/core/store/hooks'; import { useAppSelector } from '@staticcms/core/store/hooks';
import { isEmpty } from '@staticcms/core/lib/util/string.util';
import type { import type {
BaseField, BaseField,
@ -16,6 +17,10 @@ import type {
} from '@staticcms/core/interface'; } from '@staticcms/core/interface';
import type { CSSProperties } from 'react'; import type { CSSProperties } from 'react';
import './Image.css';
export const classes = generateClassNames('Image', ['root', 'empty']);
export interface ImageProps<EF extends BaseField> { export interface ImageProps<EF extends BaseField> {
src?: string; src?: string;
alt?: string; alt?: string;
@ -42,18 +47,7 @@ const Image = <EF extends BaseField = UnknownField>({
const assetSource = useMediaAsset(src, collection, field, entry ?? editingDraft); const assetSource = useMediaAsset(src, collection, field, entry ?? editingDraft);
if (isEmpty(src)) { if (isEmpty(src)) {
return ( return <ImageIcon className={classNames(classes.root, classes.empty, className)} />;
<ImageIcon
className="
p-10
rounded-md
border
max-w-full
border-gray-200/75
dark:border-slate-600/75
"
/>
);
} }
return ( return (
@ -63,7 +57,7 @@ const Image = <EF extends BaseField = UnknownField>({
src={assetSource} src={assetSource}
alt={alt} alt={alt}
data-testid={dataTestId ?? 'image'} data-testid={dataTestId ?? 'image'}
className={classNames('object-cover max-w-full overflow-hidden', className)} className={classNames(classes.root, className)}
style={style} 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 { Dropdown } from '@mui/base/Dropdown';
import MenuUnstyled from '@mui/base/MenuUnstyled'; import { Menu as BaseMenu } from '@mui/base/Menu';
import { MenuButton } from '@mui/base/MenuButton';
import { KeyboardArrowDown as KeyboardArrowDownIcon } from '@styled-icons/material/KeyboardArrowDown'; 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 classNames from '@staticcms/core/lib/util/classNames.util';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import useButtonClassNames from '../button/useButtonClassNames'; import useButtonClassNames from '../button/useButtonClassNames';
import type { FC, ReactNode } from 'react'; import type { FC, ReactNode } from 'react';
import type { BaseBaseProps } from '../button/Button'; 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 { export interface MenuProps {
label: ReactNode; label: ReactNode;
startIcon?: FC<{ className?: string }>; startIcon?: FC<{ className?: string }>;
@ -24,8 +40,8 @@ export interface MenuProps {
hideDropdownIcon?: boolean; hideDropdownIcon?: boolean;
hideDropdownIconOnMobile?: boolean; hideDropdownIconOnMobile?: boolean;
hideLabel?: boolean; hideLabel?: boolean;
keepMounted?: boolean;
disabled?: boolean; disabled?: boolean;
keepMounted?: boolean;
'data-testid'?: string; 'data-testid'?: string;
} }
@ -44,103 +60,56 @@ const Menu = ({
hideDropdownIcon = false, hideDropdownIcon = false,
hideDropdownIconOnMobile = false, hideDropdownIconOnMobile = false,
hideLabel = false, hideLabel = false,
keepMounted = false,
disabled = false, disabled = false,
keepMounted = false,
'data-testid': dataTestId, 'data-testid': dataTestId,
}: MenuProps) => { }: 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 calculatedButtonClassName = useButtonClassNames(variant, color, size, rounded);
const menuButtonClassNames = useMemo( const menuButtonClassNames = useMemo(
() => classNames(calculatedButtonClassName, buttonClassName, 'whitespace-nowrap'), () => classNames(calculatedButtonClassName, buttonClassName, classes.dropdown),
[calculatedButtonClassName, buttonClassName], [calculatedButtonClassName, buttonClassName],
); );
return ( return (
<ClickAwayListener mouseEvent="onMouseDown" touchEvent="onTouchStart" onClickAway={handleClose}> <Dropdown>
<div className={classNames('flex', rootClassName)}> <div
<button className={classNames(
type="button" classes.root,
onClick={handleButtonClick} hideLabel && classes['hide-label'],
aria-controls={isOpen ? 'simple-menu' : undefined} hideDropdownIcon && classes['hide-dropdown-icon'],
aria-expanded={isOpen || undefined} hideDropdownIconOnMobile && classes['hide-dropdown-icon-mobile'],
rootClassName,
)}
>
<MenuButton
aria-haspopup="menu" aria-haspopup="menu"
data-testid={dataTestId} data-testid={dataTestId}
className={menuButtonClassNames} className={menuButtonClassNames}
disabled={disabled} disabled={disabled}
> >
{StartIcon ? ( {StartIcon ? (
<StartIcon <StartIcon className={classNames(classes['dropdown-start-icon'], iconClassName)} />
className={classNames( ) : null}
`-ml-0.5 h-5 w-5`, {!hideLabel ? (
!hideLabel && !hideDropdownIcon && 'mr-1.5', <div className={classNames(classes.label, labelClassName)}>{label}</div>
hideDropdownIconOnMobile && '!mr-0 md:!mr-1.5',
iconClassName,
)}
/>
) : null} ) : null}
{!hideLabel ? <div className={labelClassName}>{label}</div> : null}
{!hideDropdownIcon ? ( {!hideDropdownIcon ? (
<KeyboardArrowDownIcon <KeyboardArrowDownIcon className={classes['dropdown-icon']} aria-hidden="true" />
className={classNames(
`-mr-0.5 h-5 w-5`,
!hideLabel && 'ml-2',
hideDropdownIconOnMobile && '!hidden md:!block',
)}
aria-hidden="true"
/>
) : null} ) : null}
</button> </MenuButton>
<MenuUnstyled <BaseMenu
open={isOpen}
anchorEl={anchorEl}
keepMounted={keepMounted}
slotProps={{ slotProps={{
root: { root: {
className: ` className: classes.menu,
absolute keepMounted,
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,
}, },
}} }}
> >
{children} {children}
</MenuUnstyled> </BaseMenu>
</div> </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 React from 'react';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import './MenuGroup.css';
export const classes = generateClassNames('MenuGroup', ['root']);
export interface MenuGroupProps { export interface MenuGroupProps {
children: ReactNode | ReactNode[]; children: ReactNode | ReactNode[];
} }
const MenuGroup = ({ children }: MenuGroupProps) => { const MenuGroup = ({ children }: MenuGroupProps) => {
return ( return <div className={classes.root}>{children}</div>;
<div
className="
py-1
border-b
border-gray-200
dark:border-slate-700
"
>
{children}
</div>
);
}; };
export default MenuGroup; 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 React from 'react';
import MenuItemUnstyled from '@mui/base/MenuItemUnstyled';
import classNames from '@staticcms/core/lib/util/classNames.util'; 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 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 { export interface MenuItemButtonProps {
active?: boolean; active?: boolean;
onClick: (event: MouseEvent) => void; onClick: (event: MouseEvent) => void;
@ -29,50 +44,17 @@ const MenuItemButton = ({
'data-testid': dataTestId, 'data-testid': dataTestId,
}: MenuItemButtonProps) => { }: MenuItemButtonProps) => {
return ( return (
<MenuItemUnstyled <MenuItem
slotProps={{ slotProps={{
root: { root: {
className: classNames( className: classNames(
className, className,
active ? 'bg-slate-200 dark:bg-slate-600' : '', classes.root,
` disabled && classes.disabled,
px-4 active && classes.active,
py-2 color === 'default' && classes.default,
text-sm color === 'warning' && classes.warning,
w-full color === 'error' && classes.error,
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
`,
), ),
}, },
}} }}
@ -80,12 +62,12 @@ const MenuItemButton = ({
disabled={disabled} disabled={disabled}
data-testid={dataTestId} data-testid={dataTestId}
> >
<div className="flex items-center gap-2 flex-grow"> <div className={classes.content}>
{StartIcon ? <StartIcon className="h-5 w-5" /> : null} {StartIcon ? <StartIcon className={classes['start-icon']} /> : null}
{children} {children}
</div> </div>
{EndIcon ? <EndIcon className="h-5 w-5" /> : null} {EndIcon ? <EndIcon className={classes['end-icon']} /> : null}
</MenuItemUnstyled> </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 React from 'react';
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
import MenuItemUnstyled from '@mui/base/MenuItemUnstyled';
import classNames from '@staticcms/core/lib/util/classNames.util'; 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 type { FC, ReactNode } from 'react';
import './MenuItemLink.css';
export const classes = generateClassNames('MenuItemLink', [
'root',
'active',
'content',
'start-icon',
'end-icon',
]);
export interface MenuItemLinkProps { export interface MenuItemLinkProps {
href: string; href: string;
children: ReactNode; children: ReactNode;
@ -24,38 +35,21 @@ const MenuItemLink = ({
endIcon: EndIcon, endIcon: EndIcon,
}: MenuItemLinkProps) => { }: MenuItemLinkProps) => {
return ( return (
<MenuItemUnstyled <NavLink to={href}>
component={NavLink} <MenuItem
to={href} slotProps={{
slotProps={{ root: {
root: { className: classNames(className, classes.root, active && classes.active),
className: classNames( },
className, }}
active ? 'bg-slate-100 dark:bg-slate-900' : '', >
` <div className={classes.content}>
px-4 {StartIcon ? <StartIcon className={classes['start-icon']} /> : null}
py-2 {children}
text-sm </div>
text-gray-800 {EndIcon ? <EndIcon className={classes['end-icon']} /> : null}
dark:text-gray-300 </MenuItem>
w-full </NavLink>
text-left
flex
items-center
justify-between
hover:bg-slate-100
dark:hover:bg-slate-900
`,
),
},
}}
>
<div className="flex items-center gap-2 flex-grow">
{StartIcon ? <StartIcon className="h-5 w-5" /> : null}
{children}
</div>
{EndIcon ? <EndIcon className="h-5 w-5" /> : null}
</MenuItemUnstyled>
); );
}; };

View File

@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import classNames from '@staticcms/core/lib/util/classNames.util'; import classNames from '@staticcms/core/lib/util/classNames.util';
import modalClasses from './Modal.classes';
const Backdrop = React.forwardRef< const Backdrop = React.forwardRef<
HTMLDivElement, HTMLDivElement,
@ -9,18 +10,7 @@ const Backdrop = React.forwardRef<
const { open, className, ownerState: _ownerState, ...other } = props; const { open, className, ownerState: _ownerState, ...other } = props;
return ( return (
<div <div
className={classNames( className={classNames(modalClasses.backdrop, open && 'MuiBackdrop-open', className)}
`
fixed
inset-0
bg-black
bg-opacity-50
dark:bg-opacity-60
z-50
`,
open && 'MuiBackdrop-open',
className,
)}
ref={ref} ref={ref}
{...other} {...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 React, { useCallback } from 'react';
import classNames from '@staticcms/core/lib/util/classNames.util'; import classNames from '@staticcms/core/lib/util/classNames.util';
import Backdrop from './Backdrop'; import Backdrop from './Backdrop';
import modalClasses from './Modal.classes';
import type { FC, ReactNode } from 'react'; import type { FC, ReactNode } from 'react';
import './Modal.css';
interface ModalProps { interface ModalProps {
open: boolean; open: boolean;
children: ReactNode; children: ReactNode;
@ -19,7 +22,7 @@ const Modal: FC<ModalProps> = ({ open, children, className, onClose }) => {
}, [onClose]); }, [onClose]);
return ( return (
<ModalUnstyled <BaseModal
open={open} open={open}
onClose={handleClose} onClose={handleClose}
slots={{ slots={{
@ -27,42 +30,12 @@ const Modal: FC<ModalProps> = ({ open, children, className, onClose }) => {
}} }}
slotProps={{ slotProps={{
root: { root: {
className: ` className: modalClasses.root,
fixed
inset-0
overflow-y-auto
z-50
flex
min-h-full
items-center
justify-center
text-center
styled-scrollbars
`,
}, },
}} }}
> >
<div <div className={classNames(modalClasses.content, className)}>{children}</div>
className={classNames( </BaseModal>
`
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>
); );
}; };

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 React, { useMemo } from 'react';
import classNames from '@staticcms/core/lib/util/classNames.util'; 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 type { FC, ReactNode } from 'react';
import './Pill.css';
export const classes = generateClassNames('Pill', [
'root',
'no-wrap',
'primary',
'default',
'disabled',
]);
interface PillProps { interface PillProps {
children: ReactNode | ReactNode[]; children: ReactNode | ReactNode[];
noWrap?: boolean; noWrap?: boolean;
@ -22,48 +33,18 @@ const Pill: FC<PillProps> = ({
const colorClassNames = useMemo(() => { const colorClassNames = useMemo(() => {
switch (color) { switch (color) {
case 'primary': case 'primary':
return disabled return classes.primary;
? `
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
`;
default: default:
return disabled return classes.default;
? `
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
`;
} }
}, [color, disabled]); }, [color]);
return ( return (
<span <span
className={classNames( className={classNames(
` classes.root,
text-xs noWrap && classes['no-wrap'],
font-medium disabled && classes.disabled,
px-3
py-1
rounded-lg
truncate
`,
noWrap && 'whitespace-nowrap',
colorClassNames, colorClassNames,
className, 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 React from 'react';
import classNames from '@staticcms/core/lib/util/classNames.util'; import classNames from '@staticcms/core/lib/util/classNames.util';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import type { FC } from 'react'; import type { FC } from 'react';
import './CircularProgress.css';
export const classes = generateClassNames('CircularProgress', [
'root',
'svg',
'md',
'sm',
'sr-label',
]);
export interface CircularProgressProps { export interface CircularProgressProps {
className?: string; className?: string;
'data-testid'?: string; 'data-testid'?: string;
@ -16,27 +27,13 @@ const CircularProgress: FC<CircularProgressProps> = ({
size = 'medium', size = 'medium',
}) => { }) => {
return ( return (
<div role="status" className={className} data-testid={dataTestId}> <div role="status" className={classNames(classes.root, className)} data-testid={dataTestId}>
<svg <svg
aria-hidden="true" aria-hidden="true"
className={classNames( className={classNames(
` classes.svg,
mr-2 size === 'medium' && classes.md,
text-gray-200 size === 'small' && classes.sm,
animate-spin
dark:text-gray-600
fill-blue-600
`,
size === 'medium' &&
`
w-8
h-8
`,
size === 'small' &&
`
w-5
h-5
`,
)} )}
viewBox="0 0 100 101" viewBox="0 0 100 101"
fill="none" fill="none"
@ -51,7 +48,7 @@ const CircularProgress: FC<CircularProgressProps> = ({
fill="currentFill" fill="currentFill"
/> />
</svg> </svg>
<span className="sr-only">Loading...</span> <span className={classes['sr-label']}>Loading...</span>
</div> </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 React, { useEffect, useMemo, useState } from 'react';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import CircularProgress from './CircularProgress'; import CircularProgress from './CircularProgress';
import './Loader.css';
export const classes = generateClassNames('Loader', ['root']);
export interface LoaderProps { export interface LoaderProps {
children: string | string[] | undefined; children: string | string[] | undefined;
} }
@ -35,19 +40,7 @@ const Loader = ({ children }: LoaderProps) => {
}, [children, currentItem]); }, [children, currentItem]);
return ( return (
<div <div className={classes.root}>
className="
absolute
inset-0
flex
flex-col
gap-2
items-center
justify-center
bg-slate-50
dark:bg-slate-900
"
>
<CircularProgress /> <CircularProgress />
<div>{text}</div> <div>{text}</div>
</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 React, { useMemo } from 'react';
import classNames from '@staticcms/core/lib/util/classNames.util'; import classNames from '@staticcms/core/lib/util/classNames.util';
import { isNotNullish } from '@staticcms/core/lib/util/null.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 type { ReactNode } from 'react';
import './Option.css';
export const classes = generateClassNames('SelectOption', ['root', 'selected', 'label']);
export interface OptionProps<T> { export interface OptionProps<T> {
selectedValue: T | null | T[]; selectedValue: T | null | T[];
value: T | null; value: T | null;
@ -28,31 +33,17 @@ const Option = function <T>({
); );
return ( return (
<OptionUnstyled <BaseOption
value={value} value={value}
data-testid={dataTestId} data-testid={dataTestId}
slotProps={{ slotProps={{
root: { root: {
className: classNames( className: classNames(classes.root, selected && classes.selected),
`
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' : '',
),
}, },
}} }}
> >
<span className={classNames('block truncate', selected ? 'font-medium' : 'font-normal')}> <span className={classes.label}>{children}</span>
{children} </BaseOption>
</span>
</OptionUnstyled>
); );
}; };

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 { KeyboardArrowDown as KeyboardArrowDownIcon } from '@styled-icons/material/KeyboardArrowDown';
import React, { forwardRef, useCallback, useState } from 'react'; import React, { forwardRef, useCallback, useState } from 'react';
import useElementSize from '@staticcms/core/lib/hooks/useElementSize'; import useElementSize from '@staticcms/core/lib/hooks/useElementSize';
import classNames from '@staticcms/core/lib/util/classNames.util'; import classNames from '@staticcms/core/lib/util/classNames.util';
import { isNotEmpty } from '@staticcms/core/lib/util/string.util'; import { isNotEmpty } from '@staticcms/core/lib/util/string.util';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import Option from './Option'; import Option from './Option';
import type { FocusEvent, KeyboardEvent, MouseEvent, ReactNode, Ref } from 'react'; 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 { export interface Option {
label: string; label: string;
value: number | string; value: number | string;
@ -31,6 +48,7 @@ export interface SelectProps {
options: (number | string)[] | Option[]; options: (number | string)[] | Option[];
required?: boolean; required?: boolean;
disabled?: boolean; disabled?: boolean;
rootClassName?: string;
onChange: SelectChangeEventHandler; onChange: SelectChangeEventHandler;
onOpenChange?: (open: boolean) => void; onOpenChange?: (open: boolean) => void;
} }
@ -44,6 +62,7 @@ const Select = forwardRef(
options, options,
required = false, required = false,
disabled, disabled,
rootClassName,
onChange, onChange,
onOpenChange, onOpenChange,
}: SelectProps, }: SelectProps,
@ -82,120 +101,79 @@ const Select = forwardRef(
[onChange, value], [onChange, value],
); );
return ( const handleClick = useCallback(
<div className="relative w-full"> (event: MouseEvent) => {
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} event.stopPropagation();
<SelectUnstyled<any> event.preventDefault();
renderValue={() => { handleOpenChange(!open);
return ( },
<div className="w-full"> [handleOpenChange, open],
<div className="flex flex-start w-select-widget-label"> );
<span className="truncate">{label ?? placeholder}</span>
</div>
<span
className="
pointer-events-none
absolute
inset-y-0
right-0
flex
items-center
pr-2
"
>
<KeyboardArrowDownIcon
className={classNames(
`
h-5
w-5
text-gray-400
`,
disabled &&
`
text-gray-300/75
dark:text-gray-600/75
`,
)}
aria-hidden="true"
/>
</span>
</div>
);
}}
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
`,
),
},
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
`,
style: { width: ref ? width : 'auto' },
disablePortal: false,
},
}}
value={value}
disabled={disabled}
onChange={handleChange}
listboxOpen={open}
onListboxOpenChange={handleOpenChange}
data-testid="select-input"
>
{!Array.isArray(value) && !required ? (
<Option value="" selectedValue={value}>
<i>None</i>
</Option>
) : null}
{options.map((option, index) => {
const { label: optionLabel, value: optionValue } = getOptionLabelAndValue(option);
return ( const handleClickAway = useCallback(() => {
<Option handleOpenChange(false);
key={index} }, [handleOpenChange]);
value={optionValue}
selectedValue={value} return (
data-testid={`select-option-${optionValue}`} <ClickAwayListener onClickAway={handleClickAway}>
> <div className={classNames(classes.root, rootClassName)}>
{optionLabel} {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<BaseSelect<any>
renderValue={() => {
return (
<div className={classes.value}>
<div className={classes.label}>
<span className={classes['label-text']}>{label ?? placeholder}</span>
</div>
<span className={classes.dropdown}>
<KeyboardArrowDownIcon
className={classes['dropdown-icon']}
aria-hidden="true"
/>
</span>
</div>
);
}}
ref={ref}
onClick={handleClick}
slotProps={{
root: {
className: classes.input,
},
popper: {
className: classes.popper,
style: { width: ref ? width : 'auto' },
disablePortal: false,
},
}}
value={value}
disabled={disabled}
onChange={handleChange}
listboxOpen={open}
data-testid="select-input"
>
{!Array.isArray(value) && !required ? (
<Option value="" selectedValue={value}>
<i>None</i>
</Option> </Option>
); ) : null}
})} {options.map((option, index) => {
</SelectUnstyled> const { label: optionLabel, value: optionValue } = getOptionLabelAndValue(option);
</div>
return (
<Option
key={index}
value={optionValue}
selectedValue={value}
data-testid={`select-option-${optionValue}`}
>
{optionLabel}
</Option>
);
})}
</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 React, { forwardRef, useCallback } from 'react';
import classNames from '@staticcms/core/lib/util/classNames.util'; 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 type { ChangeEvent, ChangeEventHandler } from 'react';
import './Switch.css';
export const classes = generateClassNames('Switch', [
'root',
'disabled',
'input',
'toggle',
'label',
]);
export interface SwitchProps { export interface SwitchProps {
label?: string; label?: string;
value: boolean; value: boolean;
disabled?: boolean; disabled?: boolean;
rootClassName?: string;
inputClassName?: string;
onChange?: ChangeEventHandler<HTMLInputElement>; onChange?: ChangeEventHandler<HTMLInputElement>;
} }
const Switch = forwardRef<HTMLInputElement | null, SwitchProps>( const Switch = forwardRef<HTMLInputElement | null, SwitchProps>(
({ label, value, disabled, onChange }, ref) => { ({ label, value, disabled, rootClassName, inputClassName, onChange }, ref) => {
const handleChange = useCallback( const handleChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => { (event: ChangeEvent<HTMLInputElement>) => {
onChange?.(event); onChange?.(event);
@ -21,78 +34,19 @@ const Switch = forwardRef<HTMLInputElement | null, SwitchProps>(
); );
return ( return (
<label <label className={classNames(classes.root, disabled && classes.disabled, rootClassName)}>
className={classNames(
`
relative
inline-flex
items-center
cursor-pointer
`,
disabled && 'cursor-default',
)}
>
<input <input
data-testid="switch-input" data-testid="switch-input"
ref={ref} ref={ref}
type="checkbox" type="checkbox"
checked={value} checked={value}
className="sr-only peer" className={classNames(classes.input, inputClassName)}
disabled={disabled} disabled={disabled}
onChange={handleChange} onChange={handleChange}
onClick={() => false} onClick={() => false}
/> />
<div <div className={classes.toggle} />
className={classNames( {label ? <span className={classes.label}>{label}</span> : null}
`
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}
</label> </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 React from 'react';
import tableClasses from './Table.classes';
import TableHeaderCell from './TableHeaderCell'; import TableHeaderCell from './TableHeaderCell';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import './Table.css';
interface TableCellProps { interface TableCellProps {
columns: ReactNode[]; columns: ReactNode[];
children: ReactNode[]; children: ReactNode[];
@ -11,20 +14,16 @@ interface TableCellProps {
const TableCell = ({ columns, children }: TableCellProps) => { const TableCell = ({ columns, children }: TableCellProps) => {
return ( return (
<div <div className={tableClasses.root}>
className=" <table className={tableClasses.table}>
z-[2] <thead className={tableClasses.header}>
" <tr className={tableClasses['header-row']}>
>
<table className="w-full text-sm text-left text-gray-500 dark:text-gray-300">
<thead className="text-xs">
<tr className="shadow-sm">
{columns.map((column, index) => ( {columns.map((column, index) => (
<TableHeaderCell key={index}>{column}</TableHeaderCell> <TableHeaderCell key={index}>{column}</TableHeaderCell>
))} ))}
</tr> </tr>
</thead> </thead>
<tbody>{children}</tbody> <tbody className={tableClasses.body}>{children}</tbody>
</table> </table>
</div> </div>
); );

View File

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

View File

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import classNames from '@staticcms/core/lib/util/classNames.util';
import { isEmpty } from '@staticcms/core/lib/util/string.util'; import { isEmpty } from '@staticcms/core/lib/util/string.util';
import tableClasses from './Table.classes';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
@ -11,34 +11,8 @@ interface TableHeaderCellProps {
const TableHeaderCell = ({ children }: TableHeaderCellProps) => { const TableHeaderCell = ({ children }: TableHeaderCellProps) => {
return ( return (
<th <th scope="col" className={tableClasses['header-cell']}>
scope="col" <div className={tableClasses['header-cell-content']}>
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
"
>
{typeof children === 'string' && isEmpty(children) ? <>&nbsp;</> : children} {typeof children === 'string' && isEmpty(children) ? <>&nbsp;</> : children}
</div> </div>
</th> </th>

View File

@ -2,6 +2,7 @@ import React, { useCallback } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import classNames from '@staticcms/core/lib/util/classNames.util'; import classNames from '@staticcms/core/lib/util/classNames.util';
import tableClasses from './Table.classes';
import type { KeyboardEvent, ReactNode } from 'react'; import type { KeyboardEvent, ReactNode } from 'react';
@ -28,22 +29,7 @@ const TableRow = ({ children, className, to }: TableRowProps) => {
return ( return (
<tr <tr
className={classNames( className={classNames(tableClasses['body-row'], className)}
`
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,
)}
tabIndex={to ? 0 : -1} tabIndex={to ? 0 : -1}
onKeyDown={handleKeyDown} 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 { Input } from '@mui/base/Input';
import React, { forwardRef, useCallback, useState } from 'react'; 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 type { ChangeEventHandler, RefObject } from 'react';
import './TextArea.css';
export const classes = generateClassNames('TextArea', ['root', 'input']);
export interface TextAreaProps { export interface TextAreaProps {
value: string; value: string;
disabled?: boolean; disabled?: boolean;
placeholder?: string; placeholder?: string;
className?: string; rootClassName?: string;
inputClassName?: string;
'data-testid'?: string; 'data-testid'?: string;
onChange: ChangeEventHandler<HTMLInputElement>; onChange: ChangeEventHandler<HTMLInputElement>;
} }
@ -20,7 +28,18 @@ function getHeight(rawHeight: string): number {
} }
const TextArea = forwardRef<HTMLInputElement | null, TextAreaProps>( 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 [lastAutogrowHeight, setLastAutogrowHeight] = useState(MIN_TEXT_AREA_HEIGHT);
const autoGrow = useCallback(() => { const autoGrow = useCallback(() => {
@ -43,16 +62,22 @@ const TextArea = forwardRef<HTMLInputElement | null, TextAreaProps>(
return; return;
} }
textarea.style.height = `${newHeight}px`;
setLastAutogrowHeight(newHeight);
if (newHeight > MIN_TEXT_AREA_HEIGHT - MIN_BOTTOM_PADDING) { if (newHeight > MIN_TEXT_AREA_HEIGHT - MIN_BOTTOM_PADDING) {
textarea.style.paddingBottom = `${MIN_BOTTOM_PADDING}px`; textarea.style.paddingBottom = `${MIN_BOTTOM_PADDING}px`;
newHeight += MIN_BOTTOM_PADDING;
} }
textarea.style.height = `${newHeight}px`;
setLastAutogrowHeight(newHeight);
}, [lastAutogrowHeight, ref]); }, [lastAutogrowHeight, ref]);
useLayoutEffect(() => {
autoGrow();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return ( return (
<InputUnstyled <Input
multiline multiline
minRows={4} minRows={4}
onInput={autoGrow} onInput={autoGrow}
@ -62,28 +87,12 @@ const TextArea = forwardRef<HTMLInputElement | null, TextAreaProps>(
data-testid={dataTestId ?? 'textarea-input'} data-testid={dataTestId ?? 'textarea-input'}
slotProps={{ slotProps={{
root: { root: {
className: ` className: classNames(classes.root, rootClassName),
flex
w-full
${className}
`,
}, },
input: { input: {
ref, ref,
placeholder, placeholder,
className: ` className: classNames(classes.input, inputClassName),
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
`,
}, },
}} }}
/> />

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