init project
Some checks failed
No response / noResponse (push) Has been cancelled
CI / Continuous releases (push) Has been cancelled
CI / test-dev (macos-latest) (push) Has been cancelled
CI / test-dev (ubuntu-latest) (push) Has been cancelled
CI / test-dev (windows-latest) (push) Has been cancelled
Maintenance / main (push) Has been cancelled
Scorecards supply-chain security / Scorecards analysis (push) Has been cancelled
CodeQL / Analyze (push) Has been cancelled

This commit is contained in:
how2ice
2025-12-12 14:26:25 +09:00
commit 005cf56baf
43188 changed files with 1079531 additions and 0 deletions

View File

@@ -0,0 +1,53 @@
<!-- markdownlint-disable-next-line -->
<p align="center">
<a href="https://mui.com/joy-ui/getting-started/" rel="noopener" target="_blank"><img width="150" height="133" src="https://mui.com/static/logo.svg" alt="Joy UI logo"></a>
</p>
<h1 align="center">Joy UI</h1>
Joy UI is an open-source React component library that implements MUI's own design principles. It's comprehensive and can be used in production out of the box.
## Installation
Install the package in your project directory with:
```bash
npm install @mui/joy @emotion/react @emotion/styled
```
## Documentation
Visit [https://mui.com/joy-ui/getting-started/](https://mui.com/joy-ui/getting-started/) to view the full documentation.
## Questions
For how-to questions that don't involve making changes to the code base, please use [Stack Overflow](https://stackoverflow.com/questions/tagged/joy-ui) instead of GitHub issues.
Use the "joy-ui" tag on Stack Overflow to make it easier for the community to find your question.
## Examples
The documentation features [a collection of example projects using Joy UI](https://github.com/mui/material-ui/tree/master/examples).
## Contributing
Read the [contributing guide](/CONTRIBUTING.md) to learn about the development process, how to propose bug fixes and improvements, and how to build and test your changes.
Contributing to Joy UI is about more than just issues and pull requests!
There are many other ways to [support Joy UI](https://mui.com/material-ui/getting-started/faq/#mui-is-awesome-how-can-i-support-the-project) beyond contributing to the code base.
## Changelog
The [changelog](https://github.com/mui/material-ui/releases) is regularly updated to reflect what's changed in each new release.
## Roadmap
Future plans and high-priority features and enhancements can be found in the [roadmap](https://mui.com/material-ui/discover-more/roadmap/).
## License
This project is licensed under the terms of the
[MIT license](/LICENSE).
## Security
For details of supported versions and contact details for reporting security issues, please refer to the [security policy](https://github.com/mui/material-ui/security/policy).

View File

@@ -0,0 +1,88 @@
{
"name": "@mui/joy",
"version": "5.0.0-beta.49",
"private": true,
"description": "Joy UI is an open-source React component library that implements MUI's own design principles. It's comprehensive and can be used in production out of the box.",
"keywords": [
"react",
"react-component",
"design-system"
],
"repository": {
"type": "git",
"url": "git+https://github.com/mui/material-ui.git",
"directory": "packages/mui-joy"
},
"license": "MIT",
"bugs": {
"url": "https://github.com/mui/material-ui/issues"
},
"homepage": "https://mui.com/joy-ui/getting-started/",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/mui-org"
},
"scripts": {
"build": "code-infra build",
"release": "pnpm build && pnpm publish",
"test": "pnpm --workspace-root test:unit --project \"*:@mui/joy\"",
"typescript": "tsc -p tsconfig.json",
"typescript:module-augmentation": "node scripts/testModuleAugmentation.js"
},
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/base": "7.0.0-beta.4",
"@mui/core-downloads-tracker": "workspace:^",
"@mui/system": "workspace:^",
"@mui/types": "workspace:^",
"@mui/utils": "workspace:^",
"clsx": "^2.1.1",
"prop-types": "^15.8.1"
},
"devDependencies": {
"@mui/internal-test-utils": "workspace:^",
"@mui/material": "workspace:^",
"@types/chai": "^5.2.3",
"@types/prop-types": "^15.7.15",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/sinon": "^17.0.4",
"chai": "^6.0.1",
"es-toolkit": "^1.39.10",
"fast-glob": "^3.3.3",
"next": "^15.5.7",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"sinon": "^21.0.0"
},
"peerDependencies": {
"@emotion/react": "^11.5.0",
"@emotion/styled": "^11.3.0",
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@emotion/react": {
"optional": true
},
"@emotion/styled": {
"optional": true
}
},
"sideEffects": false,
"publishConfig": {
"access": "public",
"directory": "build"
},
"engines": {
"node": ">=14.0.0"
},
"exports": {
".": "./src/index.ts",
"./*": "./src/*/index.ts"
}
}

View File

@@ -0,0 +1,61 @@
const childProcess = require('child_process');
const path = require('path');
const { promisify } = require('util');
const { chunk } = require('es-toolkit/array');
const glob = require('fast-glob');
const exec = promisify(childProcess.exec);
const packageRoot = path.resolve(__dirname, '../');
async function test(tsconfigPath) {
try {
await exec(['pnpm', 'tsc', '--project', tsconfigPath].join(' '), { cwd: packageRoot });
} catch (error) {
if (error.stdout !== undefined) {
// `exec` error
throw new Error(`exit code ${error.code}: ${error.stdout}`);
}
// Unknown error
throw error;
}
}
/**
* Tests various module augmentation scenarios.
* We can't run them with a single `tsc` run since these apply globally.
* Running them all would mean they're not isolated.
* Each test case represents a section in our docs.
*/
async function main() {
const tsconfigPaths = await glob('test/typescript/moduleAugmentation/*.tsconfig.json', {
absolute: true,
cwd: packageRoot,
});
// Need to process in chunks or we might run out-of-memory
// approximate pnpm lerna --concurrency 7
const tsconfigPathsChunks = chunk(tsconfigPaths, 7);
for await (const tsconfigPathsChunk of tsconfigPathsChunks) {
await Promise.all(
tsconfigPathsChunk.map(async (tsconfigPath) => {
await test(tsconfigPath).then(
() => {
// eslint-disable-next-line no-console -- test runner feedback
console.log(`PASS ${path.relative(process.cwd(), tsconfigPath)}`);
},
(error) => {
// don't bail but log the error
console.error(`FAIL ${path.relative(process.cwd(), tsconfigPath)}\n ${error}`);
// and mark the test as failed
process.exitCode = 1;
},
);
}),
);
}
}
main().catch((error) => {
console.error(error);
process.exit(1);
});

View File

@@ -0,0 +1,151 @@
import { expect } from 'chai';
import { spy } from 'sinon';
import { createRenderer, fireEvent, screen } from '@mui/internal-test-utils';
import { ThemeProvider } from '@mui/joy/styles';
import Accordion, { accordionClasses as classes } from '@mui/joy/Accordion';
import AccordionSummary from '@mui/joy/AccordionSummary';
import describeConformance from '../../test/describeConformance';
describe('<Accordion />', () => {
const { render } = createRenderer();
describeConformance(<Accordion />, () => ({
classes,
inheritComponent: 'div',
render,
ThemeProvider,
muiName: 'JoyAccordion',
refInstanceof: window.HTMLDivElement,
testComponentPropWith: 'span',
skip: ['classesRoot', 'componentsProp', 'themeVariants'],
slots: {
root: {
expectedClassName: classes.root,
},
},
}));
it('should render and not be controlled', () => {
const { container } = render(
<Accordion>
<AccordionSummary>Header</AccordionSummary>
</Accordion>,
);
expect(container.firstChild).not.to.have.class(classes.expanded);
});
it('should handle defaultExpanded prop', () => {
const { container } = render(
<Accordion defaultExpanded>
<AccordionSummary>Header</AccordionSummary>
</Accordion>,
);
expect(container.firstChild).to.have.class(classes.expanded);
});
it('should render the summary and collapse elements', () => {
render(
<Accordion>
<AccordionSummary>Summary</AccordionSummary>
<div id="panel-content">Hello</div>
</Accordion>,
);
expect(screen.getByText('Summary')).toBeVisible();
expect(screen.getByRole('button')).to.have.attribute('aria-expanded', 'false');
});
it('should be controlled', () => {
const view = render(
<Accordion expanded>
<AccordionSummary>Header</AccordionSummary>
</Accordion>,
);
const panel = view.container.firstChild;
expect(panel).to.have.class(classes.expanded);
view.setProps({ expanded: false });
expect(panel).not.to.have.class(classes.expanded);
});
it('should be disabled', () => {
render(
<Accordion disabled>
<AccordionSummary>Summary</AccordionSummary>
</Accordion>,
);
expect(screen.getByRole('button')).to.have.class(classes.disabled);
});
it('should call onChange when clicking the summary element', () => {
const handleChange = spy();
render(
<Accordion onChange={handleChange}>
<AccordionSummary>Header</AccordionSummary>
</Accordion>,
);
fireEvent.click(screen.getByText('Header'));
expect(handleChange.callCount).to.equal(1);
});
it('when controlled should call the onChange', () => {
const handleChange = spy();
render(
<Accordion onChange={handleChange} expanded>
<AccordionSummary>Header</AccordionSummary>
</Accordion>,
);
fireEvent.click(screen.getByText('Header'));
expect(handleChange.callCount).to.equal(1);
expect(handleChange.args[0][1]).to.equal(false);
});
it('when undefined onChange and controlled should not call the onChange', () => {
const handleChange = spy();
const view = render(
<Accordion onChange={handleChange} expanded>
<AccordionSummary>Header</AccordionSummary>
</Accordion>,
);
view.setProps({ onChange: undefined });
fireEvent.click(screen.getByText('Header'));
expect(handleChange.callCount).to.equal(0);
});
it('when disabled should have the disabled class', () => {
const { container } = render(
<Accordion disabled>
<AccordionSummary>Header</AccordionSummary>
</Accordion>,
);
expect(container.firstChild).to.have.class(classes.disabled);
});
it('should warn when switching from controlled to uncontrolled', () => {
const view = render(
<Accordion expanded>
<AccordionSummary>Header</AccordionSummary>
</Accordion>,
);
expect(() => view.setProps({ expanded: undefined })).to.toErrorDev(
'MUI: A component is changing the controlled expanded state of Accordion to be uncontrolled.',
);
});
it('should warn when switching between uncontrolled to controlled', () => {
const view = render(
<Accordion>
<AccordionSummary>Header</AccordionSummary>
</Accordion>,
);
expect(() => view.setProps({ expanded: true })).toErrorDev(
'MUI: A component is changing the uncontrolled expanded state of Accordion to be controlled.',
);
});
});

View File

@@ -0,0 +1,226 @@
'use client';
import * as React from 'react';
import PropTypes from 'prop-types';
import { unstable_composeClasses as composeClasses } from '@mui/base';
import { OverridableComponent } from '@mui/types';
import capitalize from '@mui/utils/capitalize';
import useControlled from '@mui/utils/useControlled';
import useId from '@mui/utils/useId';
import { useThemeProps } from '../styles';
import styled from '../styles/styled';
import { getAccordionUtilityClass } from './accordionClasses';
import { AccordionProps, AccordionOwnerState, AccordionTypeMap } from './AccordionProps';
import useSlot from '../utils/useSlot';
import AccordionContext from './AccordionContext';
import { StyledListItem } from '../ListItem/ListItem';
import accordionDetailsClasses from '../AccordionDetails/accordionDetailsClasses';
const useUtilityClasses = (ownerState: AccordionOwnerState) => {
const { variant, color, expanded, disabled } = ownerState;
const slots = {
root: [
'root',
expanded && 'expanded',
disabled && 'disabled',
color && `color${capitalize(color)}`,
variant && `variant${capitalize(variant)}`,
],
};
return composeClasses(slots, getAccordionUtilityClass, {});
};
const AccordionRoot = styled(StyledListItem as unknown as 'div', {
name: 'JoyAccordion',
slot: 'Root',
overridesResolver: (props, styles) => styles.root,
})<{ ownerState: AccordionOwnerState }>({
borderBottom: 'var(--Accordion-borderBottom)',
'&[data-first-child]': {
'--ListItem-radius': 'var(--unstable_List-childRadius) var(--unstable_List-childRadius) 0 0',
},
'&[data-last-child]': {
'--ListItem-radius': '0 0 var(--unstable_List-childRadius) var(--unstable_List-childRadius)',
'& [aria-expanded="true"]': {
'--ListItem-radius': '0',
},
[`& .${accordionDetailsClasses.root}`]: {
'--AccordionDetails-radius':
'0 0 var(--unstable_List-childRadius) var(--unstable_List-childRadius)',
},
},
'&:not([data-first-child]):not([data-last-child])': {
'--ListItem-radius': '0',
},
});
/**
*
* Demos:
*
* - [Accordion](https://mui.com/joy-ui/react-accordion/)
*
* API:
*
* - [Accordion API](https://mui.com/joy-ui/api/accordion/)
*/
const Accordion = React.forwardRef(function Accordion(inProps, ref) {
const props = useThemeProps<typeof inProps & AccordionProps>({
props: inProps,
name: 'JoyAccordion',
});
const {
accordionId: idOverride,
component = 'div',
color = 'neutral',
children,
defaultExpanded = false,
disabled = false,
expanded: expandedProp,
onChange,
variant = 'plain',
slots = {},
slotProps = {},
...other
} = props;
const accordionId = useId(idOverride);
const [expanded, setExpandedState] = useControlled({
controlled: expandedProp,
default: defaultExpanded,
name: 'Accordion',
state: 'expanded',
});
const handleChange = React.useCallback(
(event: React.SyntheticEvent) => {
setExpandedState(!expanded);
if (onChange) {
onChange(event, !expanded);
}
},
[expanded, onChange, setExpandedState],
);
const contextValue = React.useMemo(
() => ({ accordionId, expanded, disabled, toggle: handleChange }),
[accordionId, expanded, disabled, handleChange],
);
const externalForwardedProps = { ...other, component, slots, slotProps };
const ownerState = {
...props,
component,
color,
variant,
expanded,
disabled,
nested: true, // for the ListItem styles
};
const classes = useUtilityClasses(ownerState);
const [SlotRoot, rootProps] = useSlot('root', {
ref,
className: classes.root,
elementType: AccordionRoot,
externalForwardedProps,
ownerState,
});
return (
<AccordionContext.Provider value={contextValue}>
<SlotRoot {...rootProps}>
{React.Children.map(children, (child, index) =>
React.isValidElement(child) && index === 0
? React.cloneElement(child, {
// @ts-ignore: to let ListItem knows when to apply margin(Inline|Block)Start
'data-first-child': '',
})
: child,
)}
</SlotRoot>
</AccordionContext.Provider>
);
}) as OverridableComponent<AccordionTypeMap>;
Accordion.propTypes /* remove-proptypes */ = {
// ┌────────────────────────────── Warning ──────────────────────────────┐
// │ These PropTypes are generated from the TypeScript type definitions. │
// │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
// └─────────────────────────────────────────────────────────────────────┘
/**
* The id to be used in the AccordionDetails which is controlled by the AccordionSummary.
* If not provided, the id is autogenerated.
*/
accordionId: PropTypes.string,
/**
* Used to render icon or text elements inside the Accordion if `src` is not set.
* This can be an element, or just a string.
*/
children: PropTypes.node,
/**
* The color of the component. It supports those theme colors that make sense for this component.
* @default 'neutral'
*/
color: PropTypes.oneOf(['danger', 'neutral', 'primary', 'success', 'warning']),
/**
* The component used for the root node.
* Either a string to use a HTML element or a component.
*/
component: PropTypes.elementType,
/**
* If `true`, expands the accordion by default.
* @default false
*/
defaultExpanded: PropTypes.bool,
/**
* If `true`, the component is disabled.
* @default false
*/
disabled: PropTypes.bool,
/**
* If `true`, expands the accordion, otherwise collapse it.
* Setting this prop enables control over the accordion.
*/
expanded: PropTypes.bool,
/**
* Callback fired when the expand/collapse state is changed.
*
* @param {React.SyntheticEvent} event The event source of the callback. **Warning**: This is a generic event not a change event.
* @param {boolean} expanded The `expanded` state of the accordion.
*/
onChange: PropTypes.func,
/**
* The props used for each slot inside.
* @default {}
*/
slotProps: PropTypes.shape({
root: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
}),
/**
* The components used for each slot inside.
* @default {}
*/
slots: PropTypes.shape({
root: PropTypes.elementType,
}),
/**
* The system prop that allows defining system overrides as well as additional CSS styles.
*/
sx: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.func, PropTypes.object, PropTypes.bool])),
PropTypes.func,
PropTypes.object,
]),
/**
* The [global variant](https://mui.com/joy-ui/main-features/global-variants/) to use.
* @default 'plain'
*/
variant: PropTypes.oneOf(['outlined', 'plain', 'soft', 'solid']),
} as any;
export default Accordion;

View File

@@ -0,0 +1,20 @@
'use client';
import * as React from 'react';
/**
* @ignore - internal component.
*/
const AccordionContext = React.createContext<
Partial<{
disabled: boolean;
expanded: boolean;
toggle: (event: React.SyntheticEvent) => void;
accordionId: string;
}>
>({});
if (process.env.NODE_ENV !== 'production') {
AccordionContext.displayName = 'AccordionContext';
}
export default AccordionContext;

View File

@@ -0,0 +1,83 @@
import * as React from 'react';
import { OverridableStringUnion, OverrideProps } from '@mui/types';
import { ColorPaletteProp, SxProps, VariantProp, ApplyColorInversion } from '../styles/types';
import { SlotProps, CreateSlotsAndSlotProps } from '../utils/types';
export type AccordionSlot = 'root';
export interface AccordionSlots {
/**
* The component that renders the root.
* @default 'div'
*/
root?: React.ElementType;
}
export type AccordionSlotsAndSlotProps = CreateSlotsAndSlotProps<
AccordionSlots,
{
root: SlotProps<'div', {}, AccordionOwnerState>;
}
>;
export interface AccordionPropsVariantOverrides {}
export interface AccordionPropsColorOverrides {}
export interface AccordionTypeMap<ExtraProps = {}, Tag extends React.ElementType = 'div'> {
props: ExtraProps & {
/**
* The id to be used in the AccordionDetails which is controlled by the AccordionSummary.
* If not provided, the id is autogenerated.
*/
accordionId?: string;
/**
* The color of the component. It supports those theme colors that make sense for this component.
* @default 'neutral'
*/
color?: OverridableStringUnion<ColorPaletteProp, AccordionPropsColorOverrides>;
/**
* Used to render icon or text elements inside the Accordion if `src` is not set.
* This can be an element, or just a string.
*/
children?: React.ReactNode;
/**
* If `true`, expands the accordion by default.
* @default false
*/
defaultExpanded?: boolean;
/**
* If `true`, the component is disabled.
* @default false
*/
disabled?: boolean;
/**
* If `true`, expands the accordion, otherwise collapse it.
* Setting this prop enables control over the accordion.
*/
expanded?: boolean;
/**
* Callback fired when the expand/collapse state is changed.
*
* @param {React.SyntheticEvent} event The event source of the callback. **Warning**: This is a generic event not a change event.
* @param {boolean} expanded The `expanded` state of the accordion.
*/
onChange?: (event: React.SyntheticEvent, expanded: boolean) => void;
/**
* The [global variant](https://mui.com/joy-ui/main-features/global-variants/) to use.
* @default 'plain'
*/
variant?: OverridableStringUnion<VariantProp, AccordionPropsVariantOverrides>;
/**
* The system prop that allows defining system overrides as well as additional CSS styles.
*/
sx?: SxProps;
} & AccordionSlotsAndSlotProps;
defaultComponent: Tag;
}
export type AccordionProps<
Tag extends React.ElementType = AccordionTypeMap['defaultComponent'],
ExtraProps = { component?: React.ElementType },
> = OverrideProps<AccordionTypeMap<ExtraProps, Tag>, Tag>;
export interface AccordionOwnerState extends ApplyColorInversion<AccordionProps> {}

View File

@@ -0,0 +1,24 @@
import { generateUtilityClass, generateUtilityClasses } from '../className';
export interface AccordionClasses {
/** Class name applied to the root element. */
root: string;
/** Class name applied to the root element if `expanded` is true. */
expanded: string;
/** Class name applied to the root element if `disabled` is true. */
disabled: string;
}
export type AccordionClassKey = keyof AccordionClasses;
export function getAccordionUtilityClass(slot: string): string {
return generateUtilityClass('MuiAccordion', slot);
}
const accordionClasses: AccordionClasses = generateUtilityClasses('MuiAccordion', [
'root',
'expanded',
'disabled',
]);
export default accordionClasses;

View File

@@ -0,0 +1,4 @@
export { default } from './Accordion';
export * from './accordionClasses';
export { default as accordionClasses } from './accordionClasses';
export * from './AccordionProps';

View File

@@ -0,0 +1,134 @@
import { expect } from 'chai';
import { createRenderer, fireEvent, screen } from '@mui/internal-test-utils';
import { ThemeProvider } from '@mui/joy/styles';
import Accordion from '@mui/joy/Accordion';
import AccordionSummary from '@mui/joy/AccordionSummary';
import AccordionDetails, { accordionDetailsClasses as classes } from '@mui/joy/AccordionDetails';
import describeConformance from '../../test/describeConformance';
describe('<AccordionDetails />', () => {
const { render } = createRenderer();
describeConformance(<AccordionDetails />, () => ({
classes,
inheritComponent: 'div',
render,
ThemeProvider,
muiName: 'JoyAccordionDetails',
refInstanceof: window.HTMLDivElement,
testComponentPropWith: 'span',
skip: ['classesRoot', 'componentsProp', 'themeVariants'],
slots: {
root: {
expectedClassName: classes.root,
},
content: {
expectedClassName: classes.content,
},
},
}));
describe('tab index', () => {
it('[initial] interactive content should have tab index -1', () => {
render(
<Accordion>
<AccordionDetails>
<a href="/foo" data-testid="link">
Hello
</a>
<input data-testid="textbox" />
</AccordionDetails>
</Accordion>,
);
expect(screen.getByTestId('link')).to.have.property('tabIndex', -1);
expect(screen.getByTestId('textbox')).to.have.property('tabIndex', -1);
});
it('[expanded] interactive content should not have tab index 0', () => {
render(
<Accordion expanded>
<AccordionDetails>
<a href="/foo" data-testid="link">
Hello
</a>
<input data-testid="textbox" />
</AccordionDetails>
</Accordion>,
);
expect(screen.getByTestId('link')).to.have.property('tabIndex', 0);
expect(screen.getByTestId('textbox')).to.have.property('tabIndex', 0);
});
it('interactive content should preserve the tab index when closed', () => {
render(
<Accordion defaultExpanded>
<AccordionSummary>title</AccordionSummary>
<AccordionDetails>
<input tabIndex={2} data-testid="textbox" />
</AccordionDetails>
</Accordion>,
);
expect(screen.getByRole('button')).to.have.attribute('aria-expanded', 'true');
expect(screen.getByTestId('textbox')).to.have.property('tabIndex', 2);
fireEvent.click(screen.getByRole('button')); // close
expect(screen.getByRole('button')).to.have.attribute('aria-expanded', 'false');
expect(screen.getByTestId('textbox')).to.have.property('tabIndex', -1);
fireEvent.click(screen.getByRole('button')); // reopen
expect(screen.getByRole('button')).to.have.attribute('aria-expanded', 'true');
expect(screen.getByTestId('textbox')).to.have.property('tabIndex', 2);
});
it('should retain the default tab index if not explicitly set', () => {
render(
<Accordion>
<AccordionSummary>title</AccordionSummary>
<AccordionDetails>
<input data-testid="textbox" />
</AccordionDetails>
</Accordion>,
);
expect(screen.getByTestId('textbox')).to.have.property('tabIndex', -1);
fireEvent.click(screen.getByRole('button')); // open
expect(screen.getByTestId('textbox')).to.have.property('tabIndex', 0);
fireEvent.click(screen.getByRole('button')); // close
expect(screen.getByTestId('textbox')).to.have.property('tabIndex', -1);
fireEvent.click(screen.getByRole('button')); // reopen
expect(screen.getByTestId('textbox')).to.have.property('tabIndex', 0);
});
it('should retain the -1 tab index when explicitly set', () => {
render(
<Accordion>
<AccordionSummary>title</AccordionSummary>
<AccordionDetails>
<input data-testid="textbox" tabIndex={-1} />
</AccordionDetails>
</Accordion>,
);
expect(screen.getByTestId('textbox')).to.have.property('tabIndex', -1);
fireEvent.click(screen.getByRole('button')); // open
expect(screen.getByTestId('textbox')).to.have.property('tabIndex', -1);
fireEvent.click(screen.getByRole('button')); // close
expect(screen.getByTestId('textbox')).to.have.property('tabIndex', -1);
});
});
});

View File

@@ -0,0 +1,221 @@
'use client';
import * as React from 'react';
import PropTypes from 'prop-types';
import useForkRef from '@mui/utils/useForkRef';
import { unstable_composeClasses as composeClasses } from '@mui/base';
import { OverridableComponent } from '@mui/types';
import { useThemeProps } from '../styles';
import styled from '../styles/styled';
import accordionDetailsClasses, {
getAccordionDetailsUtilityClass,
} from './accordionDetailsClasses';
import {
AccordionDetailsProps,
AccordionDetailsOwnerState,
AccordionDetailsTypeMap,
} from './AccordionDetailsProps';
import useSlot from '../utils/useSlot';
import AccordionContext from '../Accordion/AccordionContext';
const useUtilityClasses = (ownerState: AccordionDetailsOwnerState) => {
const { expanded } = ownerState;
const slots = {
root: ['root', expanded && 'expanded'],
content: ['content', expanded && 'expanded'],
};
return composeClasses(slots, getAccordionDetailsUtilityClass, {});
};
const AccordionDetailsRoot = styled('div', {
name: 'JoyAccordionDetails',
slot: 'Root',
overridesResolver: (props, styles) => styles.root,
})<{ ownerState: AccordionDetailsOwnerState }>(({ ownerState, theme }) => ({
overflow: 'hidden',
borderRadius: 'var(--AccordionDetails-radius)',
display: 'grid',
gridTemplateRows: '1fr',
marginInline: 'calc(-1 * var(--ListItem-paddingLeft)) calc(-1 * var(--ListItem-paddingRight))',
transition: 'var(--AccordionDetails-transition)',
...theme.variants[ownerState.variant!]?.[ownerState.color!],
[`&:not(.${accordionDetailsClasses.expanded})`]: {
gridTemplateRows: '0fr',
},
}));
/**
* The content slot is required because the root slot is a CSS Grid, it needs a child.
*/
const AccordionDetailsContent = styled('div', {
name: 'JoyAccordionDetails',
slot: 'Content',
overridesResolver: (props, styles) => styles.root,
})<{ ownerState: AccordionDetailsOwnerState }>({
display: 'flex',
flexDirection: 'column',
overflow: 'hidden', // required for user-provided transition to work
// Need to apply padding to content rather than root because the overflow.
// Otherwise, the focus ring of the children can be cut off.
paddingInlineStart: 'var(--ListItem-paddingLeft)',
paddingInlineEnd: 'var(--ListItem-paddingRight)',
paddingBlockStart: 'calc(var(--ListItem-paddingY) / 2)',
paddingBlockEnd: 'calc(2.5 * var(--ListItem-paddingY))',
transition: 'var(--AccordionDetails-transition)',
[`&:not(.${accordionDetailsClasses.expanded})`]: {
paddingBlock: 0,
},
});
/**
*
* Demos:
*
* - [Accordion](https://mui.com/joy-ui/react-accordion/)
*
* API:
*
* - [AccordionDetails API](https://mui.com/joy-ui/api/accordion-details/)
*/
const AccordionDetails = React.forwardRef(function AccordionDetails(inProps, ref) {
const props = useThemeProps<typeof inProps & AccordionDetailsProps>({
props: inProps,
name: 'JoyAccordionDetails',
});
const {
component = 'div',
children,
color = 'neutral',
variant = 'plain',
slots = {},
slotProps = {},
...other
} = props;
const { accordionId, expanded = false } = React.useContext(AccordionContext);
const rootRef = React.useRef<HTMLElement>(null);
const handleRef = useForkRef(rootRef, ref);
React.useEffect(() => {
if (rootRef.current) {
const elements = rootRef.current.querySelectorAll(
'a, button, input, textarea, select, details, [tabindex]:not([tabindex="-1"])',
);
elements.forEach((elm) => {
const currentTabIndex = elm.getAttribute('tabindex');
const prevTabIndex = elm.getAttribute('data-prev-tabindex');
if (expanded) {
// Restore the previous tabindex if it exists, or remove it if it was "unset"
if (prevTabIndex === 'unset') {
elm.removeAttribute('tabindex');
} else if (prevTabIndex !== null) {
elm.setAttribute('tabindex', prevTabIndex);
}
elm.removeAttribute('data-prev-tabindex');
} else {
// If element has no data-prev-tabindex, store the current tabindex or "unset"
if (prevTabIndex === null) {
elm.setAttribute('data-prev-tabindex', currentTabIndex || 'unset');
}
elm.setAttribute('tabindex', '-1');
}
});
}
}, [expanded]);
const externalForwardedProps = { ...other, component, slots, slotProps };
const ownerState = {
...props,
component,
color,
variant,
expanded,
nesting: true, // for the List styles
};
const classes = useUtilityClasses(ownerState);
const [SlotRoot, rootProps] = useSlot('root', {
ref: handleRef,
className: classes.root,
elementType: AccordionDetailsRoot,
externalForwardedProps,
additionalProps: {
id: `${accordionId}-details`,
'aria-labelledby': `${accordionId}-summary`,
role: 'region',
hidden: expanded ? undefined : true,
},
ownerState,
});
const [SlotContent, contentProps] = useSlot('content', {
className: classes.content,
elementType: AccordionDetailsContent,
externalForwardedProps,
ownerState,
});
return (
<SlotRoot {...rootProps}>
<SlotContent {...contentProps}>{children}</SlotContent>
</SlotRoot>
);
}) as OverridableComponent<AccordionDetailsTypeMap>;
AccordionDetails.propTypes /* remove-proptypes */ = {
// ┌────────────────────────────── Warning ──────────────────────────────┐
// │ These PropTypes are generated from the TypeScript type definitions. │
// │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
// └─────────────────────────────────────────────────────────────────────┘
/**
* Used to render icon or text elements inside the AccordionDetails if `src` is not set.
* This can be an element, or just a string.
*/
children: PropTypes.node,
/**
* The color of the component. It supports those theme colors that make sense for this component.
* @default 'neutral'
*/
color: PropTypes.oneOf(['danger', 'neutral', 'primary', 'success', 'warning']),
/**
* The component used for the root node.
* Either a string to use a HTML element or a component.
*/
component: PropTypes.elementType,
/**
* The props used for each slot inside.
* @default {}
*/
slotProps: PropTypes.shape({
content: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
root: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
}),
/**
* The components used for each slot inside.
* @default {}
*/
slots: PropTypes.shape({
content: PropTypes.elementType,
root: PropTypes.elementType,
}),
/**
* The system prop that allows defining system overrides as well as additional CSS styles.
*/
sx: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.func, PropTypes.object, PropTypes.bool])),
PropTypes.func,
PropTypes.object,
]),
/**
* The [global variant](https://mui.com/joy-ui/main-features/global-variants/) to use.
* @default 'plain'
*/
variant: PropTypes.oneOf(['outlined', 'plain', 'soft', 'solid']),
} as any;
export default AccordionDetails;

View File

@@ -0,0 +1,67 @@
import * as React from 'react';
import { OverridableStringUnion, OverrideProps } from '@mui/types';
import { ColorPaletteProp, SxProps, VariantProp, ApplyColorInversion } from '../styles/types';
import { SlotProps, CreateSlotsAndSlotProps } from '../utils/types';
export type AccordionDetailsSlot = 'root' | 'content';
export interface AccordionDetailsSlots {
/**
* The component that renders the root.
* @default 'div'
*/
root?: React.ElementType;
/**
* The component that renders the content.
* @default 'div'
*/
content?: React.ElementType;
}
export type AccordionDetailsSlotsAndSlotProps = CreateSlotsAndSlotProps<
AccordionDetailsSlots,
{
root: SlotProps<'div', {}, AccordionDetailsOwnerState>;
content: SlotProps<'div', {}, AccordionDetailsOwnerState>;
}
>;
export interface AccordionDetailsPropsVariantOverrides {}
export interface AccordionDetailsPropsColorOverrides {}
export interface AccordionDetailsTypeMap<P = {}, D extends React.ElementType = 'div'> {
props: P & {
/**
* Used to render icon or text elements inside the AccordionDetails if `src` is not set.
* This can be an element, or just a string.
*/
children?: React.ReactNode;
/**
* The color of the component. It supports those theme colors that make sense for this component.
* @default 'neutral'
*/
color?: OverridableStringUnion<ColorPaletteProp, AccordionDetailsPropsColorOverrides>;
/**
* The [global variant](https://mui.com/joy-ui/main-features/global-variants/) to use.
* @default 'plain'
*/
variant?: OverridableStringUnion<VariantProp, AccordionDetailsPropsVariantOverrides>;
/**
* The system prop that allows defining system overrides as well as additional CSS styles.
*/
sx?: SxProps;
} & AccordionDetailsSlotsAndSlotProps;
defaultComponent: D;
}
export type AccordionDetailsProps<
D extends React.ElementType = AccordionDetailsTypeMap['defaultComponent'],
P = { component?: React.ElementType },
> = OverrideProps<AccordionDetailsTypeMap<P, D>, D>;
export interface AccordionDetailsOwnerState extends ApplyColorInversion<AccordionDetailsProps> {
/**
* The expanded state of the accordion.
*/
expanded: boolean;
}

View File

@@ -0,0 +1,23 @@
import { generateUtilityClass, generateUtilityClasses } from '../className';
export interface AccordionDetailsClasses {
/** Class name applied to the root element. */
root: string;
/** Class name applied to the content element. */
content: string;
/** Class name applied to the root element when expanded. */
expanded: string;
}
export type AccordionDetailsClassKey = keyof AccordionDetailsClasses;
export function getAccordionDetailsUtilityClass(slot: string): string {
return generateUtilityClass('MuiAccordionDetails', slot);
}
const accordionDetailsClasses: AccordionDetailsClasses = generateUtilityClasses(
'MuiAccordionDetails',
['root', 'content', 'expanded'],
);
export default accordionDetailsClasses;

View File

@@ -0,0 +1,4 @@
export { default } from './AccordionDetails';
export * from './accordionDetailsClasses';
export { default as accordionDetailsClasses } from './accordionDetailsClasses';
export * from './AccordionDetailsProps';

View File

@@ -0,0 +1,78 @@
import { expect } from 'chai';
import { createRenderer, screen } from '@mui/internal-test-utils';
import { ThemeProvider } from '@mui/joy/styles';
import AccordionGroup, { accordionGroupClasses as classes } from '@mui/joy/AccordionGroup';
import describeConformance from '../../test/describeConformance';
describe('<AccordionGroup />', () => {
const { render } = createRenderer();
describeConformance(<AccordionGroup />, () => ({
classes,
inheritComponent: 'div',
render,
ThemeProvider,
muiName: 'JoyAccordionGroup',
refInstanceof: window.HTMLDivElement,
testComponentPropWith: 'span',
skip: ['classesRoot', 'componentsProp', 'themeVariants'],
slots: {
root: {
expectedClassName: classes.root,
},
},
}));
describe('classes', () => {
(
[
{ size: 'sm', class: classes.sizeSm },
{ size: 'md', class: classes.sizeMd },
{ size: 'lg', class: classes.sizeLg },
] as const
).forEach((sizeConfig) => {
it(`should have ${sizeConfig.class} class for ${sizeConfig.size} size `, () => {
render(<AccordionGroup data-testid="root" size={sizeConfig.size} />);
expect(screen.getByTestId('root')).to.have.class(sizeConfig.class);
});
});
(
[
{ variant: 'outlined', class: classes.variantOutlined },
{ variant: 'plain', class: classes.variantPlain },
{ variant: 'soft', class: classes.variantSoft },
{ variant: 'solid', class: classes.variantSolid },
] as const
).forEach((variantConfig) => {
it(`should have ${variantConfig.class} class for ${variantConfig.variant} variant `, () => {
render(<AccordionGroup data-testid="root" variant={variantConfig.variant} />);
expect(screen.getByTestId('root')).to.have.class(variantConfig.class);
});
});
(
[
{ color: 'danger', class: classes.colorDanger },
{ color: 'neutral', class: classes.colorNeutral },
{ color: 'primary', class: classes.colorPrimary },
{ color: 'success', class: classes.colorSuccess },
] as const
).forEach((colorConfig) => {
it(`should have ${colorConfig.class} class for ${colorConfig.color} color `, () => {
render(<AccordionGroup data-testid="root" color={colorConfig.color} />);
expect(screen.getByTestId('root')).to.have.class(colorConfig.class);
});
});
});
it('should not warn when using custom color, variant, size', () => {
expect(() => {
// @ts-expect-error as `custom` color, variant, size is not part of the type system
render(<AccordionGroup color="custom" variant="custom" size="custom" />);
}).not.toErrorDev();
});
});

View File

@@ -0,0 +1,205 @@
'use client';
import * as React from 'react';
import PropTypes from 'prop-types';
import capitalize from '@mui/utils/capitalize';
import { unstable_composeClasses as composeClasses } from '@mui/base';
import { OverridableComponent } from '@mui/types';
import { useThemeProps } from '../styles';
import styled from '../styles/styled';
import { getAccordionGroupUtilityClass } from './accordionGroupClasses';
import {
AccordionGroupProps,
AccordionGroupOwnerState,
AccordionGroupTypeMap,
} from './AccordionGroupProps';
import useSlot from '../utils/useSlot';
import ListProvider from '../List/ListProvider';
import { StyledList } from '../List/List';
import accordionDetailsClasses from '../AccordionDetails/accordionDetailsClasses';
import accordionClasses from '../Accordion/accordionClasses';
const useUtilityClasses = (ownerState: AccordionGroupOwnerState) => {
const { variant, color, size } = ownerState;
const slots = {
root: [
'root',
variant && `variant${capitalize(variant)}`,
color && `color${capitalize(color)}`,
size && `size${capitalize(size)}`,
],
};
return composeClasses(slots, getAccordionGroupUtilityClass, {});
};
const AccordionGroupRoot = styled(StyledList as unknown as 'div', {
name: 'JoyAccordionGroup',
slot: 'Root',
overridesResolver: (props, styles) => styles.root,
})<{ ownerState: AccordionGroupOwnerState }>(({ theme, ownerState }) => {
let transition: Record<string, any> = {};
if (ownerState.transition) {
if (typeof ownerState.transition === 'string') {
transition = {
'--AccordionDetails-transition': `grid-template-rows ${ownerState.transition}, padding-block ${ownerState.transition}`,
};
}
if (typeof ownerState.transition === 'object') {
transition = {
'--AccordionDetails-transition': `grid-template-rows ${ownerState.transition.initial}, padding-block ${ownerState.transition.initial}`,
[`& .${accordionDetailsClasses.root}.${accordionDetailsClasses.expanded}`]: {
'--AccordionDetails-transition': `grid-template-rows ${ownerState.transition.expanded}, padding-block ${ownerState.transition.expanded}`,
},
};
}
}
return {
'--List-padding': '0px',
'--ListDivider-gap': '0px',
...transition,
...(!ownerState.disableDivider && {
[`& .${accordionClasses.root}:not([data-last-child])`]: {
'--Accordion-borderBottom': `1px solid ${theme.vars.palette.divider}`,
},
}),
};
});
/**
*
* Demos:
*
* - [Accordion](https://mui.com/joy-ui/react-accordion/)
*
* API:
*
* - [AccordionGroup API](https://mui.com/joy-ui/api/accordion-group/)
*/
const AccordionGroup = React.forwardRef(function AccordionGroup(inProps, ref) {
const props = useThemeProps<typeof inProps & AccordionGroupProps>({
props: inProps,
name: 'JoyAccordionGroup',
});
const {
component = 'div',
color = 'neutral',
children,
disableDivider = false,
variant = 'plain',
transition = '0.2s ease',
size = 'md',
slots = {},
slotProps = {},
...other
} = props;
const externalForwardedProps = { ...other, component, slots, slotProps };
const ownerState = {
...props,
component,
color,
disableDivider,
variant,
transition,
size,
};
const classes = useUtilityClasses(ownerState);
const [SlotRoot, rootProps] = useSlot('root', {
ref,
className: classes.root,
elementType: AccordionGroupRoot,
externalForwardedProps,
ownerState,
});
return (
<SlotRoot {...rootProps}>
<ListProvider>{children}</ListProvider>
</SlotRoot>
);
}) as OverridableComponent<AccordionGroupTypeMap>;
AccordionGroup.propTypes /* remove-proptypes */ = {
// ┌────────────────────────────── Warning ──────────────────────────────┐
// │ These PropTypes are generated from the TypeScript type definitions. │
// │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
// └─────────────────────────────────────────────────────────────────────┘
/**
* Used to render icon or text elements inside the AccordionGroup if `src` is not set.
* This can be an element, or just a string.
*/
children: PropTypes.node,
/**
* The color of the component. It supports those theme colors that make sense for this component.
* @default 'neutral'
*/
color: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([
PropTypes.oneOf(['danger', 'neutral', 'primary', 'success', 'warning']),
PropTypes.string,
]),
/**
* The component used for the root node.
* Either a string to use a HTML element or a component.
*/
component: PropTypes.elementType,
/**
* If `true`, the divider between accordions will be hidden.
* @default false
*/
disableDivider: PropTypes.bool,
/**
* The size of the component (affect other nested list* components).
* @default 'md'
*/
size: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([
PropTypes.oneOf(['sm', 'md', 'lg']),
PropTypes.string,
]),
/**
* The props used for each slot inside.
* @default {}
*/
slotProps: PropTypes.shape({
root: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
}),
/**
* The components used for each slot inside.
* @default {}
*/
slots: PropTypes.shape({
root: PropTypes.elementType,
}),
/**
* The system prop that allows defining system overrides as well as additional CSS styles.
*/
sx: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.func, PropTypes.object, PropTypes.bool])),
PropTypes.func,
PropTypes.object,
]),
/**
* The CSS transition for the Accordion details.
* @default '0.2s ease'
*/
transition: PropTypes.oneOfType([
PropTypes.shape({
expanded: PropTypes.string.isRequired,
initial: PropTypes.string.isRequired,
}),
PropTypes.string,
]),
/**
* The [global variant](https://mui.com/joy-ui/main-features/global-variants/) to use.
* @default 'plain'
*/
variant: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([
PropTypes.oneOf(['outlined', 'plain', 'soft', 'solid']),
PropTypes.string,
]),
} as any;
export default AccordionGroup;

View File

@@ -0,0 +1,72 @@
import * as React from 'react';
import { OverridableStringUnion, OverrideProps } from '@mui/types';
import { ColorPaletteProp, SxProps, VariantProp, ApplyColorInversion } from '../styles/types';
import { SlotProps, CreateSlotsAndSlotProps } from '../utils/types';
export type AccordionGroupSlot = 'root';
export interface AccordionGroupSlots {
/**
* The component that renders the root.
* @default 'div'
*/
root?: React.ElementType;
}
export type AccordionGroupSlotsAndSlotProps = CreateSlotsAndSlotProps<
AccordionGroupSlots,
{
root: SlotProps<'div', {}, AccordionGroupOwnerState>;
}
>;
export interface AccordionGroupPropsSizeOverrides {}
export interface AccordionGroupPropsVariantOverrides {}
export interface AccordionGroupPropsColorOverrides {}
export interface AccordionGroupTypeMap<P = {}, D extends React.ElementType = 'div'> {
props: P & {
/**
* Used to render icon or text elements inside the AccordionGroup if `src` is not set.
* This can be an element, or just a string.
*/
children?: React.ReactNode;
/**
* The color of the component. It supports those theme colors that make sense for this component.
* @default 'neutral'
*/
color?: OverridableStringUnion<ColorPaletteProp, AccordionGroupPropsColorOverrides>;
/**
* If `true`, the divider between accordions will be hidden.
* @default false
*/
disableDivider?: boolean;
/**
* The size of the component (affect other nested list* components).
* @default 'md'
*/
size?: OverridableStringUnion<'sm' | 'md' | 'lg', AccordionGroupPropsSizeOverrides>;
/**
* The CSS transition for the Accordion details.
* @default '0.2s ease'
*/
transition?: string | { initial: string; expanded: string };
/**
* The [global variant](https://mui.com/joy-ui/main-features/global-variants/) to use.
* @default 'plain'
*/
variant?: OverridableStringUnion<VariantProp, AccordionGroupPropsVariantOverrides>;
/**
* The system prop that allows defining system overrides as well as additional CSS styles.
*/
sx?: SxProps;
} & AccordionGroupSlotsAndSlotProps;
defaultComponent: D;
}
export type AccordionGroupProps<
D extends React.ElementType = AccordionGroupTypeMap['defaultComponent'],
P = { component?: React.ElementType },
> = OverrideProps<AccordionGroupTypeMap<P, D>, D>;
export interface AccordionGroupOwnerState extends ApplyColorInversion<AccordionGroupProps> {}

View File

@@ -0,0 +1,57 @@
import { generateUtilityClass, generateUtilityClasses } from '../className';
export interface AccordionGroupClasses {
/** Class name applied to the root element. */
root: string;
/** Class name applied to the root element if `color="primary"`. */
colorPrimary: string;
/** Class name applied to the root element if `color="neutral"`. */
colorNeutral: string;
/** Class name applied to the root element if `color="danger"`. */
colorDanger: string;
/** Class name applied to the root element if `color="success"`. */
colorSuccess: string;
/** Class name applied to the root element if `color="warning"`. */
colorWarning: string;
/** Class name applied to the root element when color inversion is triggered. */
colorContext: string;
/** Class name applied to the root element if `variant="plain"`. */
variantPlain: string;
/** Class name applied to the root element if `variant="outlined"`. */
variantOutlined: string;
/** Class name applied to the root element if `variant="soft"`. */
variantSoft: string;
/** Class name applied to the root element if `variant="solid"`. */
variantSolid: string;
/** Class name applied to the root element if `size="sm"`. */
sizeSm: string;
/** Class name applied to the root element if `size="md"`. */
sizeMd: string;
/** Class name applied to the root element if `size="lg"`. */
sizeLg: string;
}
export type AccordionGroupClassKey = keyof AccordionGroupClasses;
export function getAccordionGroupUtilityClass(slot: string): string {
return generateUtilityClass('MuiAccordionGroup', slot);
}
const accordionGroupClasses: AccordionGroupClasses = generateUtilityClasses('MuiAccordionGroup', [
'root',
'colorPrimary',
'colorNeutral',
'colorDanger',
'colorSuccess',
'colorWarning',
'colorContext',
'variantPlain',
'variantOutlined',
'variantSoft',
'variantSolid',
'sizeSm',
'sizeMd',
'sizeLg',
]);
export default accordionGroupClasses;

View File

@@ -0,0 +1,4 @@
export { default } from './AccordionGroup';
export * from './accordionGroupClasses';
export { default as accordionGroupClasses } from './accordionGroupClasses';
export * from './AccordionGroupProps';

View File

@@ -0,0 +1,24 @@
import { createRenderer } from '@mui/internal-test-utils';
import { ThemeProvider } from '@mui/joy/styles';
import AccordionSummary, { accordionSummaryClasses as classes } from '@mui/joy/AccordionSummary';
import describeConformance from '../../test/describeConformance';
describe('<AccordionSummary />', () => {
const { render } = createRenderer();
describeConformance(<AccordionSummary />, () => ({
classes,
inheritComponent: 'div',
render,
ThemeProvider,
muiName: 'JoyAccordionSummary',
refInstanceof: window.HTMLDivElement,
testComponentPropWith: 'span',
skip: ['classesRoot', 'componentsProp', 'themeVariants'],
slots: {
root: {
expectedClassName: classes.root,
},
},
}));
});

View File

@@ -0,0 +1,233 @@
'use client';
import * as React from 'react';
import PropTypes from 'prop-types';
import { unstable_composeClasses as composeClasses } from '@mui/base';
import { OverridableComponent } from '@mui/types';
import { useThemeProps } from '../styles';
import styled from '../styles/styled';
import accordionSummaryClasses, {
getAccordionSummaryUtilityClass,
} from './accordionSummaryClasses';
import {
AccordionSummaryProps,
AccordionSummaryOwnerState,
AccordionSummaryTypeMap,
} from './AccordionSummaryProps';
import useSlot from '../utils/useSlot';
import AccordionContext from '../Accordion/AccordionContext';
import { StyledListItem } from '../ListItem/ListItem';
import { StyledListItemButton } from '../ListItemButton/ListItemButton';
import KeyboardArrowDown from '../internal/svg-icons/KeyboardArrowDown';
const useUtilityClasses = (ownerState: AccordionSummaryOwnerState) => {
const { disabled, expanded } = ownerState;
const slots = {
root: ['root', disabled && 'disabled', expanded && 'expanded'],
button: ['button', disabled && 'disabled', expanded && 'expanded'],
indicator: ['indicator', disabled && 'disabled', expanded && 'expanded'],
};
return composeClasses(slots, getAccordionSummaryUtilityClass, {});
};
const AccordionSummaryRoot = styled(StyledListItem as unknown as 'div', {
name: 'JoyAccordionSummary',
slot: 'Root',
overridesResolver: (props, styles) => styles.root,
})<{ ownerState: AccordionSummaryOwnerState }>(({ theme }) => ({
fontWeight: theme.vars.fontWeight.md,
gap: 'calc(var(--ListItem-paddingX, 0.75rem) + 0.25rem)',
[`&.${accordionSummaryClasses.expanded}`]: {
'--Icon-color': 'currentColor',
},
}));
const AccordionSummaryButton = styled(StyledListItemButton as unknown as 'button', {
name: 'JoyAccordionSummary',
slot: 'Button',
overridesResolver: (props, styles) => styles.button,
})<{ ownerState: AccordionSummaryOwnerState }>({
gap: 'inherit',
fontWeight: 'inherit',
justifyContent: 'space-between',
font: 'inherit',
'&:focus-visible': {
zIndex: 1, // to make the focus ring appear above the next Accordion.
},
[`.${accordionSummaryClasses.root} &`]: {
'--unstable_ListItem-flex': '1 0 0%', // grow to fill the available space of ListItem
},
});
const AccordionSummaryIndicator = styled('span', {
name: 'JoyAccordionSummary',
slot: 'Indicator',
overridesResolver: (props, styles) => styles.indicator,
})<{ ownerState: AccordionSummaryOwnerState }>({
display: 'inline-flex',
[`&.${accordionSummaryClasses.expanded}`]: {
transform: 'rotate(180deg)',
},
});
/**
*
* Demos:
*
* - [Accordion](https://mui.com/joy-ui/react-accordion/)
*
* API:
*
* - [AccordionSummary API](https://mui.com/joy-ui/api/accordion-summary/)
*/
const AccordionSummary = React.forwardRef(function AccordionSummary(inProps, ref) {
const props = useThemeProps<typeof inProps & AccordionSummaryProps>({
props: inProps,
name: 'JoyAccordionSummary',
});
const {
component = 'div',
color = 'neutral',
children,
indicator = <KeyboardArrowDown />,
variant = 'plain',
slots = {},
slotProps = {},
...other
} = props;
const {
accordionId,
disabled = false,
expanded = false,
toggle,
} = React.useContext(AccordionContext);
const externalForwardedProps = { ...other, component, slots, slotProps };
const ownerState = {
...props,
component,
color,
disabled,
expanded,
variant,
};
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
if (toggle) {
toggle(event);
}
if (typeof slotProps.button === 'function') {
slotProps.button(ownerState)?.onClick?.(event);
} else {
slotProps.button?.onClick?.(event);
}
};
const classes = useUtilityClasses(ownerState);
const [SlotRoot, rootProps] = useSlot('root', {
ref,
className: classes.root,
elementType: AccordionSummaryRoot,
externalForwardedProps,
ownerState,
});
const [SlotButton, buttonProps] = useSlot('button', {
ref,
className: classes.button,
elementType: AccordionSummaryButton,
externalForwardedProps,
additionalProps: {
component: 'button',
id: `${accordionId}-summary`,
'aria-expanded': expanded ? 'true' : 'false',
'aria-controls': `${accordionId}-details`,
disabled,
type: 'button',
onClick: handleClick,
},
ownerState,
});
const [SlotIndicator, indicatorProps] = useSlot('indicator', {
ref,
className: classes.indicator,
elementType: AccordionSummaryIndicator,
externalForwardedProps,
ownerState,
});
return (
// Root and Button slots are required based on [WAI-ARIA Accordion](https://www.w3.org/WAI/ARIA/apg/patterns/accordion/examples/accordion/)
<SlotRoot {...rootProps}>
<SlotButton {...buttonProps}>
{children}
{indicator && <SlotIndicator {...indicatorProps}>{indicator}</SlotIndicator>}
</SlotButton>
</SlotRoot>
);
}) as OverridableComponent<AccordionSummaryTypeMap>;
AccordionSummary.propTypes /* remove-proptypes */ = {
// ┌────────────────────────────── Warning ──────────────────────────────┐
// │ These PropTypes are generated from the TypeScript type definitions. │
// │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
// └─────────────────────────────────────────────────────────────────────┘
/**
* Used to render icon or text elements inside the AccordionSummary if `src` is not set.
* This can be an element, or just a string.
*/
children: PropTypes.node,
/**
* The color of the component. It supports those theme colors that make sense for this component.
* @default 'neutral'
*/
color: PropTypes.oneOf(['danger', 'neutral', 'primary', 'success', 'warning']),
/**
* The component used for the root node.
* Either a string to use a HTML element or a component.
*/
component: PropTypes.elementType,
/**
* The indicator element to display.
* @default <KeyboardArrowDown />
*/
indicator: PropTypes.node,
/**
* The props used for each slot inside.
* @default {}
*/
slotProps: PropTypes.shape({
button: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
indicator: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
root: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
}),
/**
* The components used for each slot inside.
* @default {}
*/
slots: PropTypes.shape({
button: PropTypes.elementType,
indicator: PropTypes.elementType,
root: PropTypes.elementType,
}),
/**
* The system prop that allows defining system overrides as well as additional CSS styles.
*/
sx: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.func, PropTypes.object, PropTypes.bool])),
PropTypes.func,
PropTypes.object,
]),
/**
* The [global variant](https://mui.com/joy-ui/main-features/global-variants/) to use.
* @default 'plain'
*/
variant: PropTypes.oneOf(['outlined', 'plain', 'soft', 'solid']),
} as any;
export default AccordionSummary;

View File

@@ -0,0 +1,83 @@
import * as React from 'react';
import { OverridableStringUnion, OverrideProps } from '@mui/types';
import { ColorPaletteProp, SxProps, VariantProp, ApplyColorInversion } from '../styles/types';
import { SlotProps, CreateSlotsAndSlotProps } from '../utils/types';
export type AccordionSummarySlot = 'root' | 'button' | 'indicator';
export interface AccordionSummarySlots {
/**
* The component that renders the root.
* @default 'div'
*/
root?: React.ElementType;
/**
* The component that renders the button.
* @default 'button'
*/
button?: React.ElementType;
/**
* The component that renders the indicator.
* @default 'span'
*/
indicator?: React.ElementType;
}
export type AccordionSummarySlotsAndSlotProps = CreateSlotsAndSlotProps<
AccordionSummarySlots,
{
root: SlotProps<'div', {}, AccordionSummaryOwnerState>;
button: SlotProps<'button', {}, AccordionSummaryOwnerState>;
indicator: SlotProps<'span', {}, AccordionSummaryOwnerState>;
}
>;
export interface AccordionSummaryPropsVariantOverrides {}
export interface AccordionSummaryPropsColorOverrides {}
export interface AccordionSummaryTypeMap<P = {}, D extends React.ElementType = 'div'> {
props: P & {
/**
* Used to render icon or text elements inside the AccordionSummary if `src` is not set.
* This can be an element, or just a string.
*/
children?: React.ReactNode;
/**
* The color of the component. It supports those theme colors that make sense for this component.
* @default 'neutral'
*/
color?: OverridableStringUnion<ColorPaletteProp, AccordionSummaryPropsColorOverrides>;
/**
* The indicator element to display.
* @default <KeyboardArrowDown />
*/
indicator?: React.ReactNode;
/**
* The [global variant](https://mui.com/joy-ui/main-features/global-variants/) to use.
* @default 'plain'
*/
variant?: OverridableStringUnion<VariantProp, AccordionSummaryPropsVariantOverrides>;
/**
* The system prop that allows defining system overrides as well as additional CSS styles.
*/
sx?: SxProps;
} & AccordionSummarySlotsAndSlotProps;
defaultComponent: D;
}
export type AccordionSummaryProps<
D extends React.ElementType = AccordionSummaryTypeMap['defaultComponent'],
P = { component?: React.ElementType },
> = OverrideProps<AccordionSummaryTypeMap<P, D>, D>;
export interface AccordionSummaryOwnerState extends ApplyColorInversion<AccordionSummaryProps> {
/**
* If `true`, the accordion is disabled.
*/
disabled: boolean;
/**
* The expanded state of the accordion.
*/
expanded: boolean;
}

View File

@@ -0,0 +1,27 @@
import { generateUtilityClass, generateUtilityClasses } from '../className';
export interface AccordionSummaryClasses {
/** Class name applied to the root element. */
root: string;
/** Class name applied to the button element. */
button: string;
/** Class name applied to the indicator element. */
indicator: string;
/** Class name applied when the accordion is disabled. */
disabled: string;
/** Class name applied when the accordion is expanded. */
expanded: string;
}
export type AccordionSummaryClassKey = keyof AccordionSummaryClasses;
export function getAccordionSummaryUtilityClass(slot: string): string {
return generateUtilityClass('MuiAccordionSummary', slot);
}
const accordionSummaryClasses: AccordionSummaryClasses = generateUtilityClasses(
'MuiAccordionSummary',
['root', 'button', 'indicator', 'disabled', 'expanded'],
);
export default accordionSummaryClasses;

View File

@@ -0,0 +1,4 @@
export { default } from './AccordionSummary';
export * from './accordionSummaryClasses';
export { default as accordionSummaryClasses } from './accordionSummaryClasses';
export * from './AccordionSummaryProps';

View File

@@ -0,0 +1,79 @@
import { expectType } from '@mui/types';
import Alert, { AlertOwnerState } from '@mui/joy/Alert';
<Alert />;
<Alert component="div" />;
// `variant`
<Alert />;
<Alert variant="soft" />;
<Alert variant="outlined" />;
<Alert variant="solid" />;
// `color`
<Alert color="primary" />;
<Alert color="danger" />;
<Alert color="success" />;
<Alert color="warning" />;
<Alert color="neutral" />;
// `size`
<Alert size="sm" />;
<Alert size="md" />;
<Alert size="lg" />;
// @ts-expect-error there is no variant `filled`
<Alert variant="filled" />;
// @ts-expect-error there is no color `secondary`
<Alert color="secondary" />;
// @ts-expect-error there is no size `xl2`
<Alert size="xl2" />;
<Alert
slots={{
root: 'div',
startDecorator: 'div',
endDecorator: 'div',
}}
/>;
<Alert
slotProps={{
root: {
component: 'div',
'data-testid': 'test',
},
startDecorator: {
component: 'div',
'data-testid': 'test',
},
endDecorator: {
component: 'div',
'data-testid': 'test',
},
}}
/>;
<Alert
slotProps={{
root: (ownerState) => {
expectType<AlertOwnerState, typeof ownerState>(ownerState);
return {
'data-testid': 'test',
};
},
startDecorator: (ownerState) => {
expectType<AlertOwnerState, typeof ownerState>(ownerState);
return {
'data-testid': 'test',
};
},
endDecorator: (ownerState) => {
expectType<AlertOwnerState, typeof ownerState>(ownerState);
return {
'data-testid': 'test',
};
},
}}
/>;

View File

@@ -0,0 +1,82 @@
import { expect } from 'chai';
import { createRenderer, screen } from '@mui/internal-test-utils';
import capitalize from '@mui/utils/capitalize';
import { ThemeProvider } from '@mui/joy/styles';
import Alert, { AlertClassKey, alertClasses as classes } from '@mui/joy/Alert';
import describeConformance from '../../test/describeConformance';
describe('<Alert />', () => {
const { render } = createRenderer();
describeConformance(<Alert startDecorator="1" endDecorator="2" />, () => ({
classes,
inheritComponent: 'div',
render,
ThemeProvider,
muiName: 'JoyAlert',
refInstanceof: window.HTMLDivElement,
testComponentPropWith: 'span',
testVariantProps: { variant: 'solid' },
testCustomVariant: true,
slots: {
root: { expectedClassName: classes.root },
startDecorator: { expectedClassName: classes.startDecorator },
endDecorator: { expectedClassName: classes.endDecorator },
},
skip: ['classesRoot', 'componentsProp'],
}));
describe('prop: variant', () => {
it('soft by default', () => {
render(<Alert />);
expect(screen.getByRole('alert')).to.have.class(classes.variantSoft);
});
(['plain', 'outlined', 'solid'] as const).forEach((variant) => {
it(`should render ${variant}`, () => {
render(<Alert variant={variant} />);
expect(screen.getByRole('alert')).to.have.class(
classes[`variant${capitalize(variant)}` as AlertClassKey],
);
});
});
});
describe('prop: color', () => {
it('adds a primary class by default', () => {
render(<Alert />);
expect(screen.getByRole('alert')).to.have.class(classes.colorNeutral);
});
(['primary', 'success', 'danger', 'neutral', 'warning'] as const).forEach((color) => {
it(`should render ${color}`, () => {
render(<Alert color={color} />);
expect(screen.getByRole('alert')).to.have.class(
classes[`color${capitalize(color)}` as AlertClassKey],
);
});
});
});
describe('prop: size', () => {
it('md by default', () => {
render(<Alert />);
expect(screen.getByRole('alert')).to.have.class(classes.sizeMd);
});
(['sm', 'md', 'lg'] as const).forEach((size) => {
it(`should render ${size}`, () => {
render(<Alert size={size} />);
expect(screen.getByRole('alert')).to.have.class(
classes[`size${capitalize(size)}` as AlertClassKey],
);
});
});
});
});

View File

@@ -0,0 +1,281 @@
'use client';
import * as React from 'react';
import PropTypes from 'prop-types';
import clsx from 'clsx';
import { unstable_composeClasses as composeClasses } from '@mui/base';
import { OverridableComponent } from '@mui/types';
import capitalize from '@mui/utils/capitalize';
import { applySolidInversion, applySoftInversion } from '../colorInversion';
import styled from '../styles/styled';
import useThemeProps from '../styles/useThemeProps';
import useSlot from '../utils/useSlot';
import { getAlertUtilityClass } from './alertClasses';
import { AlertProps, AlertOwnerState, AlertTypeMap } from './AlertProps';
import { resolveSxValue } from '../styles/styleUtils';
const useUtilityClasses = (ownerState: AlertOwnerState) => {
const { variant, color, size } = ownerState;
const slots = {
root: [
'root',
size && `size${capitalize(size)}`,
color && `color${capitalize(color)}`,
variant && `variant${capitalize(variant)}`,
],
startDecorator: ['startDecorator'],
endDecorator: ['endDecorator'],
};
return composeClasses(slots, getAlertUtilityClass, {});
};
const AlertRoot = styled('div', {
name: 'JoyAlert',
slot: 'Root',
overridesResolver: (props, styles) => styles.root,
})<{ ownerState: AlertOwnerState }>(({ theme, ownerState }) => {
const { p, padding, borderRadius } = resolveSxValue({ theme, ownerState }, [
'p',
'padding',
'borderRadius',
]);
return [
{
'--Alert-radius': theme.vars.radius.sm,
'--Alert-decoratorChildRadius':
'max((var(--Alert-radius) - var(--variant-borderWidth, 0px)) - var(--Alert-padding), min(var(--Alert-padding) + var(--variant-borderWidth, 0px), var(--Alert-radius) / 2))',
'--Button-minHeight': 'var(--Alert-decoratorChildHeight)',
'--IconButton-size': 'var(--Alert-decoratorChildHeight)',
'--Button-radius': 'var(--Alert-decoratorChildRadius)',
'--IconButton-radius': 'var(--Alert-decoratorChildRadius)',
'--Icon-color': 'currentColor',
...(ownerState.size === 'sm' && {
'--Alert-padding': '0.5rem',
'--Alert-decoratorChildHeight': '1.5rem',
'--Icon-fontSize': theme.vars.fontSize.xl,
gap: '0.5rem',
}),
...(ownerState.size === 'md' && {
'--Alert-padding': '0.75rem',
'--Alert-decoratorChildHeight': '2rem',
'--Icon-fontSize': theme.vars.fontSize.xl,
gap: '0.625rem',
}),
...(ownerState.size === 'lg' && {
'--Alert-padding': '1rem',
'--Alert-decoratorChildHeight': '2.375rem',
'--Icon-fontSize': theme.vars.fontSize.xl2,
gap: '0.875rem',
}),
backgroundColor: theme.vars.palette.background.surface,
display: 'flex',
position: 'relative',
alignItems: 'center',
padding: `var(--Alert-padding)`,
borderRadius: 'var(--Alert-radius)',
...theme.typography[`body-${({ sm: 'xs', md: 'sm', lg: 'md' } as const)[ownerState.size!]}`],
fontWeight: theme.vars.fontWeight.md,
...(ownerState.variant === 'solid' &&
ownerState.color &&
ownerState.invertedColors &&
applySolidInversion(ownerState.color)(theme)),
...(ownerState.variant === 'soft' &&
ownerState.color &&
ownerState.invertedColors &&
applySoftInversion(ownerState.color)(theme)),
...theme.variants[ownerState.variant!]?.[ownerState.color!],
} as const,
p !== undefined && { '--Alert-padding': p },
padding !== undefined && { '--Alert-padding': padding },
borderRadius !== undefined && { '--Alert-radius': borderRadius },
];
});
const AlertStartDecorator = styled('span', {
name: 'JoyAlert',
slot: 'StartDecorator',
overridesResolver: (props, styles) => styles.startDecorator,
})<{ ownerState: AlertOwnerState }>({
display: 'inherit',
flex: 'none',
});
const AlertEndDecorator = styled('span', {
name: 'JoyAlert',
slot: 'EndDecorator',
overridesResolver: (props, styles) => styles.endDecorator,
})<{ ownerState: AlertOwnerState }>({
display: 'inherit',
flex: 'none',
marginLeft: 'auto',
});
/**
*
* Demos:
*
* - [Alert](https://mui.com/joy-ui/react-alert/)
*
* API:
*
* - [Alert API](https://mui.com/joy-ui/api/alert/)
*/
const Alert = React.forwardRef(function Alert(inProps, ref) {
const props = useThemeProps<typeof inProps & AlertProps>({
props: inProps,
name: 'JoyAlert',
});
const {
children,
className,
color = 'neutral',
invertedColors = false,
role = 'alert',
variant = 'soft',
size = 'md',
startDecorator,
endDecorator,
component,
slots = {},
slotProps = {},
...other
} = props;
const ownerState = {
...props,
color,
invertedColors,
variant,
size,
};
const classes = useUtilityClasses(ownerState);
const externalForwardedProps = { ...other, component, slots, slotProps };
const [SlotRoot, rootProps] = useSlot('root', {
ref,
className: clsx(classes.root, className),
elementType: AlertRoot,
externalForwardedProps,
ownerState,
additionalProps: {
role,
},
});
const [SlotStartDecorator, startDecoratorProps] = useSlot('startDecorator', {
className: classes.startDecorator,
elementType: AlertStartDecorator,
externalForwardedProps,
ownerState,
});
const [SlotEndDecorator, endDecoratorProps] = useSlot('endDecorator', {
className: classes.endDecorator,
elementType: AlertEndDecorator,
externalForwardedProps,
ownerState,
});
return (
<SlotRoot {...rootProps}>
{startDecorator && (
<SlotStartDecorator {...startDecoratorProps}>{startDecorator}</SlotStartDecorator>
)}
{children}
{endDecorator && <SlotEndDecorator {...endDecoratorProps}>{endDecorator}</SlotEndDecorator>}
</SlotRoot>
);
}) as OverridableComponent<AlertTypeMap>;
Alert.propTypes /* remove-proptypes */ = {
// ┌────────────────────────────── Warning ──────────────────────────────┐
// │ These PropTypes are generated from the TypeScript type definitions. │
// │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
// └─────────────────────────────────────────────────────────────────────┘
/**
* @ignore
*/
children: PropTypes.node,
/**
* @ignore
*/
className: PropTypes.string,
/**
* The color of the component. It supports those theme colors that make sense for this component.
* @default 'neutral'
*/
color: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([
PropTypes.oneOf(['danger', 'neutral', 'primary', 'success', 'warning']),
PropTypes.string,
]),
/**
* The component used for the root node.
* Either a string to use a HTML element or a component.
*/
component: PropTypes.elementType,
/**
* Element placed after the children.
*/
endDecorator: PropTypes.node,
/**
* If `true`, the children with an implicit color prop invert their colors to match the component's variant and color.
* @default false
*/
invertedColors: PropTypes.bool,
/**
* The ARIA role attribute of the element.
* @default 'alert'
*/
role: PropTypes.string,
/**
* The size of the component.
* @default 'md'
*/
size: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([
PropTypes.oneOf(['sm', 'md', 'lg']),
PropTypes.string,
]),
/**
* The props used for each slot inside.
* @default {}
*/
slotProps: PropTypes.shape({
endDecorator: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
root: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
startDecorator: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
}),
/**
* The components used for each slot inside.
* @default {}
*/
slots: PropTypes.shape({
endDecorator: PropTypes.elementType,
root: PropTypes.elementType,
startDecorator: PropTypes.elementType,
}),
/**
* Element placed before the children.
*/
startDecorator: PropTypes.node,
/**
* The system prop that allows defining system overrides as well as additional CSS styles.
*/
sx: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.func, PropTypes.object, PropTypes.bool])),
PropTypes.func,
PropTypes.object,
]),
/**
* The [global variant](https://mui.com/joy-ui/main-features/global-variants/) to use.
* @default 'soft'
*/
variant: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([
PropTypes.oneOf(['outlined', 'plain', 'soft', 'solid']),
PropTypes.string,
]),
} as any;
export default Alert;

View File

@@ -0,0 +1,88 @@
import * as React from 'react';
import { OverridableStringUnion, OverrideProps } from '@mui/types';
import { ColorPaletteProp, SxProps, VariantProp, ApplyColorInversion } from '../styles/types';
import { CreateSlotsAndSlotProps, SlotProps } from '../utils/types';
export type AlertSlot = 'root' | 'startDecorator' | 'endDecorator';
export interface AlertSlots {
/**
* The component that renders the root.
* @default 'div'
*/
root?: React.ElementType;
/**
* The component that renders the start decorator.
* @default 'span'
*/
startDecorator?: React.ElementType;
/**
* The component that renders the end decorator.
* @default 'span'
*/
endDecorator?: React.ElementType;
}
export type AlertSlotsAndSlotProps = CreateSlotsAndSlotProps<
AlertSlots,
{
root: SlotProps<'div', {}, AlertOwnerState>;
startDecorator: SlotProps<'span', {}, AlertOwnerState>;
endDecorator: SlotProps<'span', {}, AlertOwnerState>;
}
>;
export interface AlertPropsVariantOverrides {}
export interface AlertPropsColorOverrides {}
export interface AlertPropsSizeOverrides {}
export interface AlertTypeMap<P = {}, D extends React.ElementType = 'div'> {
props: P &
AlertSlotsAndSlotProps & {
/**
* The color of the component. It supports those theme colors that make sense for this component.
* @default 'neutral'
*/
color?: OverridableStringUnion<ColorPaletteProp, AlertPropsColorOverrides>;
/**
* Element placed after the children.
*/
endDecorator?: React.ReactNode;
/**
* If `true`, the children with an implicit color prop invert their colors to match the component's variant and color.
* @default false
*/
invertedColors?: boolean;
/**
* The ARIA role attribute of the element.
* @default 'alert'
*/
role?: string;
/**
* The size of the component.
* @default 'md'
*/
size?: OverridableStringUnion<'sm' | 'md' | 'lg', AlertPropsSizeOverrides>;
/**
* Element placed before the children.
*/
startDecorator?: React.ReactNode;
/**
* The system prop that allows defining system overrides as well as additional CSS styles.
*/
sx?: SxProps;
/**
* The [global variant](https://mui.com/joy-ui/main-features/global-variants/) to use.
* @default 'soft'
*/
variant?: OverridableStringUnion<VariantProp, AlertPropsVariantOverrides>;
};
defaultComponent: D;
}
export type AlertProps<
D extends React.ElementType = AlertTypeMap['defaultComponent'],
P = { component?: React.ElementType },
> = OverrideProps<AlertTypeMap<P, D>, D>;
export interface AlertOwnerState extends ApplyColorInversion<AlertProps> {}

View File

@@ -0,0 +1,63 @@
import { generateUtilityClass, generateUtilityClasses } from '../className';
export interface AlertClasses {
/** Class name applied to the root element. */
root: string;
/** Class name applied to the root element if `color="primary"`. */
colorPrimary: string;
/** Class name applied to the root element if `color="danger"`. */
colorDanger: string;
/** Class name applied to the root element if `color="neutral"`. */
colorNeutral: string;
/** Class name applied to the root element if `color="success"`. */
colorSuccess: string;
/** Class name applied to the root element if `color="warning"`. */
colorWarning: string;
/** Class name applied to the root element when color inversion is triggered. */
colorContext: string;
/** Class name applied to the endDecorator element if supplied. */
endDecorator: string;
/** Class name applied to the root element if `size="sm"`. */
sizeSm: string;
/** Class name applied to the root element if `size="md"`. */
sizeMd: string;
/** Class name applied to the root element if `size="lg"`. */
sizeLg: string;
/** Class name applied to the startDecorator element if supplied. */
startDecorator: string;
/** Class name applied to the root element if `variant="plain"`. */
variantPlain: string;
/** Class name applied to the root element if `variant="outlined"`. */
variantOutlined: string;
/** Class name applied to the root element if `variant="soft"`. */
variantSoft: string;
/** Class name applied to the root element if `variant="solid"`. */
variantSolid: string;
}
export type AlertClassKey = keyof AlertClasses;
export function getAlertUtilityClass(slot: string): string {
return generateUtilityClass('MuiAlert', slot);
}
const alertClasses: AlertClasses = generateUtilityClasses('MuiAlert', [
'root',
'startDecorator',
'endDecorator',
'colorPrimary',
'colorDanger',
'colorNeutral',
'colorSuccess',
'colorWarning',
'colorContext',
'sizeSm',
'sizeMd',
'sizeLg',
'variantPlain',
'variantOutlined',
'variantSoft',
'variantSolid',
]);
export default alertClasses;

View File

@@ -0,0 +1,4 @@
export { default } from './Alert';
export * from './alertClasses';
export { default as alertClasses } from './alertClasses';
export * from './AlertProps';

View File

@@ -0,0 +1,39 @@
import { expectType } from '@mui/types';
import AspectRatio, { AspectRatioOwnerState } from '@mui/joy/AspectRatio';
<AspectRatio
slots={{
root: 'div',
content: 'div',
}}
/>;
<AspectRatio
slotProps={{
root: {
component: 'div',
'data-testid': 'test',
},
content: {
component: 'div',
'data-testid': 'test',
},
}}
/>;
<AspectRatio
slotProps={{
root: (ownerState) => {
expectType<AspectRatioOwnerState, typeof ownerState>(ownerState);
return {
'data-testid': 'test',
};
},
content: (ownerState) => {
expectType<AspectRatioOwnerState, typeof ownerState>(ownerState);
return {
'data-testid': 'test',
};
},
}}
/>;

View File

@@ -0,0 +1,91 @@
import { expect } from 'chai';
import { createRenderer, screen } from '@mui/internal-test-utils';
import capitalize from '@mui/utils/capitalize';
import { ThemeProvider } from '@mui/joy/styles';
import AspectRatio, {
AspectRatioClassKey,
aspectRatioClasses as classes,
} from '@mui/joy/AspectRatio';
import describeConformance from '../../test/describeConformance';
describe('<AspectRatio />', () => {
const { render } = createRenderer();
describeConformance(<AspectRatio>16/9</AspectRatio>, () => ({
classes,
inheritComponent: 'div',
render,
ThemeProvider,
muiName: 'JoyAspectRatio',
refInstanceof: window.HTMLDivElement,
testComponentPropWith: 'span',
testVariantProps: { variant: 'solid' },
testCustomVariant: true,
slots: {
root: { expectedClassName: classes.root },
content: { expectedClassName: classes.content },
},
skip: ['classesRoot', 'componentsProp'],
}));
describe('prop: variant', () => {
it('plain by default', () => {
render(<AspectRatio data-testid="root">Hello World</AspectRatio>);
expect(screen.getByTestId('root').firstChild).to.have.class(classes.variantSoft);
});
(['plain', 'outlined', 'soft', 'solid'] as const).forEach((variant) => {
it(`should render ${variant}`, () => {
render(
<AspectRatio data-testid="root" variant={variant}>
Hello World
</AspectRatio>,
);
expect(screen.getByTestId('root').firstChild).to.have.class(
classes[`variant${capitalize(variant)}` as AspectRatioClassKey],
);
});
});
});
describe('prop: color', () => {
it('adds a neutral class by default', () => {
render(<AspectRatio data-testid="root">Hello World</AspectRatio>);
expect(screen.getByTestId('root').firstChild).to.have.class(classes.colorNeutral);
});
(['primary', 'success', 'danger', 'neutral', 'warning'] as const).forEach((color) => {
it(`should render ${color}`, () => {
render(
<AspectRatio data-testid="root" color={color}>
Hello World
</AspectRatio>,
);
expect(screen.getByTestId('root').firstChild).to.have.class(
classes[`color${capitalize(color)}` as AspectRatioClassKey],
);
});
});
});
it('add data-attribute to the first child', () => {
const { container } = render(
<AspectRatio>
<div>First</div>
<div>Second</div>
<div>Third</div>
</AspectRatio>,
);
expect(container.querySelector('[data-first-child]')).to.have.text('First');
});
it('able to pass the props to content slot', () => {
render(<AspectRatio slotProps={{ content: { 'data-testid': 'content' } }} />);
expect(screen.getByTestId('content')).toBeVisible();
});
});

View File

@@ -0,0 +1,255 @@
'use client';
import * as React from 'react';
import PropTypes from 'prop-types';
import { unstable_composeClasses as composeClasses } from '@mui/base';
import { OverridableComponent } from '@mui/types';
import capitalize from '@mui/utils/capitalize';
import useThemeProps from '../styles/useThemeProps';
import useSlot from '../utils/useSlot';
import styled from '../styles/styled';
import { getAspectRatioUtilityClass } from './aspectRatioClasses';
import { AspectRatioProps, AspectRatioOwnerState, AspectRatioTypeMap } from './AspectRatioProps';
const useUtilityClasses = (ownerState: AspectRatioOwnerState) => {
const { variant, color } = ownerState;
const slots = {
root: ['root'],
content: [
'content',
variant && `variant${capitalize(variant)}`,
color && `color${capitalize(color)}`,
],
};
return composeClasses(slots, getAspectRatioUtilityClass, {});
};
// Use to control the width of the content, usually in a flexbox row container
const AspectRatioRoot = styled('div', {
name: 'JoyAspectRatio',
slot: 'Root',
overridesResolver: (props, styles) => styles.root,
})<{ ownerState: AspectRatioOwnerState }>(({ ownerState, theme }) => {
const minHeight =
typeof ownerState.minHeight === 'number' ? `${ownerState.minHeight}px` : ownerState.minHeight;
const maxHeight =
typeof ownerState.maxHeight === 'number' ? `${ownerState.maxHeight}px` : ownerState.maxHeight;
return {
// a context variable for any child component
'--AspectRatio-paddingBottom': `clamp(var(--AspectRatio-minHeight), calc(100% / (${ownerState.ratio})), var(--AspectRatio-maxHeight))`,
'--AspectRatio-maxHeight': maxHeight || '9999px',
'--AspectRatio-minHeight': minHeight || '0px',
'--Icon-color':
ownerState.color !== 'neutral' || ownerState.variant === 'solid'
? 'currentColor'
: theme.vars.palette.text.icon,
borderRadius: 'var(--AspectRatio-radius)',
display: ownerState.flex ? 'flex' : 'block',
flex: ownerState.flex ? 1 : 'initial',
flexDirection: 'column',
margin: 'var(--AspectRatio-margin)',
};
});
const AspectRatioContent = styled('div', {
name: 'JoyAspectRatio',
slot: 'Content',
overridesResolver: (props, styles) => styles.content,
})<{ ownerState: AspectRatioOwnerState }>(({ theme, ownerState }) => ({
flex: 1,
position: 'relative',
borderRadius: 'inherit',
height: 0,
paddingBottom: 'calc(var(--AspectRatio-paddingBottom) - 2 * var(--variant-borderWidth, 0px))',
overflow: 'hidden',
transition: 'inherit', // makes it easy to add transition to the content
// use data-attribute instead of :first-child to support zero config SSR (emotion)
// use nested selector for integrating with nextjs image `fill` layout (spans are inserted on top of the img)
'& [data-first-child]': {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
boxSizing: 'border-box',
position: 'absolute',
width: '100%',
height: '100%',
objectFit: ownerState.objectFit,
margin: 0,
padding: 0,
'& > img': {
// support art-direction that uses <picture><img /></picture>
width: '100%',
height: '100%',
objectFit: ownerState.objectFit,
},
},
...theme.typography['body-md'],
...theme.variants[ownerState.variant!]?.[ownerState.color!],
}));
/**
*
* Demos:
*
* - [Aspect Ratio](https://mui.com/joy-ui/react-aspect-ratio/)
* - [Skeleton](https://mui.com/joy-ui/react-skeleton/)
*
* API:
*
* - [AspectRatio API](https://mui.com/joy-ui/api/aspect-ratio/)
*/
const AspectRatio = React.forwardRef(function AspectRatio(inProps, ref) {
const props = useThemeProps<typeof inProps & AspectRatioProps>({
props: inProps,
name: 'JoyAspectRatio',
});
const {
children,
ratio = '16 / 9',
minHeight,
maxHeight,
objectFit = 'cover',
color = 'neutral',
variant = 'soft',
component,
flex = false,
slots = {},
slotProps = {},
...other
} = props;
const ownerState = {
...props,
flex,
minHeight,
maxHeight,
objectFit,
ratio,
color,
variant,
};
const classes = useUtilityClasses(ownerState);
const externalForwardedProps = { ...other, component, slots, slotProps };
const [SlotRoot, rootProps] = useSlot('root', {
ref,
className: classes.root,
elementType: AspectRatioRoot,
externalForwardedProps,
ownerState,
});
const [SlotContent, contentProps] = useSlot('content', {
className: classes.content,
elementType: AspectRatioContent,
externalForwardedProps,
ownerState,
});
return (
<SlotRoot {...rootProps}>
<SlotContent {...contentProps}>
{React.Children.map(children, (child, index) =>
index === 0 && React.isValidElement(child)
? React.cloneElement(child, { 'data-first-child': '' } as Record<string, string>)
: child,
)}
</SlotContent>
</SlotRoot>
);
}) as OverridableComponent<AspectRatioTypeMap>;
AspectRatio.propTypes /* remove-proptypes */ = {
// ┌────────────────────────────── Warning ──────────────────────────────┐
// │ These PropTypes are generated from the TypeScript type definitions. │
// │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
// └─────────────────────────────────────────────────────────────────────┘
/**
* Used to render icon or text elements inside the AspectRatio if `src` is not set.
* This can be an element, or just a string.
*/
children: PropTypes.node,
/**
* The color of the component. It supports those theme colors that make sense for this component.
* @default 'neutral'
*/
color: PropTypes.oneOf(['danger', 'neutral', 'primary', 'success', 'warning']),
/**
* The component used for the root node.
* Either a string to use a HTML element or a component.
*/
component: PropTypes.elementType,
/**
* By default, the AspectRatio will maintain the aspect ratio of its content.
* Set this prop to `true` when the container is a flex row and you want the AspectRatio to fill the height of its container.
* @default false
*/
flex: PropTypes.bool,
/**
* The maximum calculated height of the element (not the CSS height).
*/
maxHeight: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
/**
* The minimum calculated height of the element (not the CSS height).
*/
minHeight: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
/**
* The CSS object-fit value of the first-child.
* @default 'cover'
*/
objectFit: PropTypes.oneOf([
'-moz-initial',
'contain',
'cover',
'fill',
'inherit',
'initial',
'none',
'revert-layer',
'revert',
'scale-down',
'unset',
]),
/**
* The aspect-ratio of the element. The current implementation uses padding instead of the CSS aspect-ratio due to browser support.
* https://caniuse.com/?search=aspect-ratio
* @default '16 / 9'
*/
ratio: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
/**
* The props used for each slot inside.
* @default {}
*/
slotProps: PropTypes.shape({
content: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
root: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
}),
/**
* The components used for each slot inside.
* @default {}
*/
slots: PropTypes.shape({
content: PropTypes.elementType,
root: PropTypes.elementType,
}),
/**
* The system prop that allows defining system overrides as well as additional CSS styles.
*/
sx: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.func, PropTypes.object, PropTypes.bool])),
PropTypes.func,
PropTypes.object,
]),
/**
* The [global variant](https://mui.com/joy-ui/main-features/global-variants/) to use.
* @default 'soft'
*/
variant: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([
PropTypes.oneOf(['outlined', 'plain', 'soft', 'solid']),
PropTypes.string,
]),
} as any;
export default AspectRatio;

View File

@@ -0,0 +1,88 @@
import * as React from 'react';
import { OverridableStringUnion, OverrideProps } from '@mui/types';
import { ColorPaletteProp, SxProps, VariantProp, ApplyColorInversion } from '../styles/types';
import { CreateSlotsAndSlotProps, SlotProps } from '../utils/types';
export type AspectRatioSlot = 'root' | 'content';
export interface AspectRatioSlots {
/**
* The component that renders the root.
* @default 'div'
*/
root?: React.ElementType;
/**
* The component that renders the content.
* @default 'div'
*/
content?: React.ElementType;
}
export interface AspectRatioPropsColorOverrides {}
export interface AspectRatioPropsVariantOverrides {}
export type AspectRatioSlotsAndSlotProps = CreateSlotsAndSlotProps<
AspectRatioSlots,
{
root: SlotProps<'div', {}, AspectRatioOwnerState>;
content: SlotProps<'div', {}, AspectRatioOwnerState>;
}
>;
export interface AspectRatioTypeMap<P = {}, D extends React.ElementType = 'div'> {
props: P &
AspectRatioSlotsAndSlotProps & {
/**
* The color of the component. It supports those theme colors that make sense for this component.
* @default 'neutral'
*/
color?: OverridableStringUnion<ColorPaletteProp, AspectRatioPropsColorOverrides>;
/**
* Used to render icon or text elements inside the AspectRatio if `src` is not set.
* This can be an element, or just a string.
*/
children?: React.ReactNode;
/**
* By default, the AspectRatio will maintain the aspect ratio of its content.
* Set this prop to `true` when the container is a flex row and you want the AspectRatio to fill the height of its container.
* @default false
*/
flex?: boolean;
/**
* The minimum calculated height of the element (not the CSS height).
*/
minHeight?: number | string;
/**
* The maximum calculated height of the element (not the CSS height).
*/
maxHeight?: number | string;
/**
* The CSS object-fit value of the first-child.
* @default 'cover'
*/
objectFit?: React.CSSProperties['objectFit'];
/**
* The aspect-ratio of the element. The current implementation uses padding instead of the CSS aspect-ratio due to browser support.
* https://caniuse.com/?search=aspect-ratio
* @default '16 / 9'
*/
ratio?: number | string;
/**
* The system prop that allows defining system overrides as well as additional CSS styles.
*/
sx?: SxProps;
/**
* The [global variant](https://mui.com/joy-ui/main-features/global-variants/) to use.
* @default 'soft'
*/
variant?: OverridableStringUnion<VariantProp, AspectRatioPropsVariantOverrides>;
};
defaultComponent: D;
}
export type AspectRatioProps<
D extends React.ElementType = AspectRatioTypeMap['defaultComponent'],
P = { component?: React.ElementType },
> = OverrideProps<AspectRatioTypeMap<P, D>, D>;
export interface AspectRatioOwnerState extends ApplyColorInversion<AspectRatioProps> {}

View File

@@ -0,0 +1,51 @@
import { generateUtilityClass, generateUtilityClasses } from '../className';
export interface AspectRatioClasses {
/** Class name applied to the root element. */
root: string;
/** Class name applied to the content element. */
content: string;
/** Class name applied to the content element if `color="primary"`. */
colorPrimary: string;
/** Class name applied to the content element if `color="neutral"`. */
colorNeutral: string;
/** Class name applied to the content element if `color="danger"`. */
colorDanger: string;
/** Class name applied to the content element if `color="success"`. */
colorSuccess: string;
/** Class name applied to the content element if `color="warning"`. */
colorWarning: string;
/** Class name applied to the root element when color inversion is triggered. */
colorContext: string;
/** Class name applied to the content element if `variant="plain"`. */
variantPlain: string;
/** Class name applied to the content element if `variant="outlined"`. */
variantOutlined: string;
/** Class name applied to the content element if `variant="soft"`. */
variantSoft: string;
/** Class name applied to the content element if `variant="solid"`. */
variantSolid: string;
}
export type AspectRatioClassKey = keyof AspectRatioClasses;
export function getAspectRatioUtilityClass(slot: string): string {
return generateUtilityClass('MuiAspectRatio', slot);
}
const aspectRatioClasses: AspectRatioClasses = generateUtilityClasses('MuiAspectRatio', [
'root',
'content',
'colorPrimary',
'colorNeutral',
'colorDanger',
'colorSuccess',
'colorWarning',
'colorContext',
'variantPlain',
'variantOutlined',
'variantSoft',
'variantSolid',
]);
export default aspectRatioClasses;

View File

@@ -0,0 +1,4 @@
export { default } from './AspectRatio';
export * from './aspectRatioClasses';
export { default as aspectRatioClasses } from './aspectRatioClasses';
export * from './AspectRatioProps';

View File

@@ -0,0 +1,193 @@
import { expectType } from '@mui/types';
import Autocomplete, { AutocompleteOwnerState } from '@mui/joy/Autocomplete';
const top100Films = [{ title: 'The Shawshank Redemption', year: 1994 }];
<Autocomplete options={[]} slotProps={{ listbox: { disablePortal: true } }} />;
<Autocomplete multiple placeholder="Favorites" options={[]} defaultValue={['a', 'b']} />;
<Autocomplete
placeholder="Favorites"
limitTags={2}
options={top100Films}
getOptionLabel={(option) => option.title}
defaultValue={[top100Films[13], top100Films[12], top100Films[11]]}
multiple
sx={{ width: '500px' }}
/>;
<Autocomplete
options={top100Films}
slotProps={{
clearIndicator: {
color: 'danger',
variant: 'outlined',
size: 'sm',
},
popupIndicator: (ownerState) => ({
color: ownerState.inputFocused ? 'danger' : 'neutral',
variant: 'outlined',
size: 'sm',
}),
listbox: {
color: 'danger',
variant: 'outlined',
size: 'sm',
},
option: {
color: 'danger',
variant: 'outlined',
},
}}
/>;
<Autocomplete
options={top100Films}
slots={{
root: 'div',
wrapper: 'div',
input: 'div',
clearIndicator: 'div',
popupIndicator: 'div',
listbox: 'div',
option: 'div',
loading: 'div',
noOptions: 'div',
limitTag: 'div',
startDecorator: 'div',
endDecorator: 'div',
}}
/>;
<Autocomplete
options={top100Films}
slotProps={{
root: {
component: 'div',
'data-testid': 'test',
},
wrapper: {
component: 'div',
'data-testid': 'test',
},
input: {
component: 'div',
'data-testid': 'test',
},
clearIndicator: {
component: 'div',
'data-testid': 'test',
},
popupIndicator: {
component: 'div',
'data-testid': 'test',
},
listbox: {
component: 'div',
'data-testid': 'test',
},
option: {
component: 'div',
'data-testid': 'test',
},
loading: {
component: 'div',
'data-testid': 'test',
},
noOptions: {
component: 'div',
'data-testid': 'test',
},
limitTag: {
component: 'div',
'data-testid': 'test',
},
startDecorator: {
component: 'div',
'data-testid': 'test',
},
endDecorator: {
component: 'div',
'data-testid': 'test',
},
}}
/>;
<Autocomplete
options={top100Films}
slotProps={{
root: (ownerState) => {
expectType<AutocompleteOwnerState<any, any, any, any>, typeof ownerState>(ownerState);
return {
'data-testid': 'test',
};
},
wrapper: (ownerState) => {
expectType<AutocompleteOwnerState<any, any, any, any>, typeof ownerState>(ownerState);
return {
'data-testid': 'test',
};
},
input: (ownerState) => {
expectType<AutocompleteOwnerState<any, any, any, any>, typeof ownerState>(ownerState);
return {
'data-testid': 'test',
};
},
clearIndicator: (ownerState) => {
expectType<AutocompleteOwnerState<any, any, any, any>, typeof ownerState>(ownerState);
return {
'data-testid': 'test',
};
},
popupIndicator: (ownerState) => {
expectType<AutocompleteOwnerState<any, any, any, any>, typeof ownerState>(ownerState);
return {
'data-testid': 'test',
};
},
listbox: (ownerState) => {
expectType<AutocompleteOwnerState<any, any, any, any>, typeof ownerState>(ownerState);
return {
'data-testid': 'test',
};
},
option: (ownerState) => {
expectType<AutocompleteOwnerState<any, any, any, any>, typeof ownerState>(ownerState);
return {
'data-testid': 'test',
};
},
loading: (ownerState) => {
expectType<AutocompleteOwnerState<any, any, any, any>, typeof ownerState>(ownerState);
return {
'data-testid': 'test',
};
},
noOptions: (ownerState) => {
expectType<AutocompleteOwnerState<any, any, any, any>, typeof ownerState>(ownerState);
return {
'data-testid': 'test',
};
},
limitTag: (ownerState) => {
expectType<AutocompleteOwnerState<any, any, any, any>, typeof ownerState>(ownerState);
return {
'data-testid': 'test',
};
},
startDecorator: (ownerState) => {
expectType<AutocompleteOwnerState<any, any, any, any>, typeof ownerState>(ownerState);
return {
'data-testid': 'test',
};
},
endDecorator: (ownerState) => {
expectType<AutocompleteOwnerState<any, any, any, any>, typeof ownerState>(ownerState);
return {
'data-testid': 'test',
};
},
}}
/>;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,389 @@
import * as React from 'react';
import {
AutocompleteChangeDetails,
AutocompleteChangeReason,
AutocompleteCloseReason,
AutocompleteFreeSoloValueMapping,
AutocompleteInputChangeReason,
AutocompleteValue,
UseAutocompleteProps,
} from '@mui/base/useAutocomplete';
import { PopperOwnProps } from '@mui/base/Popper';
import { OverridableStringUnion } from '@mui/types';
import { ColorPaletteProp, SxProps, VariantProp, ApplyColorInversion } from '../styles/types';
import { CreateSlotsAndSlotProps, SlotProps } from '../utils/types';
export type AutocompleteSlot =
| 'root'
| 'wrapper'
| 'input'
| 'startDecorator'
| 'endDecorator'
| 'clearIndicator'
| 'popupIndicator'
| 'listbox'
| 'option'
| 'loading'
| 'noOptions'
| 'limitTag';
export interface AutocompleteSlots {
/**
* The component that renders the root.
* @default 'div'
*/
root?: React.ElementType;
/**
* The component that renders the wrapper.
* @default 'div'
*/
wrapper?: React.ElementType;
/**
* The component that renders the input.
* @default 'input'
*/
input?: React.ElementType;
/**
* The component that renders the start decorator.
* @default 'div'
*/
startDecorator?: React.ElementType;
/**
* The component that renders the end decorator.
* @default 'div'
*/
endDecorator?: React.ElementType;
/**
* The component that renders the clear indicator.
* @default 'button'
*/
clearIndicator?: React.ElementType;
/**
* The component that renders the popup indicator.
* @default 'button'
*/
popupIndicator?: React.ElementType;
/**
* The component that renders the listbox.
* @default 'ul'
*/
listbox?: React.ElementType;
/**
* The component that renders the option.
* @default 'li'
*/
option?: React.ElementType;
/**
* The component that renders the loading.
* @default 'li'
*/
loading?: React.ElementType;
/**
* The component that renders the no-options.
* @default 'li'
*/
noOptions?: React.ElementType;
/**
* The component that renders the limit tag.
* @default 'div'
*/
limitTag?: React.ElementType;
}
export interface AutocompletePropsVariantOverrides {}
export interface AutocompletePropsColorOverrides {}
export interface AutocompletePropsSizeOverrides {}
export type {
AutocompleteChangeDetails,
AutocompleteChangeReason,
AutocompleteCloseReason,
AutocompleteInputChangeReason,
};
export type AutocompleteRenderGetTagProps = ({ index }: { index: number }) => {
key: number;
disabled: boolean;
'data-tag-index': number;
tabIndex: -1;
onClick: React.MouseEventHandler<HTMLButtonElement>;
};
export interface AutocompleteRenderOptionState {
inputValue: string;
selected: boolean;
ownerState: AutocompleteOwnerState<any, any, any, any>;
}
export interface AutocompleteRenderGroupParams {
key: string;
group: string;
children?: React.ReactNode;
}
export type AutocompleteSlotsAndSlotProps = CreateSlotsAndSlotProps<
AutocompleteSlots,
{
root: SlotProps<'div', {}, AutocompleteOwnerState<any, any, any, any>>;
wrapper: SlotProps<'div', {}, AutocompleteOwnerState<any, any, any, any>>;
input: SlotProps<'input', {}, AutocompleteOwnerState<any, any, any, any>>;
startDecorator: SlotProps<'span', {}, AutocompleteOwnerState<any, any, any, any>>;
endDecorator: SlotProps<'span', {}, AutocompleteOwnerState<any, any, any, any>>;
clearIndicator: SlotProps<
'button',
{
color?: OverridableStringUnion<ColorPaletteProp, AutocompletePropsColorOverrides>;
variant?: OverridableStringUnion<VariantProp, AutocompletePropsVariantOverrides>;
size?: OverridableStringUnion<'sm' | 'md' | 'lg', AutocompletePropsSizeOverrides>;
},
AutocompleteOwnerState<any, any, any, any>
>;
popupIndicator: SlotProps<
'button',
{
color?: OverridableStringUnion<ColorPaletteProp, AutocompletePropsColorOverrides>;
variant?: OverridableStringUnion<VariantProp, AutocompletePropsVariantOverrides>;
size?: OverridableStringUnion<'sm' | 'md' | 'lg', AutocompletePropsSizeOverrides>;
},
AutocompleteOwnerState<any, any, any, any>
>;
listbox: SlotProps<
'ul',
{
color?: OverridableStringUnion<ColorPaletteProp, AutocompletePropsColorOverrides>;
variant?: OverridableStringUnion<VariantProp, AutocompletePropsVariantOverrides>;
size?: OverridableStringUnion<'sm' | 'md' | 'lg', AutocompletePropsSizeOverrides>;
} & Omit<PopperOwnProps, 'slots' | 'slotProps' | 'open'>,
AutocompleteOwnerState<any, any, any, any>
>;
option: SlotProps<
'li',
{
color?: OverridableStringUnion<ColorPaletteProp, AutocompletePropsColorOverrides>;
variant?: OverridableStringUnion<VariantProp, AutocompletePropsVariantOverrides>;
},
AutocompleteOwnerState<any, any, any, any>
>;
loading: SlotProps<'li', {}, AutocompleteOwnerState<any, any, any, any>>;
noOptions: SlotProps<'li', {}, AutocompleteOwnerState<any, any, any, any>>;
limitTag: SlotProps<'span', {}, AutocompleteOwnerState<any, any, any, any>>;
}
>;
type AutocompleteOwnProps<
T,
Multiple extends boolean | undefined,
DisableClearable extends boolean | undefined,
FreeSolo extends boolean | undefined,
> = UseAutocompleteProps<T, Multiple, DisableClearable, FreeSolo> &
AutocompleteSlotsAndSlotProps & {
/**
* If `true`, the `input` element is focused during the first mount.
*/
autoFocus?: boolean;
/**
* The icon to display in place of the default clear icon.
* @default <ClearIcon fontSize="md" />
*/
clearIcon?: React.ReactNode;
/**
* Override the default text for the *clear* icon button.
*
* For localization purposes, you can use the provided [translations](https://mui.com/material-ui/guides/localization/).
* @default 'Clear'
*/
clearText?: string;
/**
* Override the default text for the *close popup* icon button.
*
* For localization purposes, you can use the provided [translations](https://mui.com/material-ui/guides/localization/).
* @default 'Close'
*/
closeText?: string;
/**
* The color of the component. It supports those theme colors that make sense for this component.
* @default 'neutral'
*/
color?: OverridableStringUnion<ColorPaletteProp, AutocompletePropsColorOverrides>;
/**
* The default value. Use when the component is not controlled.
* @default props.multiple ? [] : null
*/
defaultValue?: AutocompleteValue<T, Multiple, DisableClearable, FreeSolo>;
/**
* If `true`, the component is disabled.
* @default false
*/
disabled?: boolean;
/**
* If `true`, the `input` will indicate an error.
* The prop defaults to the value (`false`) inherited from the parent FormControl component.
* @default false
*/
error?: boolean;
/**
* Trailing adornment for this input.
*/
endDecorator?: React.ReactNode;
/**
* Force the visibility display of the popup icon.
* @default 'auto'
*/
forcePopupIcon?: true | false | 'auto';
/**
* The label to display when the tags are truncated (`limitTags`).
*
* @param {string | number} more The number of truncated tags.
* @returns {ReactNode}
* @default (more: string | number) => `+${more}`
*/
getLimitTagsText?: (more: string | number) => React.ReactNode;
/**
* If `true`, the component is in a loading state.
* This shows the `loadingText` in place of suggestions (only if there are no suggestions to show, for example `options` are empty).
* @default false
*/
loading?: boolean;
/**
* Text to display when in a loading state.
*
* For localization purposes, you can use the provided [translations](https://mui.com/material-ui/guides/localization/).
* @default 'Loading…'
*/
loadingText?: React.ReactNode;
/**
* The maximum number of tags that will be visible when not focused.
* Set `-1` to disable the limit.
* @default -1
*/
limitTags?: number;
/**
* Name attribute of the `input` element.
*/
name?: string;
/**
* Text to display when there are no options.
*
* For localization purposes, you can use the provided [translations](https://mui.com/material-ui/guides/localization/).
* @default 'No options'
*/
noOptionsText?: React.ReactNode;
/**
* Override the default text for the *open popup* icon button.
*
* For localization purposes, you can use the provided [translations](https://mui.com/material-ui/guides/localization/).
* @default 'Open'
*/
openText?: string;
/**
* The input placeholder
*/
placeholder?: string;
/**
* The icon to display in place of the default popup icon.
* @default <ArrowDropDownIcon />
*/
popupIcon?: React.ReactNode;
/**
* If `true`, the component becomes read-only. It is also supported in multiple tags where the tag cannot be deleted.
* @default false
*/
readOnly?: boolean;
/**
* Render the group.
*
* @param {AutocompleteRenderGroupParams} params The group to render.
* @returns {ReactNode}
*/
renderGroup?: (params: AutocompleteRenderGroupParams) => React.ReactNode;
/**
* Render the option, use `getOptionLabel` by default.
*
* @param {object} props The props to apply on the li element.
* @param {T} option The option to render.
* @param {object} state The state of the component.
* @returns {ReactNode}
*/
renderOption?: (
props: Omit<React.HTMLAttributes<HTMLLIElement>, 'color'>,
option: T,
state: AutocompleteRenderOptionState,
) => React.ReactNode;
/**
* Render the selected value.
*
* @param {T[]} value The `value` provided to the component.
* @param {function} getTagProps A tag props getter.
* @param {object} ownerState The state of the Autocomplete component.
* @returns {ReactNode}
*/
renderTags?: (
value: T[],
getTagProps: AutocompleteRenderGetTagProps,
ownerState: AutocompleteOwnerState<T, Multiple, DisableClearable, FreeSolo>,
) => React.ReactNode;
/**
* If `true`, the `input` element is required.
* The prop defaults to the value (`false`) inherited from the parent FormControl component.
*/
required?: boolean;
/**
* The size of the component.
* @default 'md'
*/
size?: OverridableStringUnion<'sm' | 'md' | 'lg', AutocompletePropsSizeOverrides>;
/**
* Leading adornment for this input.
*/
startDecorator?: React.ReactNode;
/**
* The system prop that allows defining system overrides as well as additional CSS styles.
*/
sx?: SxProps;
/**
* The [global variant](https://mui.com/joy-ui/main-features/global-variants/) to use.
* @default 'outlined'
*/
variant?: OverridableStringUnion<VariantProp, AutocompletePropsVariantOverrides>;
};
/**
* AutocompleteProps signature:
* @template T
* @param {string | object} T The option structure, must be a string or an object (by default, only accepts objects with { label: string } )
* @param {boolean | undefined} Multiple If your component is set with property multiple as true
* @param {boolean | undefined} DisableClearable If your component is set with property disableClearable as true
* @param {boolean | undefined} FreeSolo If your component is set with property freeSolo as true
*/
export interface AutocompleteProps<
T,
Multiple extends boolean | undefined,
DisableClearable extends boolean | undefined,
FreeSolo extends boolean | undefined,
> extends AutocompleteOwnProps<T, Multiple, DisableClearable, FreeSolo>,
Omit<React.HTMLAttributes<HTMLDivElement>, 'defaultValue' | 'onChange' | 'children' | 'color'> {
/**
* Type of the `input` element. It should be [a valid HTML5 input type](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input#input_types).
*/
type?: string;
onKeyDown?: (
event: React.KeyboardEvent<HTMLDivElement> & { defaultMuiPrevented?: boolean },
) => void;
}
export interface AutocompleteOwnerState<
T,
Multiple extends boolean | undefined,
DisableClearable extends boolean | undefined,
FreeSolo extends boolean | undefined,
> extends ApplyColorInversion<AutocompleteOwnProps<T, Multiple, DisableClearable, FreeSolo>> {
focused?: boolean;
getOptionLabel: (option: T | AutocompleteFreeSoloValueMapping<FreeSolo>) => string;
hasClearIcon?: boolean;
hasPopupIcon?: boolean;
hasOptions?: boolean;
inputFocused?: boolean;
popupOpen?: boolean;
}

View File

@@ -0,0 +1,114 @@
import { generateUtilityClass, generateUtilityClasses } from '../className';
export interface AutocompleteClasses {
/** Class name applied to the root element. */
root: string;
/** Class name applied to the wrapper element. */
wrapper: string;
/** Class name applied to the input element. */
input: string;
/** Class name applied to the startDecorator element. */
startDecorator: string;
/** Class name applied to the endDecorator element. */
endDecorator: string;
/** Class name applied to the root element if the component is a descendant of `FormControl`. */
formControl: string;
/** Class name applied to the root element if the component is focused. */
focused: string;
/** Class name applied to the root element if `disabled={true}`. */
disabled: string;
/** State class applied to the root element if `error={true}`. */
error: string;
/** Class name applied to the wrapper element if `multiple={true}`. */
multiple: string;
/** Class name applied to the limitTag element. */
limitTag: string;
/** Class name applied when the popup icon is rendered. */
hasPopupIcon: string;
/** Class name applied when the clear icon is rendered. */
hasClearIcon: string;
/** Class name applied to the clear indicator. */
clearIndicator: string;
/** Class name applied to the popup indicator. */
popupIndicator: string;
/** Class name applied to the popup indicator if the popup is open. */
popupIndicatorOpen: string;
/** Class name applied to the listbox component. */
listbox: string;
/** Class name applied to the option component. */
option: string;
/** Class name applied to the loading wrapper. */
loading: string;
/** Class name applied to the no option wrapper. */
noOptions: string;
/** Class name applied to the root element if `color="primary"`. */
colorPrimary: string;
/** Class name applied to the root element if `color="neutral"`. */
colorNeutral: string;
/** Class name applied to the root element if `color="danger"`. */
colorDanger: string;
/** Class name applied to the root element if `color="success"`. */
colorSuccess: string;
/** Class name applied to the root element if `color="warning"`. */
colorWarning: string;
/** Class name applied to the root element when color inversion is triggered. */
colorContext: string;
/** Class name applied to the root element if `size="sm"`. */
sizeSm: string;
/** Class name applied to the root element if `size="md"`. */
sizeMd: string;
/** Class name applied to the root element if `size="lg"`. */
sizeLg: string;
/** Class name applied to the root element if `variant="plain"`. */
variantPlain: string;
/** Class name applied to the root element if `variant="outlined"`. */
variantOutlined: string;
/** Class name applied to the root element if `variant="soft"`. */
variantSoft: string;
/** Class name applied to the root element if `variant="solid"`. */
variantSolid: string;
}
export type AutocompleteClassKey = keyof AutocompleteClasses;
export function getAutocompleteUtilityClass(slot: string): string {
return generateUtilityClass('MuiAutocomplete', slot);
}
const autocompleteClasses: AutocompleteClasses = generateUtilityClasses('MuiAutocomplete', [
'root',
'wrapper',
'input',
'startDecorator',
'endDecorator',
'formControl',
'focused',
'disabled',
'error',
'multiple',
'limitTag',
'hasPopupIcon',
'hasClearIcon',
'clearIndicator',
'popupIndicator',
'popupIndicatorOpen',
'listbox',
'option',
'loading',
'noOptions',
'colorPrimary',
'colorNeutral',
'colorDanger',
'colorSuccess',
'colorWarning',
'colorContext',
'sizeSm',
'sizeMd',
'sizeLg',
'variantPlain',
'variantOutlined',
'variantSoft',
'variantSolid',
]);
export default autocompleteClasses;

View File

@@ -0,0 +1,5 @@
export { createFilterOptions } from '@mui/base/useAutocomplete';
export { default } from './Autocomplete';
export * from './autocompleteClasses';
export { default as autocompleteClasses } from './autocompleteClasses';
export * from './AutocompleteProps';

View File

@@ -0,0 +1,59 @@
import { expect } from 'chai';
import { createRenderer } from '@mui/internal-test-utils';
import { ThemeProvider } from '@mui/joy/styles';
import AutocompleteListbox, {
autocompleteListboxClasses as classes,
} from '@mui/joy/AutocompleteListbox';
import describeConformance from '../../test/describeConformance';
describe('Joy <AutocompleteListbox />', () => {
const { render } = createRenderer();
describeConformance(<AutocompleteListbox />, () => ({
classes,
inheritComponent: 'ul',
render,
ThemeProvider,
muiName: 'JoyAutocompleteListbox',
refInstanceof: window.HTMLUListElement,
testVariantProps: { variant: 'solid' },
testCustomVariant: true,
skip: ['componentsProp', 'classesRoot'],
slots: {
root: {
expectedClassName: classes.root,
},
},
}));
it('should have ul tag', () => {
const { container } = render(<AutocompleteListbox />);
expect(container.firstChild).to.have.tagName('ul');
});
it('should have root className', () => {
const { container } = render(<AutocompleteListbox />);
expect(container.firstChild).to.have.class(classes.root);
expect(container.firstChild).to.have.class(classes.sizeMd);
});
it('should accept className prop', () => {
const { container } = render(<AutocompleteListbox className="foo-bar" />);
expect(container.firstChild).to.have.class('foo-bar');
});
it('should have sm classes', () => {
const { container } = render(<AutocompleteListbox size="sm" />);
expect(container.firstChild).to.have.class(classes.sizeSm);
});
it('should render with the variant class', () => {
const { container } = render(<AutocompleteListbox variant="outlined" />);
expect(container.firstChild).to.have.class(classes.variantOutlined);
});
it('should render with primary color class', () => {
const { container } = render(<AutocompleteListbox color="primary" />);
expect(container.firstChild).to.have.class(classes.colorPrimary);
});
});

View File

@@ -0,0 +1,213 @@
'use client';
import * as React from 'react';
import clsx from 'clsx';
import PropTypes from 'prop-types';
import { OverridableComponent } from '@mui/types';
import capitalize from '@mui/utils/capitalize';
import { unstable_composeClasses as composeClasses } from '@mui/base/composeClasses';
import { StyledList } from '../List/List';
import { styled, useThemeProps } from '../styles';
import { VariantColorProvider } from '../styles/variantColorInheritance';
import { getAutocompleteListboxUtilityClass } from './autocompleteListboxClasses';
import {
AutocompleteListboxOwnerState,
AutocompleteListboxTypeMap,
} from './AutocompleteListboxProps';
import listItemClasses from '../ListItem/listItemClasses';
import listClasses from '../List/listClasses';
import { scopedVariables } from '../List/ListProvider';
import useSlot from '../utils/useSlot';
const useUtilityClasses = (ownerState: AutocompleteListboxOwnerState) => {
const { variant, color, size } = ownerState;
const slots = {
root: [
'root',
variant && `variant${capitalize(variant)}`,
color && `color${capitalize(color)}`,
size && `size${capitalize(size)}`,
],
};
return composeClasses(slots, getAutocompleteListboxUtilityClass, {});
};
const excludePopperProps = <T extends Record<string, any>>({
anchorEl,
direction,
disablePortal,
keepMounted,
modifiers,
open,
placement,
popperOptions,
popperRef,
TransitionProps,
...other
}: T) => other;
export const StyledAutocompleteListbox = styled(StyledList)<{
ownerState: AutocompleteListboxOwnerState;
}>(({ theme, ownerState }) => {
const variantStyle = theme.variants[ownerState.variant!]?.[ownerState.color!];
return {
'--focus-outline-offset': `calc(${theme.vars.focus.thickness} * -1)`, // to prevent the focus outline from being cut by overflow
'--ListItem-stickyBackground':
variantStyle?.backgroundColor ||
variantStyle?.background ||
theme.vars.palette.background.popup,
'--ListItem-stickyTop': 'calc(var(--List-padding, var(--ListDivider-gap)) * -1)',
...scopedVariables,
boxShadow: theme.shadow.md,
borderRadius: `var(--List-radius, ${theme.vars.radius.sm})`,
...(!variantStyle?.backgroundColor && {
backgroundColor: theme.vars.palette.background.popup,
}),
zIndex: theme.vars.zIndex.popup,
overflow: 'auto',
maxHeight: '40vh',
position: 'relative', // to make sure that the listbox is positioned for grouped options to work.
'&:empty': {
visibility: 'hidden',
},
[`& .${listItemClasses.nested}, & .${listItemClasses.nested} .${listClasses.root}`]: {
// For grouped options autocomplete:
// Force the position to make the scroll into view logic works because the `element.offsetTop` should reference to the listbox, not the grouped list.
// See the implementation of the `useAutocomplete` line:370
//
// Resource: https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/offsetTop
position: 'initial',
},
};
});
const AutocompleteListboxRoot = styled(StyledAutocompleteListbox, {
name: 'JoyAutocompleteListbox',
slot: 'Root',
overridesResolver: (props, styles) => styles.root,
})({});
/**
*
* Demos:
*
* - [Autocomplete](https://mui.com/joy-ui/react-autocomplete/)
*
* API:
*
* - [AutocompleteListbox API](https://mui.com/joy-ui/api/autocomplete-listbox/)
*/
const AutocompleteListbox = React.forwardRef(function AutocompleteListbox(inProps, ref) {
const props = useThemeProps<typeof inProps & { component?: React.ElementType }>({
props: inProps,
name: 'JoyAutocompleteListbox',
});
const {
children,
className,
component,
color = 'neutral',
variant = 'outlined',
size = 'md',
slots = {},
slotProps = {},
...otherProps
} = props;
const ownerState = {
...props,
size,
color,
variant,
nesting: false,
row: false,
wrap: false,
};
const other = excludePopperProps(otherProps);
const classes = useUtilityClasses(ownerState);
const externalForwardedProps = { ...other, component, slots, slotProps };
const [SlotRoot, rootProps] = useSlot('root', {
ref,
className: clsx(classes.root, className),
elementType: AutocompleteListboxRoot,
externalForwardedProps,
ownerState,
additionalProps: {
role: 'listbox',
},
});
return (
<VariantColorProvider variant={variant} color={color}>
<SlotRoot {...rootProps}>{children}</SlotRoot>
</VariantColorProvider>
);
}) as OverridableComponent<AutocompleteListboxTypeMap>;
AutocompleteListbox.propTypes /* remove-proptypes */ = {
// ┌────────────────────────────── Warning ──────────────────────────────┐
// │ These PropTypes are generated from the TypeScript type definitions. │
// │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
// └─────────────────────────────────────────────────────────────────────┘
/**
* @ignore
*/
children: PropTypes.node,
/**
* @ignore
*/
className: PropTypes.string,
/**
* The color of the component. It supports those theme colors that make sense for this component.
* @default 'neutral'
*/
color: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([
PropTypes.oneOf(['danger', 'neutral', 'primary', 'success', 'warning']),
PropTypes.string,
]),
/**
* The component used for the root node.
* Either a string to use a HTML element or a component.
*/
component: PropTypes.elementType,
/**
* The size of the component (affect other nested list* components).
* @default 'md'
*/
size: PropTypes.oneOf(['sm', 'md', 'lg']),
/**
* The props used for each slot inside.
* @default {}
*/
slotProps: PropTypes.shape({
root: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
}),
/**
* The components used for each slot inside.
* @default {}
*/
slots: PropTypes.shape({
root: PropTypes.elementType,
}),
/**
* The system prop that allows defining system overrides as well as additional CSS styles.
*/
sx: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.func, PropTypes.object, PropTypes.bool])),
PropTypes.func,
PropTypes.object,
]),
/**
* The [global variant](https://mui.com/joy-ui/main-features/global-variants/) to use.
* @default 'outlined'
*/
variant: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([
PropTypes.oneOf(['contained', 'light', 'outlined', 'text']),
PropTypes.string,
]),
} as any;
export default AutocompleteListbox;

View File

@@ -0,0 +1,60 @@
import * as React from 'react';
import { OverrideProps, OverridableStringUnion } from '@mui/types';
import { ColorPaletteProp, VariantProp, SxProps, ApplyColorInversion } from '../styles/types';
import { SlotProps, CreateSlotsAndSlotProps } from '../utils/types';
export type AutocompleteListboxSlot = 'root';
export interface AutocompleteListboxSlots {
/**
* The component that renders the root.
* @default 'ul'
*/
root?: React.ElementType;
}
export type AutocompleteListboxSlotsAndSlotProps = CreateSlotsAndSlotProps<
AutocompleteListboxSlots,
{
root: SlotProps<'ul', {}, AutocompleteListboxOwnerState>;
}
>;
export interface AutocompleteListboxPropsSizeOverrides {}
export interface AutocompleteListboxPropsColorOverrides {}
export interface AutocompleteListboxPropsVariantOverrides {}
export interface AutocompleteListboxTypeMap<P = {}, D extends React.ElementType = 'ul'> {
props: P & {
/**
* The color of the component. It supports those theme colors that make sense for this component.
* @default 'neutral'
*/
color?: OverridableStringUnion<ColorPaletteProp, AutocompleteListboxPropsColorOverrides>;
/**
* The [global variant](https://mui.com/joy-ui/main-features/global-variants/) to use.
* @default 'outlined'
*/
variant?: OverridableStringUnion<VariantProp, AutocompleteListboxPropsVariantOverrides>;
/**
* The size of the component (affect other nested list* components).
* @default 'md'
*/
size?: OverridableStringUnion<'sm' | 'md' | 'lg', AutocompleteListboxPropsSizeOverrides>;
/**
* The system prop that allows defining system overrides as well as additional CSS styles.
*/
sx?: SxProps;
} & AutocompleteListboxSlotsAndSlotProps;
defaultComponent: D;
}
export type AutocompleteListboxProps<
D extends React.ElementType = AutocompleteListboxTypeMap['defaultComponent'],
P = {
component?: React.ElementType;
},
> = OverrideProps<AutocompleteListboxTypeMap<P, D>, D>;
export interface AutocompleteListboxOwnerState
extends ApplyColorInversion<AutocompleteListboxProps> {}

View File

@@ -0,0 +1,60 @@
import { generateUtilityClass, generateUtilityClasses } from '../className';
export interface AutocompleteListboxClasses {
/** Class name applied to the root element. */
root: string;
/** Class name applied to the root element if `size="sm"`. */
sizeSm: string;
/** Class name applied to the root element if `size="md"`. */
sizeMd: string;
/** Class name applied to the root element if `size="lg"`. */
sizeLg: string;
/** Class name applied to the root element if `color="primary"`. */
colorPrimary: string;
/** Class name applied to the root element if `color="neutral"`. */
colorNeutral: string;
/** Class name applied to the root element if `color="danger"`. */
colorDanger: string;
/** Class name applied to the root element if `color="success"`. */
colorSuccess: string;
/** Class name applied to the root element if `color="warning"`. */
colorWarning: string;
/** Class name applied to the root element when color inversion is triggered. */
colorContext: string;
/** Class name applied to the root element if `variant="plain"`. */
variantPlain: string;
/** Class name applied to the root element if `variant="outlined"`. */
variantOutlined: string;
/** Class name applied to the root element if `variant="soft"`. */
variantSoft: string;
/** Class name applied to the root element if `variant="solid"`. */
variantSolid: string;
}
export type AutocompleteListboxClassKey = keyof AutocompleteListboxClasses;
export function getAutocompleteListboxUtilityClass(slot: string): string {
return generateUtilityClass('MuiAutocompleteListbox', slot);
}
const autocompleteListboxClasses: AutocompleteListboxClasses = generateUtilityClasses(
'MuiAutocompleteListbox',
[
'root',
'sizeSm',
'sizeMd',
'sizeLg',
'colorPrimary',
'colorNeutral',
'colorDanger',
'colorSuccess',
'colorWarning',
'colorContext',
'variantPlain',
'variantOutlined',
'variantSoft',
'variantSolid',
],
);
export default autocompleteListboxClasses;

View File

@@ -0,0 +1,4 @@
export { default } from './AutocompleteListbox';
export * from './autocompleteListboxClasses';
export { default as autocompleteListboxClasses } from './autocompleteListboxClasses';
export * from './AutocompleteListboxProps';

View File

@@ -0,0 +1,48 @@
import { expect } from 'chai';
import { createRenderer, screen } from '@mui/internal-test-utils';
import { ThemeProvider } from '@mui/joy/styles';
import AutocompleteOption, {
autocompleteOptionClasses as classes,
} from '@mui/joy/AutocompleteOption';
import describeConformance from '../../test/describeConformance';
describe('Joy <AutocompleteOption />', () => {
const { render } = createRenderer();
describeConformance(<AutocompleteOption />, () => ({
classes,
inheritComponent: 'li',
render,
ThemeProvider,
muiName: 'JoyAutocompleteOption',
refInstanceof: window.HTMLLIElement,
testVariantProps: { color: 'primary' },
testCustomVariant: true,
skip: ['componentsProp', 'classesRoot'],
slots: {
root: {
expectedClassName: classes.root,
},
},
}));
it('should have li tag', () => {
render(<AutocompleteOption />);
expect(screen.getByRole('option')).to.have.tagName('li');
});
it('should render with the variant class', () => {
render(<AutocompleteOption variant="outlined" />);
expect(screen.getByRole('option')).to.have.class(classes.variantOutlined);
});
it('should render with primary color class', () => {
render(<AutocompleteOption color="primary" />);
expect(screen.getByRole('option')).to.have.class(classes.colorPrimary);
});
it('should accept className prop', () => {
const { container } = render(<AutocompleteOption className="foo-bar" />);
expect(container.firstChild).to.have.class('foo-bar');
});
});

View File

@@ -0,0 +1,158 @@
'use client';
import * as React from 'react';
import clsx from 'clsx';
import PropTypes from 'prop-types';
import { OverridableComponent } from '@mui/types';
import capitalize from '@mui/utils/capitalize';
import { unstable_composeClasses as composeClasses } from '@mui/base/composeClasses';
import { StyledListItemButton } from '../ListItemButton/ListItemButton';
import { styled, useThemeProps } from '../styles';
import { useVariantColor } from '../styles/variantColorInheritance';
import { getAutocompleteOptionUtilityClass } from './autocompleteOptionClasses';
import { AutocompleteOptionOwnerState, AutocompleteOptionTypeMap } from './AutocompleteOptionProps';
import useSlot from '../utils/useSlot';
const useUtilityClasses = (ownerState: AutocompleteOptionOwnerState) => {
const { color, variant } = ownerState;
const slots = {
root: [
'root',
color && `color${capitalize(color)}`,
variant && `variant${capitalize(variant)}`,
],
};
return composeClasses(slots, getAutocompleteOptionUtilityClass, {});
};
export const StyledAutocompleteOption = styled(StyledListItemButton as unknown as 'li')<{
ownerState: AutocompleteOptionOwnerState;
}>(({ theme, ownerState }) => ({
'&[aria-disabled="true"]': theme.variants[`${ownerState.variant!}Disabled`]?.[ownerState.color!],
'&[aria-selected="true"]': {
...theme.variants[`${ownerState.variant!}Active`]?.[ownerState.color!],
fontWeight: theme.vars.fontWeight.md,
},
}));
const AutocompleteOptionRoot = styled(StyledAutocompleteOption, {
name: 'JoyAutocompleteOption',
slot: 'Root',
overridesResolver: (props, styles) => styles.root,
})({});
/**
*
* Demos:
*
* - [Autocomplete](https://mui.com/joy-ui/react-autocomplete/)
*
* API:
*
* - [AutocompleteOption API](https://mui.com/joy-ui/api/autocomplete-option/)
*/
const AutocompleteOption = React.forwardRef(function AutocompleteOption(inProps, ref) {
const props = useThemeProps<typeof inProps & { component?: React.ElementType }>({
props: inProps,
name: 'JoyAutocompleteOption',
});
const {
children,
component = 'li',
color: colorProp = 'neutral',
variant: variantProp = 'plain',
className,
slots = {},
slotProps = {},
...other
} = props;
const { variant = variantProp, color = colorProp } = useVariantColor(
inProps.variant,
inProps.color,
);
const ownerState = {
...props,
component,
color,
variant,
};
const classes = useUtilityClasses(ownerState);
const externalForwardedProps = { ...other, component, slots, slotProps };
const [SlotRoot, rootProps] = useSlot('root', {
ref,
className: clsx(classes.root, className),
elementType: AutocompleteOptionRoot,
externalForwardedProps,
ownerState,
additionalProps: {
as: component,
role: 'option',
},
});
return <SlotRoot {...rootProps}>{children}</SlotRoot>;
}) as OverridableComponent<AutocompleteOptionTypeMap>;
AutocompleteOption.propTypes /* remove-proptypes */ = {
// ┌────────────────────────────── Warning ──────────────────────────────┐
// │ These PropTypes are generated from the TypeScript type definitions. │
// │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
// └─────────────────────────────────────────────────────────────────────┘
/**
* @ignore
*/
children: PropTypes.node,
/**
* @ignore
*/
className: PropTypes.string,
/**
* The color of the component. It supports those theme colors that make sense for this component.
* @default 'neutral'
*/
color: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([
PropTypes.oneOf(['danger', 'neutral', 'primary', 'success', 'warning']),
PropTypes.string,
]),
/**
* The component used for the root node.
* Either a string to use a HTML element or a component.
*/
component: PropTypes.elementType,
/**
* The props used for each slot inside.
* @default {}
*/
slotProps: PropTypes.shape({
root: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
}),
/**
* The components used for each slot inside.
* @default {}
*/
slots: PropTypes.shape({
root: PropTypes.elementType,
}),
/**
* The system prop that allows defining system overrides as well as additional CSS styles.
*/
sx: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.func, PropTypes.object, PropTypes.bool])),
PropTypes.func,
PropTypes.object,
]),
/**
* The [global variant](https://mui.com/joy-ui/main-features/global-variants/) to use.
* @default 'plain'
*/
variant: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([
PropTypes.oneOf(['contained', 'light', 'outlined', 'text']),
PropTypes.string,
]),
} as any;
export default AutocompleteOption;

View File

@@ -0,0 +1,54 @@
import * as React from 'react';
import { OverrideProps, OverridableStringUnion } from '@mui/types';
import { ColorPaletteProp, VariantProp, SxProps, ApplyColorInversion } from '../styles/types';
import { SlotProps, CreateSlotsAndSlotProps } from '../utils/types';
export type AutocompleteOptionSlot = 'root';
export interface AutocompleteOptionSlots {
/**
* The component that renders the root.
* @default 'li'
*/
root?: React.ElementType;
}
export type AutocompleteOptionSlotsAndSlotProps = CreateSlotsAndSlotProps<
AutocompleteOptionSlots,
{
root: SlotProps<'li', {}, AutocompleteOptionOwnerState>;
}
>;
export interface AutocompleteOptionPropsColorOverrides {}
export interface AutocompleteOptionPropsVariantOverrides {}
export interface AutocompleteOptionTypeMap<P = {}, D extends React.ElementType = 'li'> {
props: P & {
/**
* The color of the component. It supports those theme colors that make sense for this component.
* @default 'neutral'
*/
color?: OverridableStringUnion<ColorPaletteProp, AutocompleteOptionPropsColorOverrides>;
/**
* The [global variant](https://mui.com/joy-ui/main-features/global-variants/) to use.
* @default 'plain'
*/
variant?: OverridableStringUnion<VariantProp, AutocompleteOptionPropsVariantOverrides>;
/**
* The system prop that allows defining system overrides as well as additional CSS styles.
*/
sx?: SxProps;
} & AutocompleteOptionSlotsAndSlotProps;
defaultComponent: D;
}
export type AutocompleteOptionProps<
D extends React.ElementType = AutocompleteOptionTypeMap['defaultComponent'],
P = {
component?: React.ElementType;
},
> = OverrideProps<AutocompleteOptionTypeMap<P, D>, D>;
export interface AutocompleteOptionOwnerState
extends ApplyColorInversion<AutocompleteOptionProps> {}

View File

@@ -0,0 +1,57 @@
import { generateUtilityClass, generateUtilityClasses } from '../className';
export interface AutocompleteOptionClasses {
/** Class name applied to the root element. */
root: string;
/** State class applied to the root element if focused. */
focused: string;
/** State class applied to the `component`'s `focusVisibleClassName` prop. */
focusVisible: string;
/** Class name applied to the root element if `color="primary"`. */
colorPrimary: string;
/** Class name applied to the root element if `color="neutral"`. */
colorNeutral: string;
/** Class name applied to the root element if `color="danger"`. */
colorDanger: string;
/** Class name applied to the root element if `color="success"`. */
colorSuccess: string;
/** Class name applied to the root element if `color="warning"`. */
colorWarning: string;
/** Class name applied to the root element when color inversion is triggered. */
colorContext: string;
/** State class applied to the root element if `variant="plain"`. */
variantPlain: string;
/** State class applied to the root element if `variant="soft"`. */
variantSoft: string;
/** State class applied to the root element if `variant="outlined"`. */
variantOutlined: string;
/** State class applied to the root element if `variant="solid"`. */
variantSolid: string;
}
export type AutocompleteOptionClassKey = keyof AutocompleteOptionClasses;
export function getAutocompleteOptionUtilityClass(slot: string): string {
return generateUtilityClass('MuiAutocompleteOption', slot);
}
const autocompleteOptionClasses: AutocompleteOptionClasses = generateUtilityClasses(
'MuiAutocompleteOption',
[
'root',
'focused',
'focusVisible',
'colorPrimary',
'colorNeutral',
'colorDanger',
'colorSuccess',
'colorWarning',
'colorContext',
'variantPlain',
'variantSoft',
'variantOutlined',
'variantSolid',
],
);
export default autocompleteOptionClasses;

View File

@@ -0,0 +1,4 @@
export { default } from './AutocompleteOption';
export * from './autocompleteOptionClasses';
export { default as autocompleteOptionClasses } from './autocompleteOptionClasses';
export * from './AutocompleteOptionProps';

View File

@@ -0,0 +1,81 @@
import { expectType } from '@mui/types';
import Avatar, { AvatarOwnerState } from '@mui/joy/Avatar';
<Avatar />;
<Avatar component="div" />;
// `variant`
<Avatar />;
<Avatar variant="soft" />;
<Avatar variant="outlined" />;
<Avatar variant="solid" />;
// `color`
<Avatar color="primary" />;
<Avatar color="danger" />;
<Avatar color="success" />;
<Avatar color="warning" />;
<Avatar color="neutral" />;
// `size`
<Avatar size="sm" />;
<Avatar size="md" />;
<Avatar size="lg" />;
// @ts-expect-error there is no variant `filled`
<Avatar variant="filled" />;
// @ts-expect-error there is no color `secondary`
<Avatar color="secondary" />;
// @ts-expect-error there is no size `xl2`
<Avatar size="xl2" />;
<Avatar
slots={{
root: 'div',
img: 'div',
fallback: 'div',
}}
/>;
<Avatar
slotProps={{
root: {
component: 'div',
'data-testid': 'test',
},
img: {
component: 'div',
'data-testid': 'test',
},
fallback: {
component: 'div',
'data-testid': 'test',
},
}}
/>;
<Avatar
slotProps={{
root: (ownerState) => {
expectType<AvatarOwnerState, typeof ownerState>(ownerState);
return {
'data-testid': 'test',
};
},
img: (ownerState) => {
expectType<AvatarOwnerState, typeof ownerState>(ownerState);
return {
'data-testid': 'test',
};
},
fallback: (ownerState) => {
expectType<AvatarOwnerState, typeof ownerState>(ownerState);
return {
'data-testid': 'test',
};
},
}}
/>;

View File

@@ -0,0 +1,280 @@
import { expect } from 'chai';
import { spy } from 'sinon';
import { createRenderer, fireEvent, screen } from '@mui/internal-test-utils';
import capitalize from '@mui/utils/capitalize';
import { ThemeProvider } from '@mui/joy/styles';
import Avatar, { AvatarClassKey, avatarClasses as classes } from '@mui/joy/Avatar';
import PersonIcon from '../internal/svg-icons/Person';
import describeConformance from '../../test/describeConformance';
describe('<Avatar />', () => {
const { render } = createRenderer();
describeConformance(<Avatar />, () => ({
classes,
inheritComponent: 'div',
render,
ThemeProvider,
muiName: 'JoyAvatar',
refInstanceof: window.HTMLDivElement,
testComponentPropWith: 'span',
testDeepOverrides: { slotName: 'fallback', slotClassName: classes.fallback },
testVariantProps: { variant: 'solid' },
testCustomVariant: true,
slots: {
root: { expectedClassName: classes.root },
fallback: { expectedClassName: classes.fallback },
},
skip: ['classesRoot', 'componentsProp'],
}));
describe('prop: variant', () => {
it('soft by default', () => {
render(<Avatar data-testid="root" />);
expect(screen.getByTestId('root')).to.have.class(classes.variantSoft);
});
(['outlined', 'soft', 'solid'] as const).forEach((variant) => {
it(`should render ${variant}`, () => {
render(<Avatar data-testid="root" variant={variant} />);
expect(screen.getByTestId('root')).to.have.class(
classes[`variant${capitalize(variant)}` as AvatarClassKey],
);
});
});
});
describe('prop: color', () => {
it('adds a neutral class by default', () => {
render(<Avatar data-testid="root" />);
expect(screen.getByTestId('root')).to.have.class(classes.colorNeutral);
});
(['primary', 'success', 'danger', 'neutral', 'warning'] as const).forEach((color) => {
it(`should render ${color}`, () => {
render(<Avatar data-testid="root" color={color} />);
expect(screen.getByTestId('root')).to.have.class(
classes[`color${capitalize(color)}` as AvatarClassKey],
);
});
});
});
describe('prop: size', () => {
it('md by default', () => {
render(<Avatar data-testid="root" />);
expect(screen.getByTestId('root')).to.have.class(classes.sizeMd);
});
(['sm', 'md', 'lg'] as const).forEach((size) => {
it(`should render ${size}`, () => {
render(<Avatar data-testid="root" size={size} />);
expect(screen.getByTestId('root')).to.have.class(
classes[`size${capitalize(size)}` as AvatarClassKey],
);
});
});
});
describe('image avatar', () => {
it('should render a div containing an img', () => {
const { container } = render(
<Avatar
className="my-avatar"
src="/fake.png"
alt="Hello World!"
data-my-prop="woofAvatar"
/>,
);
const avatar = container.firstChild;
const img = avatar?.firstChild;
expect(avatar).to.have.tagName('div');
expect(img).to.have.tagName('img');
expect(avatar).to.have.class(classes.root);
expect(avatar).to.have.class('my-avatar');
expect(avatar).to.have.attribute('data-my-prop', 'woofAvatar');
expect(img).to.have.class(classes.img);
expect(img).to.have.attribute('alt', 'Hello World!');
expect(img).to.have.attribute('src', '/fake.png');
});
it('should be able to add more props to the image', () => {
const onError = spy();
const { container } = render(<Avatar src="/fake.png" slotProps={{ img: { onError } }} />);
const img = container.querySelector('img');
fireEvent.error(img as HTMLImageElement);
expect(onError.callCount).to.equal(1);
});
});
describe('image avatar with unrendered children', () => {
it('should render a div containing an img, not children', () => {
const { container } = render(<Avatar src="/fake.png">MB</Avatar>);
const avatar = container.firstChild;
const imgs = container.querySelectorAll('img');
expect(imgs.length).to.equal(1);
expect(avatar).to.have.text('');
});
it('should be able to add more props to the image', () => {
const onError = spy();
const { container } = render(<Avatar src="/fake.png" slotProps={{ img: { onError } }} />);
const img = container.querySelector('img');
fireEvent.error(img as HTMLImageElement);
expect(onError.callCount).to.equal(1);
});
});
describe('font icon avatar', () => {
it('should render a div containing an font icon', () => {
const { container } = render(
<Avatar>
<span className="my-icon-font" data-testid="icon">
icon
</span>
</Avatar>,
);
const avatar = container.firstChild;
const icon = avatar?.firstChild;
expect(avatar).to.have.tagName('div');
expect(icon).to.have.tagName('span');
expect(icon).to.have.class('my-icon-font');
expect(icon).to.have.text('icon');
});
it('should merge user classes & spread custom props to the root node', () => {
const { container } = render(
<Avatar className="my-avatar" data-my-prop="woofAvatar">
<span>icon</span>
</Avatar>,
);
const avatar = container.firstChild;
expect(avatar).to.have.class(classes.root);
expect(avatar).to.have.class('my-avatar');
expect(avatar).to.have.attribute('data-my-prop', 'woofAvatar');
});
it('should apply the colorNeutral class', () => {
const { container } = render(
<Avatar data-testid="avatar">
<span>icon</span>
</Avatar>,
);
const avatar = container.firstChild;
expect(avatar).to.have.class(classes.colorNeutral);
});
});
describe('svg icon avatar', () => {
it('should render a div containing an svg icon', () => {
const container = render(
<Avatar>
<PersonIcon />
</Avatar>,
).container;
const avatar = container.firstChild;
expect(avatar).to.have.tagName('div');
const personIcon = (avatar as ChildNode).firstChild;
expect(personIcon).to.have.attribute('data-testid', 'PersonIcon');
});
it('should merge user classes & spread custom props to the root node', () => {
const container = render(
<Avatar className="my-avatar" data-my-prop="woofAvatar">
<PersonIcon />
</Avatar>,
).container;
const avatar = container.firstChild;
expect(avatar).to.have.class(classes.root);
expect(avatar).to.have.class('my-avatar');
expect(avatar).to.have.attribute('data-my-prop', 'woofAvatar');
});
it('should apply the colorNeutral class', () => {
const container = render(
<Avatar>
<PersonIcon />
</Avatar>,
).container;
const avatar = container.firstChild;
expect(avatar).to.have.class(classes.colorNeutral);
});
});
describe('text avatar', () => {
it('should render a div containing a string', () => {
const container = render(<Avatar>OT</Avatar>).container;
const avatar = container.firstChild;
expect(avatar).to.have.tagName('div');
expect((avatar as ChildNode).firstChild).to.text('OT');
});
it('should merge user classes & spread custom props to the root node', () => {
const container = render(
<Avatar className="my-avatar" data-my-prop="woofAvatar">
OT
</Avatar>,
).container;
const avatar = container.firstChild;
expect(avatar).to.have.class(classes.root);
expect(avatar).to.have.class('my-avatar');
expect(avatar).to.have.attribute('data-my-prop', 'woofAvatar');
});
it('should apply the colorNeutral class', () => {
const container = render(<Avatar>OT</Avatar>).container;
const avatar = container.firstChild;
expect(avatar).to.have.class(classes.colorNeutral);
});
});
describe('falsey avatar', () => {
it('should render with defaultColor class when supplied with a child with falsey value', () => {
const container = render(<Avatar>{0}</Avatar>).container;
const avatar = container.firstChild;
expect(avatar).to.have.tagName('div');
expect((avatar as ChildNode).firstChild).to.text('0');
});
it('should merge user classes & spread custom props to the root node', () => {
const container = render(
<Avatar className="my-avatar" data-my-prop="woofAvatar">
{0}
</Avatar>,
).container;
const avatar = container.firstChild;
expect(avatar).to.have.class(classes.root);
expect(avatar).to.have.class('my-avatar');
expect(avatar).to.have.attribute('data-my-prop', 'woofAvatar');
});
it('should apply the colorNeutral class', () => {
const container = render(<Avatar>{0}</Avatar>).container;
const avatar = container.firstChild;
expect(avatar).to.have.class(classes.colorNeutral);
});
});
it('should render first letter of alt when src or srcSet are not available', () => {
const { container } = render(<Avatar className="my-avatar" alt="Hello World!" />);
const avatar = container.firstChild;
expect(avatar).to.have.text('H');
});
});

View File

@@ -0,0 +1,319 @@
'use client';
import * as React from 'react';
import PropTypes from 'prop-types';
import { unstable_composeClasses as composeClasses } from '@mui/base';
import { OverridableComponent } from '@mui/types';
import capitalize from '@mui/utils/capitalize';
import { useThemeProps } from '../styles';
import useSlot from '../utils/useSlot';
import styled from '../styles/styled';
import Person from '../internal/svg-icons/Person';
import { getAvatarUtilityClass } from './avatarClasses';
import { AvatarProps, AvatarOwnerState, AvatarTypeMap } from './AvatarProps';
import { AvatarGroupContext } from '../AvatarGroup/AvatarGroup';
const useUtilityClasses = (ownerState: AvatarOwnerState) => {
const { size, variant, color, src, srcSet } = ownerState;
const slots = {
root: [
'root',
variant && `variant${capitalize(variant)}`,
color && `color${capitalize(color)}`,
size && `size${capitalize(size)}`,
],
img: [(src || srcSet) && 'img'],
fallback: ['fallback'],
};
return composeClasses(slots, getAvatarUtilityClass, {});
};
const AvatarRoot = styled('div', {
name: 'JoyAvatar',
slot: 'Root',
overridesResolver: (props, styles) => styles.root,
})<{ ownerState: AvatarOwnerState }>(({ theme, ownerState }) => ({
'--Icon-color':
ownerState.color !== 'neutral' || ownerState.variant === 'solid'
? 'currentColor'
: theme.vars.palette.text.icon,
...theme.typography[`title-${ownerState.size!}`],
...(ownerState.size === 'sm' && {
width: `var(--Avatar-size, 2rem)`,
height: `var(--Avatar-size, 2rem)`,
fontSize: `calc(var(--Avatar-size, 2rem) * 0.4375)`, // default as 14px
}),
...(ownerState.size === 'md' && {
width: `var(--Avatar-size, 2.5rem)`,
height: `var(--Avatar-size, 2.5rem)`,
fontSize: `calc(var(--Avatar-size, 2.5rem) * 0.4)`, // default as 16px
}),
...(ownerState.size === 'lg' && {
width: `var(--Avatar-size, 3rem)`,
height: `var(--Avatar-size, 3rem)`,
fontSize: `calc(var(--Avatar-size, 3rem) * 0.375)`, // default as 18px
}),
marginInlineStart: 'var(--Avatar-marginInlineStart)',
boxShadow: `var(--Avatar-ring)`,
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
lineHeight: 1,
overflow: 'hidden',
borderRadius: 'var(--Avatar-radius, 50%)',
userSelect: 'none',
...theme.variants[ownerState.variant!]?.[ownerState.color!],
}));
const AvatarImg = styled('img', {
name: 'JoyAvatar',
slot: 'Img',
overridesResolver: (props, styles) => styles.img,
})<{ ownerState: AvatarOwnerState }>({
width: '100%',
height: '100%',
textAlign: 'center',
// Handle non-square image.
objectFit: 'cover',
// Hide alt text.
color: 'transparent',
// Hide the image broken icon, only works on Chrome.
textIndent: 10000,
});
const AvatarFallback = styled(Person as unknown as 'svg', {
name: 'JoyAvatar',
slot: 'Fallback',
overridesResolver: (props, styles) => styles.fallback,
})<{ ownerState: AvatarOwnerState }>({
width: '64%',
height: '64%',
});
type UseLoadedProps = { src?: string; srcSet?: string; crossOrigin?: any; referrerPolicy?: any };
function useLoaded({ crossOrigin, referrerPolicy, src, srcSet }: UseLoadedProps) {
const [loaded, setLoaded] = React.useState<string | boolean>(false);
React.useEffect(() => {
if (!src && !srcSet) {
return undefined;
}
setLoaded(false);
let active = true;
const image = new Image();
image.onload = () => {
if (!active) {
return;
}
setLoaded('loaded');
};
image.onerror = () => {
if (!active) {
return;
}
setLoaded('error');
};
image.crossOrigin = crossOrigin;
image.referrerPolicy = referrerPolicy;
if (src) {
image.src = src;
}
if (srcSet) {
image.srcset = srcSet;
}
return () => {
active = false;
};
}, [crossOrigin, referrerPolicy, src, srcSet]);
return loaded;
}
/**
*
* Demos:
*
* - [Avatar](https://mui.com/joy-ui/react-avatar/)
* - [Skeleton](https://mui.com/joy-ui/react-skeleton/)
*
* API:
*
* - [Avatar API](https://mui.com/joy-ui/api/avatar/)
*/
const Avatar = React.forwardRef(function Avatar(inProps, ref) {
const props = useThemeProps<typeof inProps & AvatarProps>({
props: inProps,
name: 'JoyAvatar',
});
const groupContext = React.useContext(AvatarGroupContext);
const {
alt,
color: colorProp = 'neutral',
size: sizeProp = 'md',
variant: variantProp = 'soft',
src,
srcSet,
children: childrenProp,
component,
slots = {},
slotProps = {},
...other
} = props;
const variant = inProps.variant || groupContext?.variant || variantProp;
const color = inProps.color || groupContext?.color || colorProp;
const size = inProps.size || groupContext?.size || sizeProp;
let children = null;
const ownerState = {
...props,
color,
size,
variant,
grouped: !!groupContext,
};
const classes = useUtilityClasses(ownerState);
const externalForwardedProps = { ...other, component, slots, slotProps };
const [SlotRoot, rootProps] = useSlot('root', {
ref,
className: classes.root,
elementType: AvatarRoot,
externalForwardedProps,
ownerState,
});
const [SlotImg, imageProps] = useSlot('img', {
additionalProps: {
alt,
src,
srcSet,
},
className: classes.img,
elementType: AvatarImg,
externalForwardedProps,
ownerState,
});
const [SlotFallback, fallbackProps] = useSlot('fallback', {
className: classes.fallback,
elementType: AvatarFallback,
externalForwardedProps,
ownerState,
});
// Use a hook instead of onError on the img element to support server-side rendering.
const loaded = useLoaded({
...imageProps,
src,
srcSet,
});
const hasImg = src || srcSet;
const hasImgNotFailing = hasImg && loaded !== 'error';
if (hasImgNotFailing) {
children = <SlotImg {...imageProps} />;
} else if (childrenProp != null) {
children = childrenProp;
} else if (alt) {
children = alt[0];
} else {
children = <SlotFallback {...fallbackProps} />;
}
return <SlotRoot {...rootProps}>{children}</SlotRoot>;
}) as OverridableComponent<AvatarTypeMap>;
Avatar.propTypes /* remove-proptypes */ = {
// ┌────────────────────────────── Warning ──────────────────────────────┐
// │ These PropTypes are generated from the TypeScript type definitions. │
// │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
// └─────────────────────────────────────────────────────────────────────┘
/**
* Used in combination with `src` or `srcSet` to
* provide an alt attribute for the rendered `img` element.
*/
alt: PropTypes.string,
/**
* Used to render icon or text elements inside the Avatar if `src` is not set.
* This can be an element, or just a string.
*/
children: PropTypes.node,
/**
* The color of the component. It supports those theme colors that make sense for this component.
* @default 'neutral'
*/
color: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([
PropTypes.oneOf(['danger', 'neutral', 'primary', 'success', 'warning']),
PropTypes.string,
]),
/**
* The component used for the root node.
* Either a string to use a HTML element or a component.
*/
component: PropTypes.elementType,
/**
* The size of the component.
* It accepts theme values between 'sm' and 'lg'.
* @default 'md'
*/
size: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([
PropTypes.oneOf(['lg', 'md', 'sm']),
PropTypes.string,
]),
/**
* The props used for each slot inside.
* @default {}
*/
slotProps: PropTypes.shape({
fallback: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
img: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
root: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
}),
/**
* The components used for each slot inside.
* @default {}
*/
slots: PropTypes.shape({
fallback: PropTypes.elementType,
img: PropTypes.elementType,
root: PropTypes.elementType,
}),
/**
* The `src` attribute for the `img` element.
*/
src: PropTypes.string,
/**
* The `srcSet` attribute for the `img` element.
* Use this attribute for responsive image display.
*/
srcSet: PropTypes.string,
/**
* The system prop that allows defining system overrides as well as additional CSS styles.
*/
sx: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.func, PropTypes.object, PropTypes.bool])),
PropTypes.func,
PropTypes.object,
]),
/**
* The [global variant](https://mui.com/joy-ui/main-features/global-variants/) to use.
* @default 'soft'
*/
variant: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([
PropTypes.oneOf(['outlined', 'plain', 'soft', 'solid']),
PropTypes.string,
]),
} as any;
export default Avatar;

View File

@@ -0,0 +1,95 @@
import * as React from 'react';
import { OverridableStringUnion, OverrideProps } from '@mui/types';
import { ColorPaletteProp, SxProps, VariantProp, ApplyColorInversion } from '../styles/types';
import { CreateSlotsAndSlotProps, SlotProps } from '../utils/types';
export type AvatarSlot = 'root' | 'img' | 'fallback';
export interface AvatarSlots {
/**
* The component that renders the root.
* @default 'div'
*/
root?: React.ElementType;
/**
* The component that renders the img.
* @default 'img'
*/
img?: React.ElementType;
/**
* The component that renders the fallback.
* @default 'svg'
*/
fallback?: React.ElementType;
}
export interface AvatarPropsColorOverrides {}
export interface AvatarPropsVariantOverrides {}
export interface AvatarPropsSizeOverrides {}
export type AvatarSlotsAndSlotProps = CreateSlotsAndSlotProps<
AvatarSlots,
{
root: SlotProps<'div', {}, AvatarOwnerState>;
img: SlotProps<'img', {}, AvatarOwnerState>;
fallback: SlotProps<'svg', {}, AvatarOwnerState>;
}
>;
export interface AvatarTypeMap<P = {}, D extends React.ElementType = 'div'> {
props: P &
AvatarSlotsAndSlotProps & {
/**
* Used in combination with `src` or `srcSet` to
* provide an alt attribute for the rendered `img` element.
*/
alt?: string;
/**
* Used to render icon or text elements inside the Avatar if `src` is not set.
* This can be an element, or just a string.
*/
children?: React.ReactNode;
/**
* The color of the component. It supports those theme colors that make sense for this component.
* @default 'neutral'
*/
color?: OverridableStringUnion<ColorPaletteProp, AvatarPropsColorOverrides>;
/**
* The size of the component.
* It accepts theme values between 'sm' and 'lg'.
* @default 'md'
*/
size?: OverridableStringUnion<'sm' | 'md' | 'lg', AvatarPropsSizeOverrides>;
/**
* The `src` attribute for the `img` element.
*/
src?: string;
/**
* The `srcSet` attribute for the `img` element.
* Use this attribute for responsive image display.
*/
srcSet?: string;
/**
* The system prop that allows defining system overrides as well as additional CSS styles.
*/
sx?: SxProps;
/**
* The [global variant](https://mui.com/joy-ui/main-features/global-variants/) to use.
* @default 'soft'
*/
variant?: OverridableStringUnion<VariantProp, AvatarPropsVariantOverrides>;
};
defaultComponent: D;
}
export type AvatarProps<
D extends React.ElementType = AvatarTypeMap['defaultComponent'],
P = { component?: React.ElementType },
> = OverrideProps<AvatarTypeMap<P, D>, D>;
export interface AvatarOwnerState extends ApplyColorInversion<AvatarProps> {
/**
* The avatar is wrapped by AvatarGroup component.
*/
grouped: boolean;
}

View File

@@ -0,0 +1,60 @@
import { generateUtilityClass, generateUtilityClasses } from '../className';
export interface AvatarClasses {
/** Class name applied to the root element. */
root: string;
/** Class name applied to the root element if `color="primary"`. */
colorPrimary: string;
/** Class name applied to the root element if `color="neutral"`. */
colorNeutral: string;
/** Class name applied to the root element if `color="danger"`. */
colorDanger: string;
/** Class name applied to the root element if `color="success"`. */
colorSuccess: string;
/** Class name applied to the root element if `color="warning"`. */
colorWarning: string;
/** Class name applied to the root element when color inversion is triggered. */
colorContext: string;
/** Class name applied to the fallback icon. */
fallback: string;
/** Class name applied to the root element if `size="sm"`. */
sizeSm: string;
/** Class name applied to the root element if `size="md"`. */
sizeMd: string;
/** Class name applied to the root element if `size="lg"`. */
sizeLg: string;
/** Class name applied to the img element if either `src` or `srcSet` is defined. */
img: string;
/** Class name applied to the root element if `variant="outlined"`. */
variantOutlined: string;
/** Class name applied to the root element if `variant="soft"`. */
variantSoft: string;
/** Class name applied to the root element if `variant="solid"`. */
variantSolid: string;
}
export type AvatarClassKey = keyof AvatarClasses;
export function getAvatarUtilityClass(slot: string): string {
return generateUtilityClass('MuiAvatar', slot);
}
const avatarClasses: AvatarClasses = generateUtilityClasses('MuiAvatar', [
'root',
'colorPrimary',
'colorNeutral',
'colorDanger',
'colorSuccess',
'colorWarning',
'colorContext',
'fallback',
'sizeSm',
'sizeMd',
'sizeLg',
'img',
'variantOutlined',
'variantSoft',
'variantSolid',
]);
export default avatarClasses;

View File

@@ -0,0 +1,4 @@
export { default } from './Avatar';
export * from './avatarClasses';
export { default as avatarClasses } from './avatarClasses';
export * from './AvatarProps';

View File

@@ -0,0 +1,45 @@
import { expect } from 'chai';
import { createRenderer } from '@mui/internal-test-utils';
import { ThemeProvider } from '@mui/joy/styles';
import AvatarGroup, { avatarGroupClasses as classes } from '@mui/joy/AvatarGroup';
import Avatar, { avatarClasses } from '@mui/joy/Avatar';
import describeConformance from '../../test/describeConformance';
describe('<AvatarGroup />', () => {
const { render } = createRenderer();
describeConformance(<AvatarGroup />, () => ({
classes,
inheritComponent: 'div',
render,
ThemeProvider,
muiName: 'JoyAvatarGroup',
refInstanceof: window.HTMLDivElement,
testComponentPropWith: 'span',
testVariantProps: { variant: 'solid' },
testCustomVariant: true,
skip: ['classesRoot', 'componentsProp'],
slots: {
root: {
expectedClassName: classes.root,
},
},
}));
it('provide context to Avatar', () => {
const { container } = render(
<AvatarGroup variant="solid" color="primary" size="sm">
<Avatar src="/" />
</AvatarGroup>,
);
const firstChild = container.firstChild;
if (firstChild === null) {
return;
}
const avatar = container.firstChild?.firstChild;
expect(avatar).to.have.class(avatarClasses.colorPrimary);
expect(avatar).to.have.class(avatarClasses.variantSolid);
expect(avatar).to.have.class(avatarClasses.sizeSm);
});
});

View File

@@ -0,0 +1,174 @@
'use client';
import * as React from 'react';
import clsx from 'clsx';
import PropTypes from 'prop-types';
import { unstable_composeClasses as composeClasses } from '@mui/base';
import { OverridableComponent } from '@mui/types';
import { useThemeProps } from '../styles';
import styled from '../styles/styled';
import { getAvatarGroupUtilityClass } from './avatarGroupClasses';
import { AvatarGroupProps, AvatarGroupOwnerState, AvatarGroupTypeMap } from './AvatarGroupProps';
import useSlot from '../utils/useSlot';
export const AvatarGroupContext = React.createContext<AvatarGroupOwnerState | undefined>(undefined);
if (process.env.NODE_ENV !== 'production') {
AvatarGroupContext.displayName = 'AvatarGroupContext';
}
const useUtilityClasses = () => {
const slots = {
root: ['root'],
};
return composeClasses(slots, getAvatarGroupUtilityClass, {});
};
const AvatarGroupGroupRoot = styled('div', {
name: 'JoyAvatarGroup',
slot: 'Root',
overridesResolver: (props, styles) => styles.root,
})<{ ownerState: AvatarGroupOwnerState }>(({ ownerState, theme }) => ({
...(ownerState.size === 'sm' && {
'--AvatarGroup-gap': '-0.375rem',
'--Avatar-ringSize': '2px',
}),
...(ownerState.size === 'md' && {
'--AvatarGroup-gap': '-0.5rem',
'--Avatar-ringSize': '2px',
}),
...(ownerState.size === 'lg' && {
'--AvatarGroup-gap': '-0.625rem',
'--Avatar-ringSize': '4px',
}),
'--Avatar-ring': `0 0 0 var(--Avatar-ringSize) var(--Avatar-ringColor, ${theme.vars.palette.background.surface})`,
'--Avatar-marginInlineStart': 'var(--AvatarGroup-gap)',
display: 'flex',
marginInlineStart: 'calc(-1 * var(--AvatarGroup-gap))',
}));
/**
*
* Demos:
*
* - [Avatar](https://mui.com/joy-ui/react-avatar/)
*
* API:
*
* - [AvatarGroup API](https://mui.com/joy-ui/api/avatar-group/)
*/
const AvatarGroup = React.forwardRef(function AvatarGroup(inProps, ref) {
const props = useThemeProps<typeof inProps & AvatarGroupProps>({
props: inProps,
name: 'JoyAvatarGroup',
});
const {
className,
color,
component = 'div',
size = 'md',
variant,
children,
slots = {},
slotProps = {},
...other
} = props;
const ownerState = React.useMemo(
() => ({
...props,
color,
component,
size,
variant,
}),
[color, component, props, size, variant],
);
const classes = useUtilityClasses();
const [SlotRoot, rootProps] = useSlot('root', {
ref,
className: clsx(classes.root, className),
elementType: AvatarGroupGroupRoot,
externalForwardedProps: { ...other, component, slots, slotProps },
ownerState,
});
return (
<AvatarGroupContext.Provider value={ownerState}>
<SlotRoot {...rootProps}>{children}</SlotRoot>
</AvatarGroupContext.Provider>
);
}) as OverridableComponent<AvatarGroupTypeMap>;
AvatarGroup.propTypes /* remove-proptypes */ = {
// ┌────────────────────────────── Warning ──────────────────────────────┐
// │ These PropTypes are generated from the TypeScript type definitions. │
// │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
// └─────────────────────────────────────────────────────────────────────┘
/**
* Used to render icon or text elements inside the AvatarGroup if `src` is not set.
* This can be an element, or just a string.
*/
children: PropTypes.node,
/**
* @ignore
*/
className: PropTypes.string,
/**
* The color of the component. It supports those theme colors that make sense for this component.
* @default 'neutral'
*/
color: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([
PropTypes.oneOf(['danger', 'neutral', 'primary', 'success', 'warning']),
PropTypes.string,
]),
/**
* The component used for the root node.
* Either a string to use a HTML element or a component.
*/
component: PropTypes.elementType,
/**
* The size of the component.
* It accepts theme values between 'sm' and 'lg'.
* @default 'md'
*/
size: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([
PropTypes.oneOf(['lg', 'md', 'sm']),
PropTypes.string,
]),
/**
* The props used for each slot inside.
* @default {}
*/
slotProps: PropTypes.shape({
root: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
}),
/**
* The components used for each slot inside.
* @default {}
*/
slots: PropTypes.shape({
root: PropTypes.elementType,
}),
/**
* The system prop that allows defining system overrides as well as additional CSS styles.
*/
sx: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.func, PropTypes.object, PropTypes.bool])),
PropTypes.func,
PropTypes.object,
]),
/**
* The [global variant](https://mui.com/joy-ui/main-features/global-variants/) to use.
* @default 'soft'
*/
variant: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([
PropTypes.oneOf(['outlined', 'plain', 'soft', 'solid']),
PropTypes.string,
]),
} as any;
export default AvatarGroup;

View File

@@ -0,0 +1,62 @@
import * as React from 'react';
import { OverrideProps } from '@mui/types';
import { ApplyColorInversion, SxProps } from '../styles/types';
import { AvatarProps } from '../Avatar/AvatarProps';
import { SlotProps, CreateSlotsAndSlotProps } from '../utils/types';
export type AvatarGroupSlot = 'root';
export interface AvatarGroupSlots {
/**
* The component that renders the root.
* @default 'div'
*/
root?: React.ElementType;
}
export type AvatarGroupSlotsAndSlotProps = CreateSlotsAndSlotProps<
AvatarGroupSlots,
{
root: SlotProps<'div', {}, AvatarGroupOwnerState>;
}
>;
export interface AvatarGroupTypeMap<P = {}, D extends React.ElementType = 'div'> {
props: P &
Pick<AvatarProps, 'color' | 'size' | 'variant'> & {
/**
* The color context for the avatar children.
* It has no effect on the AvatarGroup.
* @default 'neutral'
*/
color?: AvatarProps['color'];
/**
* Used to render icon or text elements inside the AvatarGroup if `src` is not set.
* This can be an element, or just a string.
*/
children?: React.ReactNode;
/**
* The size of the component and the avatar children.
* @default 'md'
*/
size?: AvatarProps['size'];
/**
* The system prop that allows defining system overrides as well as additional CSS styles.
*/
sx?: SxProps;
/**
* The variant context for the avatar children.
* It has no effect on the AvatarGroup.
* @default 'soft'
*/
variant?: AvatarProps['variant'];
} & AvatarGroupSlotsAndSlotProps;
defaultComponent: D;
}
export type AvatarGroupProps<
D extends React.ElementType = AvatarGroupTypeMap['defaultComponent'],
P = { component?: React.ElementType },
> = OverrideProps<AvatarGroupTypeMap<P, D>, D>;
export interface AvatarGroupOwnerState extends ApplyColorInversion<AvatarGroupProps> {}

View File

@@ -0,0 +1,16 @@
import { generateUtilityClass, generateUtilityClasses } from '../className';
export interface AvatarGroupClasses {
/** Class name applied to the root element. */
root: string;
}
export type AvatarGroupClassKey = keyof AvatarGroupClasses;
export function getAvatarGroupUtilityClass(slot: string): string {
return generateUtilityClass('MuiAvatarGroup', slot);
}
const avatarGroupClasses: AvatarGroupClasses = generateUtilityClasses('MuiAvatarGroup', ['root']);
export default avatarGroupClasses;

View File

@@ -0,0 +1,4 @@
export { default } from './AvatarGroup';
export * from './avatarGroupClasses';
export { default as avatarGroupClasses } from './avatarGroupClasses';
export * from './AvatarGroupProps';

View File

@@ -0,0 +1,64 @@
import { expectType } from '@mui/types';
import Badge, { BadgeOwnerState } from '@mui/joy/Badge';
<Badge />;
<Badge component="div" />;
// `variant`
<Badge variant="soft" />;
<Badge variant="outlined" />;
<Badge variant="solid" />;
// `color`
<Badge color="primary" />;
<Badge color="danger" />;
<Badge color="success" />;
<Badge color="warning" />;
<Badge color="neutral" />;
// @ts-expect-error there is no variant `filled`
<Badge variant="filled" />;
// @ts-expect-error there is no color `secondary`
<Badge color="secondary" />;
// @ts-expect-error there is no elevation `xl2`
<Badge elevation="xl2" />;
<Badge
slots={{
root: 'div',
badge: 'div',
}}
/>;
<Badge
slotProps={{
root: {
component: 'div',
'data-testid': 'test',
},
badge: {
component: 'div',
'data-testid': 'test',
},
}}
/>;
<Badge
slotProps={{
root: (ownerState) => {
expectType<BadgeOwnerState, typeof ownerState>(ownerState);
return {
'data-testid': 'test',
};
},
badge: (ownerState) => {
expectType<BadgeOwnerState, typeof ownerState>(ownerState);
return {
'data-testid': 'test',
};
},
}}
/>;

View File

@@ -0,0 +1,211 @@
import { expect } from 'chai';
import { createRenderer, screen } from '@mui/internal-test-utils';
import capitalize from '@mui/utils/capitalize';
import { ThemeProvider } from '@mui/joy/styles';
import Badge, { BadgeClassKey, BadgeOrigin, badgeClasses as classes } from '@mui/joy/Badge';
import describeConformance from '../../test/describeConformance';
function findBadge(container: HTMLElement) {
return (container?.firstChild as HTMLElement)?.querySelector('span') ?? null;
}
describe('<Badge />', () => {
const { render } = createRenderer();
const defaultProps = {
children: (
<div className="unique" data-testid="children">
Hello World
</div>
),
badgeContent: 10,
};
describeConformance(
<Badge badgeContent="1">
<button />
</Badge>,
() => ({
classes,
inheritComponent: 'span',
render,
ThemeProvider,
refInstanceof: window.HTMLSpanElement,
muiName: 'JoyBadge',
testVariantProps: { color: 'neutral', variant: 'soft' },
testCustomVariant: true,
slots: {
root: { expectedClassName: classes.root },
badge: { expectedClassName: classes.badge },
},
skip: ['classesRoot', 'componentsProp'],
}),
);
it('renders children and badgeContent', () => {
const children = <div id="child" data-testid="child" />;
const badge = <div id="badge" data-testid="badge" />;
const { container } = render(<Badge badgeContent={badge}>{children}</Badge>);
expect(container.firstChild).to.contain(screen.getByTestId('child'));
expect(container.firstChild).to.contain(screen.getByTestId('badge'));
});
it('renders children', () => {
const { container } = render(<Badge className="testClassName" {...defaultProps} />);
expect(container.firstChild).to.contain(screen.getByTestId('children'));
});
describe('prop: invisible', () => {
it('should default to false', () => {
const { container } = render(<Badge {...defaultProps} />);
expect(findBadge(container)).not.to.have.class(classes.invisible);
});
it('should render without the invisible class when set to false', () => {
const { container } = render(<Badge {...defaultProps} invisible={false} />);
expect(findBadge(container)).not.to.have.class(classes.invisible);
});
it('should render with the invisible class when set to true', () => {
const { container } = render(<Badge {...defaultProps} invisible />);
expect(findBadge(container)).to.have.class(classes.invisible);
});
it('should render with invisible class when invisible and showZero are set to false and content is 0', () => {
const { container } = render(<Badge badgeContent={0} showZero={false} invisible={false} />);
expect(findBadge(container)).to.have.class(classes.invisible);
expect(findBadge(container)).to.have.text('');
});
it('should not render with invisible class when invisible and showZero are set to false and content is not 0', () => {
const { container } = render(<Badge badgeContent={1} showZero={false} invisible={false} />);
expect(findBadge(container)).not.to.have.class(classes.invisible);
expect(findBadge(container)).to.have.text('1');
});
});
describe('prop: showZero', () => {
it('should default to false', () => {
const { container } = render(<Badge {...defaultProps} badgeContent={0} />);
expect(findBadge(container)).to.have.class(classes.invisible);
});
it('should render without the invisible class when false and badgeContent is not 0', () => {
const { container } = render(<Badge {...defaultProps} showZero />);
expect(findBadge(container)).not.to.have.class(classes.invisible);
});
it('should render without the invisible class when true and badgeContent is 0', () => {
const { container } = render(<Badge {...defaultProps} badgeContent={0} showZero />);
expect(findBadge(container)).not.to.have.class(classes.invisible);
});
it('should render with the invisible class when false and badgeContent is 0', () => {
const { container } = render(<Badge {...defaultProps} badgeContent={0} showZero={false} />);
expect(findBadge(container)).to.have.class(classes.invisible);
});
});
describe('prop: color', () => {
it('adds a neutral class by default', () => {
const { container } = render(<Badge {...defaultProps} />);
expect(findBadge(container)).to.have.class(classes.colorPrimary);
});
(['primary', 'success', 'danger', 'neutral', 'warning'] as const).forEach((color) => {
it(`should render ${color}`, () => {
const { container } = render(<Badge color={color} {...defaultProps} />);
expect(findBadge(container)).to.have.class(
classes[`color${capitalize(color)}` as BadgeClassKey],
);
});
});
});
describe('prop: size', () => {
it('adds a sm class by default', () => {
const { container } = render(<Badge {...defaultProps} />);
expect(findBadge(container)).to.have.class(classes.sizeMd);
});
(['sm', 'md', 'lg'] as const).forEach((size) => {
it(`should render ${size}`, () => {
const { container } = render(<Badge size={size} {...defaultProps} />);
expect(findBadge(container)).to.have.class(
classes[`size${capitalize(size)}` as BadgeClassKey],
);
});
});
});
describe('prop: variant', () => {
it('adds a light class by default', () => {
const { container } = render(<Badge {...defaultProps} />);
expect(findBadge(container)).to.have.class(classes.variantSolid);
});
(['outlined', 'soft', 'solid'] as const).forEach((variant) => {
it(`should render ${variant}`, () => {
const { container } = render(<Badge variant={variant} {...defaultProps} />);
expect(findBadge(container)).to.have.class(
classes[`variant${capitalize(variant)}` as BadgeClassKey],
);
});
});
});
describe('prop: max', () => {
it('should default to 99', () => {
const { container } = render(<Badge {...defaultProps} badgeContent={100} />);
expect(findBadge(container)).to.have.text('99+');
});
it('should cap badgeContent', () => {
const { container } = render(<Badge {...defaultProps} badgeContent={1000} max={999} />);
expect(findBadge(container)).to.have.text('999+');
});
it('should not cap if badgeContent and max are equal', () => {
const { container } = render(<Badge {...defaultProps} badgeContent={1000} max={1000} />);
expect(findBadge(container)).to.have.text('1000');
});
it('should not cap if badgeContent is lower than max', () => {
const { container } = render(<Badge {...defaultProps} badgeContent={50} max={1000} />);
expect(findBadge(container)).to.have.text('50');
});
});
describe('prop: anchorOrigin', () => {
it('topRight by default', () => {
const { container } = render(
<Badge {...defaultProps} anchorOrigin={{ horizontal: 'right', vertical: 'top' }} />,
);
expect(findBadge(container)).to.have.class(classes.anchorOriginTopRight);
});
(
[
{ horizontal: 'left', vertical: 'top' },
{ horizontal: 'left', vertical: 'bottom' },
{ horizontal: 'right', vertical: 'top' },
{ horizontal: 'right', vertical: 'bottom' },
] as BadgeOrigin[]
).forEach((anchorOrigin) => {
it(`should render ${anchorOrigin}`, () => {
const { container } = render(<Badge {...defaultProps} anchorOrigin={anchorOrigin} />);
expect(findBadge(container)).to.have.class(
classes[
`anchorOrigin${capitalize(anchorOrigin.vertical)}${capitalize(
anchorOrigin.horizontal,
)}` as BadgeClassKey
],
);
});
});
});
});

View File

@@ -0,0 +1,335 @@
'use client';
import * as React from 'react';
import PropTypes from 'prop-types';
import { OverridableComponent } from '@mui/types';
import capitalize from '@mui/utils/capitalize';
import usePreviousProps from '@mui/utils/usePreviousProps';
import { unstable_composeClasses as composeClasses } from '@mui/base';
import styled from '../styles/styled';
import useThemeProps from '../styles/useThemeProps';
import useSlot from '../utils/useSlot';
import badgeClasses, { getBadgeUtilityClass } from './badgeClasses';
import { BadgeProps, BadgeOwnerState, BadgeTypeMap } from './BadgeProps';
const useUtilityClasses = (ownerState: BadgeOwnerState) => {
const { color, variant, size, anchorOrigin, invisible } = ownerState;
const slots = {
root: ['root'],
badge: [
'badge',
invisible && 'invisible',
anchorOrigin &&
`anchorOrigin${capitalize(anchorOrigin.vertical)}${capitalize(anchorOrigin.horizontal)}`,
variant && `variant${capitalize(variant)}`,
color && `color${capitalize(color)}`,
size && `size${capitalize(size)}`,
],
};
return composeClasses(slots, getBadgeUtilityClass, {});
};
const BadgeRoot = styled('span', {
name: 'JoyBadge',
slot: 'Root',
overridesResolver: (props, styles) => styles.root,
})<{ ownerState: BadgeOwnerState }>(({ theme, ownerState }) => ({
...(ownerState.size === 'sm' && {
'--Badge-minHeight': '0.5rem',
...(ownerState.badgeContent && {
'--Badge-minHeight': '1rem',
}),
'--Badge-paddingX': '0.25rem',
}),
...(ownerState.size === 'md' && {
'--Badge-minHeight': '0.75rem',
...(ownerState.badgeContent && {
'--Badge-minHeight': '1.25rem',
}),
'--Badge-paddingX': '0.375rem',
}),
...(ownerState.size === 'lg' && {
'--Badge-minHeight': '1rem',
...(ownerState.badgeContent && {
'--Badge-minHeight': '1.5rem',
}),
'--Badge-paddingX': '0.5rem',
}),
'--Badge-ringSize': '2px',
'--Badge-ring': `0 0 0 var(--Badge-ringSize) var(--Badge-ringColor, ${theme.vars.palette.background.surface})`,
position: 'relative',
display: 'inline-flex',
// For correct alignment with the text.
verticalAlign: 'middle',
flexShrink: 0,
}));
const BadgeBadge = styled('span', {
name: 'JoyBadge',
slot: 'Badge',
overridesResolver: (props, styles) => styles.badge,
})<{ ownerState: BadgeOwnerState }>(({ theme, ownerState }) => {
const inset = {
top: ownerState.badgeInset,
left: ownerState.badgeInset,
bottom: ownerState.badgeInset,
right: ownerState.badgeInset,
};
if (typeof ownerState.badgeInset === 'string') {
const insetValues = ownerState.badgeInset.split(' ');
if (insetValues.length > 1) {
inset.top = insetValues[0];
inset.right = insetValues[1];
if (insetValues.length === 2) {
inset.bottom = insetValues[0];
inset.left = insetValues[1];
}
if (insetValues.length === 3) {
inset.left = insetValues[1];
inset.bottom = insetValues[2];
}
if (insetValues.length === 4) {
inset.bottom = insetValues[2];
inset.left = insetValues[3];
}
}
}
const translateY =
ownerState.anchorOrigin?.vertical === 'top' ? 'translateY(-50%)' : 'translateY(50%)';
const translateX =
ownerState.anchorOrigin?.horizontal === 'left' ? 'translateX(-50%)' : 'translateX(50%)';
const transformOriginY = ownerState.anchorOrigin?.vertical === 'top' ? '0%' : '100%';
const transformOriginX = ownerState.anchorOrigin?.horizontal === 'left' ? '0%' : '100%';
const typography =
theme.typography[`body-${({ sm: 'xs', md: 'sm', lg: 'md' } as const)[ownerState.size!]}`];
return {
'--Icon-color': 'currentColor',
'--Icon-fontSize': `calc(1em * ${typography?.lineHeight ?? '1'})`,
display: 'inline-flex',
flexWrap: 'wrap',
justifyContent: 'center',
alignContent: 'center',
alignItems: 'center',
position: 'absolute',
boxSizing: 'border-box',
boxShadow: 'var(--Badge-ring)',
lineHeight: 1,
padding: '0 calc(var(--Badge-paddingX) - var(--variant-borderWidth, 0px))',
minHeight: 'var(--Badge-minHeight)',
minWidth: 'var(--Badge-minHeight)',
borderRadius: 'var(--Badge-radius, var(--Badge-minHeight))',
zIndex: theme.vars.zIndex.badge,
backgroundColor: theme.vars.palette.background.surface,
[ownerState.anchorOrigin!.vertical]: inset[ownerState.anchorOrigin!.vertical],
[ownerState.anchorOrigin!.horizontal]: inset[ownerState.anchorOrigin!.horizontal],
transform: `scale(1) ${translateX} ${translateY}`,
transformOrigin: `${transformOriginX} ${transformOriginY}`,
[`&.${badgeClasses.invisible}`]: {
transform: `scale(0) ${translateX} ${translateY}`,
},
...typography,
fontWeight: theme.vars.fontWeight.md,
...theme.variants[ownerState.variant!]?.[ownerState.color!],
};
});
/**
*
* Demos:
*
* - [Badge](https://mui.com/joy-ui/react-badge/)
*
* API:
*
* - [Badge API](https://mui.com/joy-ui/api/badge/)
*/
const Badge = React.forwardRef(function Badge(inProps, ref) {
const props = useThemeProps<typeof inProps & BadgeProps>({ props: inProps, name: 'JoyBadge' });
const {
anchorOrigin: anchorOriginProp = {
vertical: 'top',
horizontal: 'right',
},
badgeInset: badgeInsetProp = 0,
children,
size: sizeProp = 'md',
color: colorProp = 'primary',
invisible: invisibleProp = false,
max = 99,
badgeContent: badgeContentProp = '',
showZero = false,
variant: variantProp = 'solid',
component,
slots = {},
slotProps = {},
...other
} = props;
const prevProps = usePreviousProps({
anchorOrigin: anchorOriginProp,
size: sizeProp,
badgeInset: badgeInsetProp,
color: colorProp,
variant: variantProp,
});
let invisible = invisibleProp;
if (
invisibleProp === false &&
((badgeContentProp === 0 && !showZero) || badgeContentProp == null)
) {
invisible = true;
}
const {
color = colorProp,
size = sizeProp,
anchorOrigin = anchorOriginProp,
variant = variantProp,
badgeInset = badgeInsetProp,
} = invisible ? prevProps : props;
const ownerState = {
...props,
anchorOrigin,
badgeInset,
variant,
invisible,
color,
size,
};
const classes = useUtilityClasses(ownerState);
const externalForwardedProps = { ...other, component, slots, slotProps };
let displayValue =
badgeContentProp && Number(badgeContentProp) > max ? `${max}+` : badgeContentProp;
if (invisible && badgeContentProp === 0) {
displayValue = '';
}
const [SlotRoot, rootProps] = useSlot('root', {
ref,
className: classes.root,
elementType: BadgeRoot,
externalForwardedProps,
ownerState,
});
const [SlotBadge, badgeProps] = useSlot('badge', {
className: classes.badge,
elementType: BadgeBadge,
externalForwardedProps,
ownerState,
});
return (
<SlotRoot {...rootProps}>
{children}
<SlotBadge {...badgeProps}>{displayValue}</SlotBadge>
</SlotRoot>
);
}) as OverridableComponent<BadgeTypeMap>;
Badge.propTypes /* remove-proptypes */ = {
// ┌────────────────────────────── Warning ──────────────────────────────┐
// │ These PropTypes are generated from the TypeScript type definitions. │
// │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
// └─────────────────────────────────────────────────────────────────────┘
/**
* The anchor of the badge.
* @default {
* vertical: 'top',
* horizontal: 'right',
* }
*/
anchorOrigin: PropTypes.shape({
horizontal: PropTypes.oneOf(['left', 'right']).isRequired,
vertical: PropTypes.oneOf(['bottom', 'top']).isRequired,
}),
/**
* The content rendered within the badge.
* @default ''
*/
badgeContent: PropTypes.node,
/**
* The inset of the badge. Support shorthand syntax as described in https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/inset.
* @default 0
*/
badgeInset: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
/**
* The badge will be added relative to this node.
*/
children: PropTypes.node,
/**
* The color of the component. It supports those theme colors that make sense for this component.
* @default 'primary'
*/
color: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([
PropTypes.oneOf(['danger', 'neutral', 'primary', 'success', 'warning']),
PropTypes.string,
]),
/**
* The component used for the root node.
* Either a string to use a HTML element or a component.
*/
component: PropTypes.elementType,
/**
* If `true`, the badge is invisible.
* @default false
*/
invisible: PropTypes.bool,
/**
* Max count to show.
* @default 99
*/
max: PropTypes.number,
/**
* Controls whether the badge is hidden when `badgeContent` is zero.
* @default false
*/
showZero: PropTypes.bool,
/**
* The size of the component.
* @default 'md'
*/
size: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([
PropTypes.oneOf(['sm', 'md', 'lg']),
PropTypes.string,
]),
/**
* The props used for each slot inside.
* @default {}
*/
slotProps: PropTypes.shape({
badge: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
root: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
}),
/**
* The components used for each slot inside.
* @default {}
*/
slots: PropTypes.shape({
badge: PropTypes.elementType,
root: PropTypes.elementType,
}),
/**
* The system prop that allows defining system overrides as well as additional CSS styles.
*/
sx: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.func, PropTypes.object, PropTypes.bool])),
PropTypes.func,
PropTypes.object,
]),
/**
* The [global variant](https://mui.com/joy-ui/main-features/global-variants/) to use.
* @default 'solid'
*/
variant: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([
PropTypes.oneOf(['outlined', 'plain', 'soft', 'solid']),
PropTypes.string,
]),
} as any;
export default Badge;

View File

@@ -0,0 +1,108 @@
import * as React from 'react';
import { OverridableStringUnion, OverrideProps } from '@mui/types';
import { ColorPaletteProp, SxProps, VariantProp, ApplyColorInversion } from '../styles/types';
import { SlotProps, CreateSlotsAndSlotProps } from '../utils/types';
export type BadgeSlot = 'root' | 'badge';
export interface BadgeSlots {
/**
* The component that renders the root.
* @default 'span'
*/
root?: React.ElementType;
/**
* The component that renders the badge.
* @default 'span'
*/
badge?: React.ElementType;
}
export interface BadgePropsVariantOverrides {}
export interface BadgePropsColorOverrides {}
export interface BadgePropsSizeOverrides {}
export interface BadgeOrigin {
vertical: 'top' | 'bottom';
horizontal: 'left' | 'right';
}
export type BadgeSlotsAndSlotProps = CreateSlotsAndSlotProps<
BadgeSlots,
{
root: SlotProps<'div', {}, BadgeOwnerState>;
badge: SlotProps<'div', {}, BadgeOwnerState>;
}
>;
export interface BadgeTypeMap<D extends React.ElementType = 'span', P = {}> {
props: P &
BadgeSlotsAndSlotProps & {
/**
* The anchor of the badge.
* @default {
* vertical: 'top',
* horizontal: 'right',
* }
*/
anchorOrigin?: BadgeOrigin;
/**
* The content rendered within the badge.
* @default ''
*/
badgeContent?: React.ReactNode;
/**
* The inset of the badge. Support shorthand syntax as described in https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/inset.
* @default 0
*/
badgeInset?: number | string;
/**
* The badge will be added relative to this node.
*/
children?: React.ReactNode;
/**
* The color of the component. It supports those theme colors that make sense for this component.
* @default 'primary'
*/
color?: OverridableStringUnion<ColorPaletteProp, BadgePropsColorOverrides>;
/**
* If `true`, the badge is invisible.
* @default false
*/
invisible?: boolean;
/**
* Max count to show.
* @default 99
*/
max?: number;
/**
* The size of the component.
* @default 'md'
*/
size?: OverridableStringUnion<'sm' | 'md' | 'lg', BadgePropsSizeOverrides>;
/**
* Controls whether the badge is hidden when `badgeContent` is zero.
* @default false
*/
showZero?: boolean;
/**
* The system prop that allows defining system overrides as well as additional CSS styles.
*/
sx?: SxProps;
/**
* The [global variant](https://mui.com/joy-ui/main-features/global-variants/) to use.
* @default 'solid'
*/
variant?: OverridableStringUnion<VariantProp, BadgePropsVariantOverrides>;
};
defaultComponent: D;
}
export type BadgeProps<
D extends React.ElementType = BadgeTypeMap['defaultComponent'],
P = { component?: React.ElementType },
> = OverrideProps<BadgeTypeMap<D, P>, D>;
export interface BadgeOwnerState extends ApplyColorInversion<BadgeProps> {}

View File

@@ -0,0 +1,81 @@
import { generateUtilityClass, generateUtilityClasses } from '../className';
export interface BadgeClasses {
/** Class name applied to the root element. */
root: string;
/** Class name applied to the badge `span` element. */
badge: string;
/** Class name applied to the badge `span` element if `anchorOrigin={{ 'top', 'right' }}`. */
anchorOriginTopRight: string;
/** Class name applied to the badge `span` element if `anchorOrigin={{ 'bottom', 'right' }}`. */
anchorOriginBottomRight: string;
/** Class name applied to the badge `span` element if `anchorOrigin={{ 'top', 'left' }}`. */
anchorOriginTopLeft: string;
/** Class name applied to the badge `span` element if `anchorOrigin={{ 'bottom', 'left' }}`. */
anchorOriginBottomLeft: string;
/** Class name applied to the badge `span` element if `color="primary"`. */
colorPrimary: string;
/** Class name applied to the badge `span` element if `color="danger"`. */
colorDanger: string;
/** Class name applied to the badge `span` element if `color="neutral"`. */
colorNeutral: string;
/** Class name applied to the badge `span` element if `color="success"`. */
colorSuccess: string;
/** Class name applied to the badge `span` element if `color="warning"`. */
colorWarning: string;
/** Class name applied to the root element when color inversion is triggered. */
colorContext: string;
/** State class applied to the badge `span` element if `invisible={true}`. */
invisible: string;
/** State class applied to the badge `span` element if `location="inside"`. */
locationInside: string;
/** State class applied to the badge `span` element if `location="outside"`. */
locationOutside: string;
/** Class name applied to the badge `span` element if `size="sm"`. */
sizeSm: string;
/** Class name applied to the badge `span` element if `size="md"`. */
sizeMd: string;
/** Class name applied to the badge `span` element if `size="lg"`. */
sizeLg: string;
/** Class name applied to the root element if `variant="plain"`. */
variantPlain: string;
/** Class name applied to the badge `span` element if `variant="outlined"`. */
variantOutlined: string;
/** Class name applied to the badge `span` element if `variant="soft"`. */
variantSoft: string;
/** Class name applied to the badge `span` element if `variant="solid"`. */
variantSolid: string;
}
export type BadgeClassKey = keyof BadgeClasses;
export function getBadgeUtilityClass(slot: string): string {
return generateUtilityClass('MuiBadge', slot);
}
const badgeClasses: BadgeClasses = generateUtilityClasses('MuiBadge', [
'root',
'badge',
'anchorOriginTopRight',
'anchorOriginBottomRight',
'anchorOriginTopLeft',
'anchorOriginBottomLeft',
'colorPrimary',
'colorDanger',
'colorNeutral',
'colorSuccess',
'colorWarning',
'colorContext',
'invisible',
'locationInside',
'locationOutside',
'sizeSm',
'sizeMd',
'sizeLg',
'variantPlain',
'variantOutlined',
'variantSoft',
'variantSolid',
]);
export default badgeClasses;

View File

@@ -0,0 +1,4 @@
export { default } from './Badge';
export * from './BadgeProps';
export { default as badgeClasses } from './badgeClasses';
export * from './badgeClasses';

View File

@@ -0,0 +1,29 @@
import Box from '@mui/joy/Box';
function ThemeValuesCanBeSpread() {
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }} />;
<Box
sx={(theme) => ({
...theme.typography['body-md'],
color: theme.palette.primary[500],
})}
/>;
<Box
sx={(theme) => ({
...theme.variants.outlined,
color: 'primary.500',
})}
/>;
<Box
sx={[
{ color: 'primary.textColor', typography: 'body-sm' },
(theme) => theme.variants.outlined,
(theme) => ({
'&:hover': theme.variants.outlinedHover,
}),
(theme) => ({
'&:disabled': theme.variants.outlinedDisabled,
}),
]}
/>;
}

View File

@@ -0,0 +1,188 @@
import { expect } from 'chai';
import { createRenderer, isJsdom } from '@mui/internal-test-utils';
import { ThemeProvider, CssVarsProvider, extendTheme, PalettePrimary } from '@mui/joy/styles';
import { unstable_ClassNameGenerator as ClassNameGenerator } from '@mui/joy/className';
import Box from '@mui/joy/Box';
import describeConformance from '../../test/describeConformance';
const isJSDOM = isJsdom();
describe('Joy <Box />', () => {
const { render } = createRenderer();
describeConformance(<Box />, () => ({
muiName: 'JoyBox',
classes: { root: ClassNameGenerator.generate('JoyBox') },
render,
ThemeProvider,
inheritComponent: 'div',
skip: [
'componentProp',
'componentsProp',
'rootClass',
'themeVariants',
'themeStyleOverrides',
'themeDefaultProps',
],
refInstanceof: window.HTMLDivElement,
}));
it.skipIf(isJSDOM)('respects theme from context', function test() {
const theme = extendTheme({
colorSchemes: {
light: {
palette: {
primary: {
['main' as keyof PalettePrimary]: 'rgb(255, 0, 0)',
},
},
},
},
});
const { container } = render(
<CssVarsProvider theme={theme}>
<Box color="primary.main" />
</CssVarsProvider>,
);
expect(container.firstChild).toHaveComputedStyle({
color: 'rgb(255, 0, 0)',
});
});
describe('ClassNameGenerator', () => {
afterEach(() => {
ClassNameGenerator.reset();
});
it('get custom className', () => {
const { container, rerender } = render(<Box />);
expect(container.firstChild).to.have.class('MuiBox-root');
ClassNameGenerator.configure((name) => name.replace('Mui', 'Company'));
rerender(<Box />);
expect(container.firstChild).to.have.class('CompanyBox-root');
});
});
describe('sx', () => {
const theme = extendTheme({
colorSchemes: {
light: {
palette: {
primary: {
500: 'rgb(0, 0, 255)',
},
},
},
},
radius: {
md: '77px',
},
shadow: {
md: 'rgb(0, 0, 0) 0px 0px 10px 0px',
},
});
it.skipIf(isJSDOM)('color', function test() {
const { container } = render(
<CssVarsProvider theme={theme}>
<Box sx={{ color: 'primary.500' }} />
</CssVarsProvider>,
);
expect(container.firstChild).toHaveComputedStyle({
color: 'rgb(0, 0, 255)',
});
});
it.skipIf(isJSDOM)('bgcolor', function test() {
const { container } = render(
<CssVarsProvider theme={theme}>
<Box sx={{ bgcolor: 'primary.500' }} />
</CssVarsProvider>,
);
expect(container.firstChild).toHaveComputedStyle({
backgroundColor: 'rgb(0, 0, 255)',
});
});
it.skipIf(isJSDOM)('backgroundColor', function test() {
const { container } = render(
<CssVarsProvider theme={theme}>
<Box sx={{ backgroundColor: 'primary.500' }} />
</CssVarsProvider>,
);
expect(container.firstChild).toHaveComputedStyle({
backgroundColor: 'rgb(0, 0, 255)',
});
});
it.skipIf(isJSDOM)('borderRadius', function test() {
const { container } = render(
<CssVarsProvider theme={theme}>
<Box sx={{ borderRadius: 'md' }} />
</CssVarsProvider>,
);
expect(container.firstChild).toHaveComputedStyle({
borderTopLeftRadius: '77px',
borderTopRightRadius: '77px',
borderBottomLeftRadius: '77px',
borderBottomRightRadius: '77px',
});
});
it.skipIf(isJSDOM)('boxShadow', function test() {
const { container } = render(
<CssVarsProvider theme={theme}>
<Box sx={{ boxShadow: 'md' }} />
</CssVarsProvider>,
);
expect(container.firstChild).toHaveComputedStyle({
boxShadow: 'rgb(0, 0, 0) 0px 0px 10px 0px',
});
});
it.skipIf(isJSDOM)('fontSize', function test() {
const { container } = render(
<CssVarsProvider theme={theme}>
<Box sx={{ fontSize: 'md' }} />
</CssVarsProvider>,
);
expect(container.firstChild).toHaveComputedStyle({
fontSize: '16px',
});
});
it.skipIf(isJSDOM)('fontWeight', function test() {
const { container } = render(
<CssVarsProvider theme={theme}>
<Box sx={{ fontWeight: 'md' }} />
</CssVarsProvider>,
);
expect(container.firstChild).toHaveComputedStyle({
fontWeight: '500',
});
});
it.skipIf(isJSDOM)('lineHeight', function test() {
const { container } = render(
<CssVarsProvider theme={theme}>
<Box sx={{ lineHeight: 'md' }} />
</CssVarsProvider>,
);
expect(container.firstChild).toHaveComputedStyle({
lineHeight: '24px',
});
});
});
});

View File

@@ -0,0 +1,50 @@
'use client';
import { createBox } from '@mui/system';
import PropTypes from 'prop-types';
import { unstable_ClassNameGenerator as ClassNameGenerator } from '../className';
import { Theme } from '../styles/types';
import defaultTheme from '../styles/defaultTheme';
import THEME_ID from '../styles/identifier';
import boxClasses from './boxClasses';
/**
*
* Demos:
*
* - [Box](https://mui.com/joy-ui/react-box/)
*
* API:
*
* - [Box API](https://mui.com/joy-ui/api/box/)
*/
const Box = createBox<Theme>({
themeId: THEME_ID,
defaultTheme,
defaultClassName: boxClasses.root,
generateClassName: ClassNameGenerator.generate,
});
Box.propTypes /* remove-proptypes */ = {
// ┌────────────────────────────── Warning ──────────────────────────────┐
// │ These PropTypes are generated from the TypeScript type definitions. │
// │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
// └─────────────────────────────────────────────────────────────────────┘
/**
* @ignore
*/
children: PropTypes.node,
/**
* The component used for the root node.
* Either a string to use a HTML element or a component.
*/
component: PropTypes.elementType,
/**
* The system prop that allows defining system overrides as well as additional CSS styles.
*/
sx: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.func, PropTypes.object, PropTypes.bool])),
PropTypes.func,
PropTypes.object,
]),
} as any;
export default Box;

View File

@@ -0,0 +1,12 @@
import { OverrideProps } from '@mui/types';
import { BoxTypeMap } from '@mui/system';
import { Theme } from '../styles/types';
export type BoxSlot = 'root';
export type BoxProps<
D extends React.ElementType = BoxTypeMap['defaultComponent'],
P = {},
> = OverrideProps<BoxTypeMap<P, D, Theme>, D>;
export interface BoxOwnerState extends BoxProps {}

View File

@@ -0,0 +1,12 @@
import generateUtilityClasses from '@mui/utils/generateUtilityClasses';
export interface BoxClasses {
/** Class name applied to the root element. */
root: string;
}
export type BoxClassKey = keyof BoxClasses;
const boxClasses: BoxClasses = generateUtilityClasses('MuiBox', ['root']);
export default boxClasses;

View File

@@ -0,0 +1,5 @@
export { default } from './Box';
export * from './BoxProps';
export { default as boxClasses } from './boxClasses';
export * from './boxClasses';

View File

@@ -0,0 +1,73 @@
import { expectType } from '@mui/types';
import Breadcrumbs, { BreadcrumbsOwnerState } from '@mui/joy/Breadcrumbs';
<Breadcrumbs />;
<Breadcrumbs component="div" />;
// `size`
<Breadcrumbs size="sm" />;
<Breadcrumbs size="md" />;
<Breadcrumbs size="lg" />;
// @ts-expect-error there is no size `xs`
<Breadcrumbs size="xs" />;
// @ts-expect-error there is no size `xl`
<Breadcrumbs size="xl" />;
<Breadcrumbs
slots={{
root: 'div',
ol: 'div',
li: 'div',
separator: 'div',
}}
/>;
<Breadcrumbs
slotProps={{
root: {
component: 'div',
'data-testid': 'test',
},
ol: {
component: 'div',
'data-testid': 'test',
},
li: {
component: 'div',
},
separator: {
'data-testid': 'test',
},
}}
/>;
<Breadcrumbs
slotProps={{
root: (ownerState) => {
expectType<BreadcrumbsOwnerState, typeof ownerState>(ownerState);
return {
'data-testid': 'test',
};
},
ol: (ownerState) => {
expectType<BreadcrumbsOwnerState, typeof ownerState>(ownerState);
return {
'data-testid': 'test',
};
},
li: (ownerState) => {
expectType<BreadcrumbsOwnerState, typeof ownerState>(ownerState);
return {
'data-testid': 'test',
};
},
separator: (ownerState) => {
expectType<BreadcrumbsOwnerState, typeof ownerState>(ownerState);
return {
'data-testid': 'test',
};
},
}}
/>;

View File

@@ -0,0 +1,46 @@
import { expect } from 'chai';
import { createRenderer, screen } from '@mui/internal-test-utils';
import capitalize from '@mui/utils/capitalize';
import { ThemeProvider } from '@mui/joy/styles';
import Breadcrumbs, {
BreadcrumbsClassKey,
breadcrumbsClasses as classes,
} from '@mui/joy/Breadcrumbs';
import describeConformance from '../../test/describeConformance';
describe('<Breadcrumbs />', () => {
const { render } = createRenderer();
describeConformance(<Breadcrumbs />, () => ({
classes,
inheritComponent: 'nav',
render,
ThemeProvider,
muiName: 'JoyBreadcrumbs',
refInstanceof: window.HTMLElement,
testVariantProps: { variant: 'solid' },
testCustomVariant: true,
slots: {
root: { expectedClassName: classes.root },
ol: { expectedClassName: classes.ol },
},
skip: ['classesRoot', 'componentsProp'],
}));
describe('prop: size', () => {
it('md by default', () => {
render(<Breadcrumbs />);
expect(screen.getByRole('navigation')).to.have.class(classes.sizeMd);
});
(['sm', 'md', 'lg'] as const).forEach((size) => {
it(`should render ${size}`, () => {
render(<Breadcrumbs size={size} />);
expect(screen.getByRole('navigation')).to.have.class(
classes[`size${capitalize(size)}` as BreadcrumbsClassKey],
);
});
});
});
});

View File

@@ -0,0 +1,249 @@
'use client';
import * as React from 'react';
import PropTypes from 'prop-types';
import { OverridableComponent } from '@mui/types';
import capitalize from '@mui/utils/capitalize';
import isMuiElement from '@mui/utils/isMuiElement';
import { unstable_composeClasses as composeClasses } from '@mui/base';
import clsx from 'clsx';
import { useThemeProps } from '../styles';
import useSlot from '../utils/useSlot';
import styled from '../styles/styled';
import { getBreadcrumbsUtilityClass } from './breadcrumbsClasses';
import { BreadcrumbsProps, BreadcrumbsOwnerState, BreadcrumbsTypeMap } from './BreadcrumbsProps';
import { TypographyInheritContext } from '../Typography/Typography';
const useUtilityClasses = (ownerState: BreadcrumbsOwnerState) => {
const { size } = ownerState;
const slots = {
root: ['root', size && `size${capitalize(size)}`],
li: ['li'],
ol: ['ol'],
separator: ['separator'],
};
return composeClasses(slots, getBreadcrumbsUtilityClass, {});
};
const BreadcrumbsRoot = styled('nav', {
name: 'JoyBreadcrumbs',
slot: 'Root',
overridesResolver: (props, styles) => styles.root,
})<{ ownerState: BreadcrumbsOwnerState }>(({ theme, ownerState }) => ({
...(ownerState.size === 'sm' && {
'--Icon-fontSize': theme.vars.fontSize.lg,
gap: 'var(--Breadcrumbs-gap, 0.25rem)',
padding: '0.5rem',
}),
...(ownerState.size === 'md' && {
'--Icon-fontSize': theme.vars.fontSize.xl,
gap: 'var(--Breadcrumbs-gap, 0.375rem)',
padding: '0.75rem',
}),
...(ownerState.size === 'lg' && {
'--Icon-fontSize': theme.vars.fontSize.xl2,
gap: 'var(--Breadcrumbs-gap, 0.5rem)',
padding: '1rem',
}),
...theme.typography[`body-${ownerState.size!}`],
}));
const BreadcrumbsOl = styled('ol', {
name: 'JoyBreadcrumbs',
slot: 'Ol',
overridesResolver: (props, styles) => styles.ol,
})<{ ownerState: BreadcrumbsOwnerState }>({
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
gap: 'inherit',
// reset user-agent style
padding: 0,
margin: 0,
listStyle: 'none',
});
const BreadcrumbsLi = styled('li', {
name: 'JoyBreadcrumbs',
slot: 'Li',
overridesResolver: (props, styles) => styles.li,
})<{ ownerState: BreadcrumbsOwnerState }>({
display: 'flex',
alignItems: 'center',
});
const BreadcrumbsSeparator = styled('li', {
name: 'JoyBreadcrumbs',
slot: 'Separator',
overridesResolver: (props, styles) => styles.separator,
})<{ ownerState: BreadcrumbsOwnerState }>({
display: 'flex',
userSelect: 'none',
});
/**
*
* Demos:
*
* - [Breadcrumbs](https://mui.com/joy-ui/react-breadcrumbs/)
*
* API:
*
* - [Breadcrumbs API](https://mui.com/joy-ui/api/breadcrumbs/)
*/
const Breadcrumbs = React.forwardRef(function Breadcrumbs(inProps, ref) {
const props = useThemeProps<typeof inProps & BreadcrumbsProps>({
props: inProps,
name: 'JoyBreadcrumbs',
});
const {
children,
className,
size = 'md',
separator = '/',
component,
slots = {},
slotProps = {},
...other
} = props;
const ownerState = {
...props,
separator,
size,
};
const classes = useUtilityClasses(ownerState);
const externalForwardedProps = { ...other, component, slots, slotProps };
const [SlotRoot, rootProps] = useSlot('root', {
ref,
className: clsx(classes.root, className),
elementType: BreadcrumbsRoot,
externalForwardedProps,
ownerState,
});
const [SlotOl, olProps] = useSlot('ol', {
className: classes.ol,
elementType: BreadcrumbsOl,
externalForwardedProps,
ownerState,
});
const [SlotLi, liProps] = useSlot('li', {
className: classes.li,
elementType: BreadcrumbsLi,
externalForwardedProps,
ownerState,
});
const [SlotSeparator, separatorProps] = useSlot('separator', {
additionalProps: {
'aria-hidden': true,
},
className: classes.separator,
elementType: BreadcrumbsSeparator,
externalForwardedProps,
ownerState,
});
const allItems = (
React.Children.toArray(children).filter((child) => {
return React.isValidElement(child);
}) as Array<React.ReactElement<any>>
).map((child, index) => (
<SlotLi key={`child-${index}`} {...liProps}>
{isMuiElement(child, ['Typography'])
? React.cloneElement(child, { component: child.props.component ?? 'span' })
: child}
</SlotLi>
));
return (
<TypographyInheritContext.Provider value>
<SlotRoot {...rootProps}>
<SlotOl {...olProps}>
{allItems.reduce((acc: React.ReactNode[], current: React.ReactNode, index: number) => {
if (index < allItems.length - 1) {
acc = acc.concat(
current,
<SlotSeparator key={`separator-${index}`} {...separatorProps}>
{separator}
</SlotSeparator>,
);
} else {
acc.push(current);
}
return acc;
}, [])}
</SlotOl>
</SlotRoot>
</TypographyInheritContext.Provider>
);
}) as OverridableComponent<BreadcrumbsTypeMap>;
Breadcrumbs.propTypes /* remove-proptypes */ = {
// ┌────────────────────────────── Warning ──────────────────────────────┐
// │ These PropTypes are generated from the TypeScript type definitions. │
// │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
// └─────────────────────────────────────────────────────────────────────┘
/**
* The content of the component.
*/
children: PropTypes.node,
/**
* @ignore
*/
className: PropTypes.string,
/**
* The component used for the root node.
* Either a string to use a HTML element or a component.
*/
component: PropTypes.elementType,
/**
* Custom separator node.
* @default '/'
*/
separator: PropTypes.node,
/**
* The size of the component.
* It accepts theme values between 'sm' and 'lg'.
* @default 'md'
*/
size: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([
PropTypes.oneOf(['sm', 'md', 'lg']),
PropTypes.string,
]),
/**
* The props used for each slot inside.
* @default {}
*/
slotProps: PropTypes.shape({
li: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
ol: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
root: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
separator: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
}),
/**
* The components used for each slot inside.
* @default {}
*/
slots: PropTypes.shape({
li: PropTypes.elementType,
ol: PropTypes.elementType,
root: PropTypes.elementType,
separator: PropTypes.elementType,
}),
/**
* The system prop that allows defining system overrides as well as additional CSS styles.
*/
sx: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.func, PropTypes.object, PropTypes.bool])),
PropTypes.func,
PropTypes.object,
]),
} as any;
export default Breadcrumbs;

View File

@@ -0,0 +1,74 @@
import * as React from 'react';
import { OverridableStringUnion, OverrideProps } from '@mui/types';
import { SxProps } from '../styles/types';
import { CreateSlotsAndSlotProps, SlotProps } from '../utils/types';
export type BreadcrumbsSlot = 'root' | 'ol' | 'li' | 'separator';
export interface BreadcrumbsSlots {
/**
* The component that renders the root.
* @default 'nav'
*/
root?: React.ElementType;
/**
* The component that renders the ol.
* @default 'ol'
*/
ol?: React.ElementType;
/**
* The component that renders the li.
* @default 'li'
*/
li?: React.ElementType;
/**
* The component that renders the separator.
* @default 'li'
*/
separator?: React.ElementType;
}
export interface BreadcrumbsPropsSizeOverrides {}
export type BreadcrumbsSlotsAndSlotProps = CreateSlotsAndSlotProps<
BreadcrumbsSlots,
{
root: SlotProps<'nav', {}, BreadcrumbsOwnerState>;
ol: SlotProps<'ol', {}, BreadcrumbsOwnerState>;
li: SlotProps<'li', {}, BreadcrumbsOwnerState>;
separator: SlotProps<'li', {}, BreadcrumbsOwnerState>;
}
>;
export interface BreadcrumbsTypeMap<P = {}, D extends React.ElementType = 'nav'> {
props: P &
BreadcrumbsSlotsAndSlotProps & {
/**
* The content of the component.
*/
children?: React.ReactNode;
/**
* Custom separator node.
* @default '/'
*/
separator?: React.ReactNode;
/**
* The size of the component.
* It accepts theme values between 'sm' and 'lg'.
* @default 'md'
*/
size?: OverridableStringUnion<'sm' | 'md' | 'lg', BreadcrumbsPropsSizeOverrides>;
/**
* The system prop that allows defining system overrides as well as additional CSS styles.
*/
sx?: SxProps;
};
defaultComponent: D;
}
export type BreadcrumbsProps<
D extends React.ElementType = BreadcrumbsTypeMap['defaultComponent'],
P = { component?: React.ElementType },
> = OverrideProps<BreadcrumbsTypeMap<P, D>, D>;
export interface BreadcrumbsOwnerState extends BreadcrumbsProps {}

View File

@@ -0,0 +1,37 @@
import generateUtilityClass from '@mui/utils/generateUtilityClass';
import generateUtilityClasses from '@mui/utils/generateUtilityClasses';
export interface BreadcrumbsClasses {
/** Class name applied to the root element. */
root: string;
/** Class name applied to the ol element. */
ol: string;
/** Class name applied to the li element. */
li: string;
/** Class name applied to the separator element. */
separator: string;
/** Class name applied to the root element if `size="sm"`. */
sizeSm: string;
/** Class name applied to the root element if `size="md"`. */
sizeMd: string;
/** Class name applied to the root element if `size="lg"`. */
sizeLg: string;
}
export type BreadcrumbsClassKey = keyof BreadcrumbsClasses;
export function getBreadcrumbsUtilityClass(slot: string): string {
return generateUtilityClass('MuiBreadcrumbs', slot);
}
const breadcrumbsClasses: BreadcrumbsClasses = generateUtilityClasses('MuiBreadcrumbs', [
'root',
'ol',
'li',
'separator',
'sizeSm',
'sizeMd',
'sizeLg',
]);
export default breadcrumbsClasses;

View File

@@ -0,0 +1,4 @@
export { default } from './Breadcrumbs';
export * from './breadcrumbsClasses';
export { default as breadcrumbsClasses } from './breadcrumbsClasses';
export * from './BreadcrumbsProps';

View File

@@ -0,0 +1,158 @@
import * as React from 'react';
import NextLink from 'next/link';
import JoyLink from '@mui/material/Link';
import { expectType } from '@mui/types';
import Button, { ButtonOwnerState } from '@mui/joy/Button';
<Button>Button</Button>;
function handleClick(event: React.MouseEvent) {}
<Button onClick={handleClick}>Button</Button>;
function handleClick2(event: React.MouseEvent<HTMLAnchorElement>) {}
<Button onClick={handleClick2}>Button</Button>;
function handleClick3(event: React.MouseEvent<HTMLButtonElement>) {}
<Button onClick={handleClick3}>Button</Button>;
function handleClick4(event: React.MouseEvent<HTMLDivElement>) {}
// @ts-expect-error should be HTMLAnchorElement | HTMLButtonElement
<Button onClick={handleClick4}>Button</Button>;
<Button variant="plain">Button</Button>;
<Button variant="outlined">Button</Button>;
<Button variant="soft">Button</Button>;
<Button variant="solid">Button</Button>;
// @ts-expect-error no `custom` variant
<Button variant="custom">Button</Button>;
<Button color="primary">Button</Button>;
<Button color="neutral">Button</Button>;
<Button color="danger">Button</Button>;
<Button color="success">Button</Button>;
<Button color="warning">Button</Button>;
// @ts-expect-error no `black` color
<Button color="black">Button</Button>;
<Button size="sm">Button</Button>;
<Button size="lg">Button</Button>;
// @ts-expect-error no `super` size
<Button size="super">Button</Button>;
<Button component="a" href="/" />;
<Button component={NextLink} href="/" />;
<Button component={JoyLink} href="/" />;
function CustomLink({
children,
to,
...props
}: React.PropsWithChildren<{ to: string } & Omit<React.JSX.IntrinsicElements['a'], 'href'>>) {
return (
<a href={to} {...props}>
{children}
</a>
);
}
// @ts-expect-error missing `to`
<Button component={CustomLink} />;
// @ts-expect-error href is not allowed
<Button component={CustomLink} to="/" href="/" />;
<Button sx={{ borderRadius: 0 }}>Button</Button>;
function Icon() {
return null;
}
<Button sx={{ width: 'var(--Button-minHeight)' }}>
<Icon />
</Button>;
<Button
variant="solid"
color="success"
endDecorator={<Icon />}
sx={{ width: 'var(--Button-minHeight)' }}
>
<Icon />
</Button>;
<Button variant="solid" startDecorator={<Icon />} size="sm">
Add to cart
</Button>;
<Button variant="outlined" endDecorator={<Icon />} color="success">
Checkout
</Button>;
<Button loading variant="outlined" disabled>
disabled
</Button>;
<Button loading loadingIndicator="Loading…" variant="outlined">
Fetch data
</Button>;
<Button endDecorator={<Icon />} loading loadingPosition="end">
Send
</Button>;
<Button loading loadingPosition="start" startDecorator={<Icon />}>
Save
</Button>;
<Button
slots={{
root: 'div',
startDecorator: 'div',
endDecorator: 'div',
loadingIndicatorCenter: 'div',
}}
/>;
<Button
slotProps={{
root: {
component: 'div',
'data-testid': 'test',
},
startDecorator: {
component: 'div',
'data-testid': 'test',
},
endDecorator: {
component: 'div',
'data-testid': 'test',
},
loadingIndicatorCenter: {
component: 'div',
'data-testid': 'test',
},
}}
/>;
<Button
slotProps={{
root: (ownerState) => {
expectType<ButtonOwnerState, typeof ownerState>(ownerState);
return {
'data-testid': 'test',
};
},
startDecorator: (ownerState) => {
expectType<ButtonOwnerState, typeof ownerState>(ownerState);
return {
'data-testid': 'test',
};
},
endDecorator: (ownerState) => {
expectType<ButtonOwnerState, typeof ownerState>(ownerState);
return {
'data-testid': 'test',
};
},
loadingIndicatorCenter: (ownerState) => {
expectType<ButtonOwnerState, typeof ownerState>(ownerState);
return {
'data-testid': 'test',
};
},
}}
/>;

View File

@@ -0,0 +1,242 @@
import { expect } from 'chai';
import { createRenderer, screen, isJsdom } from '@mui/internal-test-utils';
import Button, { buttonClasses as classes } from '@mui/joy/Button';
import { ThemeProvider } from '@mui/joy/styles';
import describeConformance from '../../test/describeConformance';
describe('Joy <Button />', () => {
const { render } = createRenderer();
describeConformance(
<Button startDecorator="icon" endDecorator="icon">
Conformance?
</Button>,
() => ({
render,
classes,
ThemeProvider,
refInstanceof: window.HTMLButtonElement,
muiName: 'JoyButton',
testDeepOverrides: { slotName: 'startDecorator', slotClassName: classes.startDecorator },
testVariantProps: { variant: 'solid', fullWidth: true },
testCustomVariant: true,
slots: {
root: { expectedClassName: classes.root },
startDecorator: { expectedClassName: classes.startDecorator },
endDecorator: { expectedClassName: classes.endDecorator },
},
skip: ['propsSpread', 'componentsProp', 'classesRoot'],
}),
);
it('by default, should render with the root, variantSolid, sizeMd and colorPrimary classes', () => {
render(<Button>Hello World</Button>);
const button = screen.getByRole('button');
expect(button).to.have.class(classes.root);
expect(button).to.have.class(classes.variantSolid);
expect(button).to.have.class(classes.sizeMd);
expect(button).to.have.class(classes.colorPrimary);
// should not have other variant classes
expect(button).not.to.have.class(classes.variantOutlined);
expect(button).not.to.have.class(classes.variantPlain);
expect(button).not.to.have.class(classes.variantSoft);
});
it('should render an outlined button', () => {
render(<Button variant="outlined">Hello World</Button>);
const button = screen.getByRole('button');
expect(button).to.have.class(classes.variantOutlined);
});
it('should render a light button', () => {
render(<Button variant="soft">Hello World</Button>);
const button = screen.getByRole('button');
expect(button).to.have.class(classes.variantSoft);
});
it('should render a contained button', () => {
render(<Button variant="solid">Hello World</Button>);
const button = screen.getByRole('button');
expect(button).to.have.class(classes.variantSolid);
});
it('should render a small button', () => {
render(<Button size="sm">Hello World</Button>);
const button = screen.getByRole('button');
expect(button).to.have.class(classes.sizeSm);
});
it('should render a large button', () => {
render(<Button size="lg">Hello World</Button>);
const button = screen.getByRole('button');
expect(button).to.have.class(classes.sizeLg);
});
it('should render a fullWidth button', () => {
render(<Button fullWidth>Hello World</Button>);
const button = screen.getByRole('button');
expect(button).to.have.class(classes.fullWidth);
});
it('should render a button with startDecorator', () => {
render(<Button startDecorator={<span>icon</span>}>Hello World</Button>);
const button = screen.getByRole('button');
const startDecorator = button.querySelector(`.${classes.startDecorator}`);
expect(button).to.have.class(classes.root);
expect(startDecorator).not.to.have.class(classes.endDecorator);
});
it('should render a button with endDecorator', () => {
render(<Button endDecorator={<span>icon</span>}>Hello World</Button>);
const button = screen.getByRole('button');
const endDecorator = button.querySelector(`.${classes.endDecorator}`);
expect(button).to.have.class(classes.root);
expect(endDecorator).not.to.have.class(classes.startDecorator);
});
describe('prop: loading', () => {
it('disables the button', () => {
render(<Button loading />);
const button = screen.getByRole('button');
expect(button).to.have.property('disabled', true);
});
it('should disable button when loading is true and disabled is false', () => {
render(<Button loading disabled={false} />);
const button = screen.getByRole('button');
expect(button).to.have.property('disabled', true);
});
it('should disable button when loading is false and disabled is true', () => {
render(<Button loading={false} disabled />);
const button = screen.getByRole('button');
expect(button).to.have.property('disabled', true);
});
it('renders a progressbar', () => {
render(<Button loading>Submit</Button>);
const progressbar = screen.getByRole('progressbar');
expect(progressbar).toBeVisible();
});
});
describe('prop:disabled', () => {
it.skipIf(isJsdom())('should apply disabled styles when button is disabled', function test() {
render(<Button disabled />);
expect(screen.getByRole('button')).toHaveComputedStyle({
color: 'rgb(159, 166, 173)',
backgroundColor: 'rgb(240, 244, 248)',
});
});
it.skipIf(isJsdom())(
'should apply disabled styles when button is disabled and when component prop is provided',
function test() {
render(<Button disabled component="a" />);
expect(screen.getByRole('button')).toHaveComputedStyle({
color: 'rgb(159, 166, 173)',
backgroundColor: 'rgb(240, 244, 248)',
});
},
);
});
describe('prop: loadingIndicator', () => {
it('is not rendered by default', () => {
render(<Button loadingIndicator="loading">Test</Button>);
expect(screen.getByRole('button')).to.have.text('Test');
});
it.skipIf(isJsdom())(
'is rendered properly when `loading` and children should not be visible',
function test() {
const { container } = render(
<Button loadingIndicator="loading.." loading>
Test
</Button>,
);
expect(container.querySelector(`.${classes.loadingIndicatorCenter}`)).to.have.text(
'loading..',
);
expect(screen.getByRole('button')).toHaveComputedStyle({ color: 'rgba(0, 0, 0, 0)' });
},
);
});
describe('prop: loadingPosition', () => {
it('center is rendered by default', () => {
render(<Button loading>Test</Button>);
const loader = screen.getByRole('progressbar');
expect(loader.parentElement).to.have.class(classes.loadingIndicatorCenter);
});
it('there should be only one loading indicator', () => {
render(
<Button loading startDecorator="🚀" endDecorator="👍">
Test
</Button>,
);
const loaders = screen.getAllByRole('progressbar');
expect(loaders).to.have.length(1);
});
it('loading indicator with `position="start"` replaces the `startDecorator` content', () => {
render(
<Button
loading
startDecorator={<span>icon</span>}
loadingPosition="start"
loadingIndicator={<span role="progressbar">loading..</span>}
>
Test
</Button>,
);
const loader = screen.getByRole('progressbar');
const button = screen.getByRole('button');
expect(loader).toBeVisible();
expect(button).to.have.text('loading..Test');
});
it('loading indicator with `position="end"` replaces the `startDecorator` content', () => {
render(
<Button
loading
endDecorator={<span>icon</span>}
loadingPosition="end"
loadingIndicator={<span role="progressbar">loading..</span>}
>
Test
</Button>,
);
const loader = screen.getByRole('progressbar');
const button = screen.getByRole('button');
expect(loader).toBeVisible();
expect(button).to.have.text('Testloading..');
});
});
});

View File

@@ -0,0 +1,494 @@
'use client';
import * as React from 'react';
import PropTypes from 'prop-types';
import { useButton } from '@mui/base/useButton';
import { unstable_composeClasses as composeClasses } from '@mui/base/composeClasses';
import { Interpolation } from '@mui/system';
import capitalize from '@mui/utils/capitalize';
import useForkRef from '@mui/utils/useForkRef';
import { styled, Theme, useThemeProps } from '../styles';
import useSlot from '../utils/useSlot';
import CircularProgress from '../CircularProgress';
import buttonClasses, { getButtonUtilityClass } from './buttonClasses';
import { ButtonOwnerState, ButtonTypeMap, ExtendButton } from './ButtonProps';
import ButtonGroupContext from '../ButtonGroup/ButtonGroupContext';
import ToggleButtonGroupContext from '../ToggleButtonGroup/ToggleButtonGroupContext';
const useUtilityClasses = (ownerState: ButtonOwnerState) => {
const {
color,
disabled,
focusVisible,
focusVisibleClassName,
fullWidth,
size,
variant,
loading,
} = ownerState;
const slots = {
root: [
'root',
disabled && 'disabled',
focusVisible && 'focusVisible',
fullWidth && 'fullWidth',
variant && `variant${capitalize(variant)}`,
color && `color${capitalize(color)}`,
size && `size${capitalize(size)}`,
loading && 'loading',
],
startDecorator: ['startDecorator'],
endDecorator: ['endDecorator'],
loadingIndicatorCenter: ['loadingIndicatorCenter'],
};
const composedClasses = composeClasses(slots, getButtonUtilityClass, {});
if (focusVisible && focusVisibleClassName) {
composedClasses.root += ` ${focusVisibleClassName}`;
}
return composedClasses;
};
const ButtonStartDecorator = styled('span', {
name: 'JoyButton',
slot: 'StartDecorator',
overridesResolver: (props, styles) => styles.startDecorator,
})<{ ownerState: ButtonOwnerState }>({
'--Icon-margin': '0 0 0 calc(var(--Button-gap) / -2)',
'--CircularProgress-margin': '0 0 0 calc(var(--Button-gap) / -2)',
display: 'inherit',
marginRight: 'var(--Button-gap)',
});
const ButtonEndDecorator = styled('span', {
name: 'JoyButton',
slot: 'EndDecorator',
overridesResolver: (props, styles) => styles.endDecorator,
})<{ ownerState: ButtonOwnerState }>({
'--Icon-margin': '0 calc(var(--Button-gap) / -2) 0 0',
'--CircularProgress-margin': '0 calc(var(--Button-gap) / -2) 0 0',
display: 'inherit',
marginLeft: 'var(--Button-gap)',
});
const ButtonLoadingCenter = styled('span', {
name: 'JoyButton',
slot: 'LoadingCenter',
overridesResolver: (props, styles) => styles.loadingIndicatorCenter,
})<{ ownerState: ButtonOwnerState }>(({ theme, ownerState }) => ({
display: 'inherit',
position: 'absolute',
left: '50%',
transform: 'translateX(-50%)',
color: theme.variants[ownerState.variant!]?.[ownerState.color!]?.color,
...(ownerState.disabled && {
color: theme.variants[`${ownerState.variant!}Disabled`]?.[ownerState.color!]?.color,
}),
}));
export const getButtonStyles = ({
theme,
ownerState,
}: {
theme: Theme;
ownerState: Partial<Omit<ButtonOwnerState, 'slots' | 'slotProps'>>;
}): Interpolation<any> => {
return [
{
'--Icon-margin': 'initial', // reset the icon's margin.
'--Icon-color':
ownerState.color !== 'neutral' || ownerState.variant === 'solid'
? 'currentColor'
: theme.vars.palette.text.icon,
...(ownerState.size === 'sm' && {
'--Icon-fontSize': theme.vars.fontSize.lg,
'--CircularProgress-size': '20px', // must be `px` unit, otherwise the CircularProgress is broken in Safari
'--CircularProgress-thickness': '2px',
'--Button-gap': '0.375rem',
minHeight: 'var(--Button-minHeight, 2rem)',
fontSize: theme.vars.fontSize.sm,
paddingBlock: 'var(--Button-paddingBlock, 0.25rem)',
paddingInline: '0.75rem',
}),
...(ownerState.size === 'md' && {
'--Icon-fontSize': theme.vars.fontSize.xl,
'--CircularProgress-size': '20px', // must be `px` unit, otherwise the CircularProgress is broken in Safari
'--CircularProgress-thickness': '2px',
'--Button-gap': '0.5rem',
minHeight: 'var(--Button-minHeight, 2.25rem)', // use min-height instead of height to make the button resilient to its content
fontSize: theme.vars.fontSize.sm,
// internal --Button-paddingBlock is used to control the padding-block of the button from the outside, for example as a decorator of an Input
paddingBlock: 'var(--Button-paddingBlock, 0.375rem)', // the padding-block act as a minimum spacing between content and root element
paddingInline: '1rem',
}),
...(ownerState.size === 'lg' && {
'--Icon-fontSize': theme.vars.fontSize.xl2,
'--CircularProgress-size': '28px', // must be `px` unit, otherwise the CircularProgress is broken in Safari
'--CircularProgress-thickness': '4px',
'--Button-gap': '0.75rem',
minHeight: 'var(--Button-minHeight, 2.75rem)',
fontSize: theme.vars.fontSize.md,
paddingBlock: 'var(--Button-paddingBlock, 0.5rem)',
paddingInline: '1.5rem',
}),
WebkitTapHighlightColor: 'transparent',
boxSizing: 'border-box',
borderRadius: `var(--Button-radius, ${theme.vars.radius.sm})`, // to be controlled by other components, for example Input
margin: `var(--Button-margin)`, // to be controlled by other components, for example Input
border: 'none',
backgroundColor: 'transparent',
cursor: 'pointer',
userSelect: 'none',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
textDecoration: 'none', // prevent user agent underline when used as anchor
fontFamily: theme.vars.fontFamily.body,
fontWeight: theme.vars.fontWeight.lg,
lineHeight: theme.vars.lineHeight.md,
...(ownerState.fullWidth && {
width: '100%',
}),
[theme.focus.selector]: theme.focus.default,
} as const,
{
...theme.variants[ownerState.variant!]?.[ownerState.color!],
'&:hover': {
'@media (hover: hover)': theme.variants[`${ownerState.variant!}Hover`]?.[ownerState.color!],
},
'&:active, &[aria-pressed="true"]':
theme.variants[`${ownerState.variant!}Active`]?.[ownerState.color!],
[`&.${buttonClasses.disabled}`]:
theme.variants[`${ownerState.variant!}Disabled`]?.[ownerState.color!],
...(ownerState.loadingPosition === 'center' && {
// this has to come after the variant styles to take effect.
[`&.${buttonClasses.loading}`]: {
color: 'transparent',
},
}),
},
];
};
const ButtonRoot = styled('button', {
name: 'JoyButton',
slot: 'Root',
overridesResolver: (props, styles) => styles.root,
})<{ ownerState: ButtonOwnerState }>(getButtonStyles);
/**
*
* Demos:
*
* - [Button](https://mui.com/joy-ui/react-button/)
* - [Button Group](https://mui.com/joy-ui/react-button-group/)
* - [Toggle Button Group](https://mui.com/joy-ui/react-toggle-button-group/)
*
* API:
*
* - [Button API](https://mui.com/joy-ui/api/button/)
*/
const Button = React.forwardRef(function Button(inProps, ref) {
const props = useThemeProps<typeof inProps & { component?: React.ElementType }>({
props: inProps,
name: 'JoyButton',
});
const {
children,
action,
color: colorProp = 'primary',
variant: variantProp = 'solid',
size: sizeProp = 'md',
fullWidth = false,
startDecorator,
endDecorator,
loading = false,
loadingPosition = 'center',
loadingIndicator: loadingIndicatorProp,
disabled: disabledProp,
component,
slots = {},
slotProps = {},
...other
} = props;
const buttonGroup = React.useContext(ButtonGroupContext);
const toggleButtonGroup = React.useContext(ToggleButtonGroupContext);
const variant = inProps.variant || buttonGroup.variant || variantProp;
const size = inProps.size || buttonGroup.size || sizeProp;
const color = inProps.color || buttonGroup.color || colorProp;
const disabled =
(inProps.loading || inProps.disabled) ?? (buttonGroup.disabled || loading || disabledProp);
const buttonRef = React.useRef<HTMLElement>(null);
const handleRef = useForkRef(buttonRef, ref);
const { focusVisible, setFocusVisible, getRootProps } = useButton({
...props,
disabled,
rootRef: handleRef,
});
const loadingIndicator = loadingIndicatorProp ?? (
<CircularProgress color={color} thickness={{ sm: 2, md: 3, lg: 4 }[size] || 3} />
);
React.useImperativeHandle(
action,
() => ({
focusVisible: () => {
setFocusVisible(true);
buttonRef.current?.focus();
},
}),
[setFocusVisible],
);
const ownerState = {
...props,
color,
fullWidth,
variant,
size,
focusVisible,
loading,
loadingPosition,
disabled,
};
const classes = useUtilityClasses(ownerState);
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
let onClick = props.onClick;
if (typeof slotProps.root === 'function') {
onClick = slotProps.root(ownerState).onClick;
} else if (slotProps.root) {
onClick = slotProps.root.onClick;
}
onClick?.(event);
if (toggleButtonGroup) {
toggleButtonGroup.onClick?.(event, props.value);
}
};
let ariaPressed = props['aria-pressed'];
if (typeof slotProps.root === 'function') {
ariaPressed = slotProps.root(ownerState)['aria-pressed'];
} else if (slotProps.root) {
ariaPressed = slotProps.root['aria-pressed'];
}
if (toggleButtonGroup?.value) {
if (Array.isArray(toggleButtonGroup.value)) {
ariaPressed = toggleButtonGroup.value.includes(props.value as string);
} else {
ariaPressed = toggleButtonGroup.value === props.value;
}
}
const externalForwardedProps = { ...other, component, slots, slotProps };
const [SlotRoot, rootProps] = useSlot('root', {
ref,
className: classes.root,
elementType: ButtonRoot,
externalForwardedProps,
getSlotProps: getRootProps,
ownerState,
additionalProps: {
onClick: handleClick,
'aria-pressed': ariaPressed,
},
});
const [SlotStartDecorator, startDecoratorProps] = useSlot('startDecorator', {
className: classes.startDecorator,
elementType: ButtonStartDecorator,
externalForwardedProps,
ownerState,
});
const [SlotEndDecorator, endDecoratorProps] = useSlot('endDecorator', {
className: classes.endDecorator,
elementType: ButtonEndDecorator,
externalForwardedProps,
ownerState,
});
const [SlotLoadingIndicatorCenter, loadingIndicatorCenterProps] = useSlot(
'loadingIndicatorCenter',
{
className: classes.loadingIndicatorCenter,
elementType: ButtonLoadingCenter,
externalForwardedProps,
ownerState,
},
);
return (
<SlotRoot {...rootProps}>
{(startDecorator || (loading && loadingPosition === 'start')) && (
<SlotStartDecorator {...startDecoratorProps}>
{loading && loadingPosition === 'start' ? loadingIndicator : startDecorator}
</SlotStartDecorator>
)}
{children}
{loading && loadingPosition === 'center' && (
<SlotLoadingIndicatorCenter {...loadingIndicatorCenterProps}>
{loadingIndicator}
</SlotLoadingIndicatorCenter>
)}
{(endDecorator || (loading && loadingPosition === 'end')) && (
<SlotEndDecorator {...endDecoratorProps}>
{loading && loadingPosition === 'end' ? loadingIndicator : endDecorator}
</SlotEndDecorator>
)}
</SlotRoot>
);
}) as ExtendButton<ButtonTypeMap>;
Button.propTypes /* remove-proptypes */ = {
// ┌────────────────────────────── Warning ──────────────────────────────┐
// │ These PropTypes are generated from the TypeScript type definitions. │
// │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
// └─────────────────────────────────────────────────────────────────────┘
/**
* A ref for imperative actions. It currently only supports `focusVisible()` action.
*/
action: PropTypes.oneOfType([
PropTypes.func,
PropTypes.shape({
current: PropTypes.shape({
focusVisible: PropTypes.func.isRequired,
}),
}),
]),
/**
* @ignore
*/
children: PropTypes.node,
/**
* The color of the component. It supports those theme colors that make sense for this component.
* @default 'primary'
*/
color: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([
PropTypes.oneOf(['danger', 'neutral', 'primary', 'success', 'warning']),
PropTypes.string,
]),
/**
* The component used for the root node.
* Either a string to use a HTML element or a component.
*/
component: PropTypes.elementType,
/**
* If `true`, the component is disabled.
* @default false
*/
disabled: PropTypes.bool,
/**
* Element placed after the children.
*/
endDecorator: PropTypes.node,
/**
* @ignore
*/
focusVisibleClassName: PropTypes.string,
/**
* If `true`, the button will take up the full width of its container.
* @default false
*/
fullWidth: PropTypes.bool,
/**
* If `true`, the loading indicator is shown and the button becomes disabled.
* @default false
*/
loading: PropTypes.bool,
/**
* The node should contain an element with `role="progressbar"` with an accessible name.
* By default we render a `CircularProgress` that is labelled by the button itself.
* @default <CircularProgress />
*/
loadingIndicator: PropTypes.node,
/**
* The loading indicator can be positioned on the start, end, or the center of the button.
* @default 'center'
*/
loadingPosition: PropTypes.oneOf(['center', 'end', 'start']),
/**
* @ignore
*/
onClick: PropTypes.func,
/**
* The size of the component.
* @default 'md'
*/
size: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([
PropTypes.oneOf(['sm', 'md', 'lg']),
PropTypes.string,
]),
/**
* The props used for each slot inside.
* @default {}
*/
slotProps: PropTypes.shape({
endDecorator: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
loadingIndicatorCenter: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
root: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
startDecorator: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
}),
/**
* The components used for each slot inside.
* @default {}
*/
slots: PropTypes.shape({
endDecorator: PropTypes.elementType,
loadingIndicatorCenter: PropTypes.elementType,
root: PropTypes.elementType,
startDecorator: PropTypes.elementType,
}),
/**
* Element placed before the children.
*/
startDecorator: PropTypes.node,
/**
* The system prop that allows defining system overrides as well as additional CSS styles.
*/
sx: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.func, PropTypes.object, PropTypes.bool])),
PropTypes.func,
PropTypes.object,
]),
/**
* @default 0
*/
tabIndex: PropTypes.number,
/**
* @ignore
*/
value: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.string),
PropTypes.number,
PropTypes.string,
]),
/**
* The [global variant](https://mui.com/joy-ui/main-features/global-variants/) to use.
* @default 'solid'
*/
variant: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([
PropTypes.oneOf(['outlined', 'plain', 'soft', 'solid']),
PropTypes.string,
]),
} as any;
// @ts-ignore internal logic for ToggleButtonGroup
Button.muiName = 'Button';
export default Button;

View File

@@ -0,0 +1,151 @@
import * as React from 'react';
import {
OverridableComponent,
OverridableStringUnion,
OverridableTypeMap,
OverrideProps,
} from '@mui/types';
import { ColorPaletteProp, SxProps, VariantProp, ApplyColorInversion } from '../styles/types';
import { SlotProps, CreateSlotsAndSlotProps } from '../utils/types';
export type ButtonSlot = 'root' | 'startDecorator' | 'endDecorator' | 'loadingIndicatorCenter';
export interface ButtonSlots {
/**
* The component that renders the root.
* @default 'button'
*/
root?: React.ElementType;
/**
* The component that renders the start decorator.
* @default 'span'
*/
startDecorator?: React.ElementType;
/**
* The component that renders the end decorator.
* @default 'span'
*/
endDecorator?: React.ElementType;
/**
* The component that renders the loading indicator center.
* @default 'span'
*/
loadingIndicatorCenter?: React.ElementType;
}
export interface ButtonPropsVariantOverrides {}
export interface ButtonPropsColorOverrides {}
export interface ButtonPropsSizeOverrides {}
export type ButtonSlotsAndSlotProps = CreateSlotsAndSlotProps<
ButtonSlots,
{
root: SlotProps<'button', {}, ButtonOwnerState>;
startDecorator: SlotProps<'span', {}, ButtonOwnerState>;
endDecorator: SlotProps<'span', {}, ButtonOwnerState>;
loadingIndicatorCenter: SlotProps<'span', {}, ButtonOwnerState>;
}
>;
export interface ButtonTypeMap<P = {}, D extends React.ElementType = 'button'> {
props: P &
ButtonSlotsAndSlotProps & {
/**
* A ref for imperative actions. It currently only supports `focusVisible()` action.
*/
action?: React.Ref<{
focusVisible(): void;
}>;
/**
* The color of the component. It supports those theme colors that make sense for this component.
* @default 'primary'
*/
color?: OverridableStringUnion<ColorPaletteProp, ButtonPropsColorOverrides>;
/**
* If `true`, the component is disabled.
* @default false
*/
disabled?: boolean;
/**
* Element placed after the children.
*/
endDecorator?: React.ReactNode;
/**
* This prop can help identify which element has keyboard focus.
* The class name will be applied when the element gains the focus through keyboard interaction.
* It's a polyfill for the [CSS :focus-visible selector](https://drafts.csswg.org/selectors-4/#the-focus-visible-pseudo).
* The rationale for using this feature [is explained here](https://github.com/WICG/focus-visible/blob/HEAD/explainer.md).
* A [polyfill can be used](https://github.com/WICG/focus-visible) to apply a `focus-visible` class to other components
* if needed.
*/
focusVisibleClassName?: string;
/**
* If `true`, the button will take up the full width of its container.
* @default false
*/
fullWidth?: boolean;
/**
* The size of the component.
* @default 'md'
*/
size?: OverridableStringUnion<'sm' | 'md' | 'lg', ButtonPropsSizeOverrides>;
/**
* Element placed before the children.
*/
startDecorator?: React.ReactNode;
/**
* The system prop that allows defining system overrides as well as additional CSS styles.
*/
sx?: SxProps;
/**
* @default 0
*/
tabIndex?: NonNullable<React.HTMLAttributes<any>['tabIndex']>;
/**
* The [global variant](https://mui.com/joy-ui/main-features/global-variants/) to use.
* @default 'solid'
*/
variant?: OverridableStringUnion<VariantProp, ButtonPropsVariantOverrides>;
/**
* If `true`, the loading indicator is shown and the button becomes disabled.
* @default false
*/
loading?: boolean;
/**
* The node should contain an element with `role="progressbar"` with an accessible name.
* By default we render a `CircularProgress` that is labelled by the button itself.
* @default <CircularProgress />
*/
loadingIndicator?: React.ReactNode;
/**
* The loading indicator can be positioned on the start, end, or the center of the button.
* @default 'center'
*/
loadingPosition?: 'start' | 'end' | 'center';
};
defaultComponent: D;
}
export interface ExtendButtonTypeMap<M extends OverridableTypeMap> {
props: M['props'] & ButtonTypeMap['props'];
defaultComponent: M['defaultComponent'];
}
export type ButtonProps<
D extends React.ElementType = ButtonTypeMap['defaultComponent'],
P = {
component?: React.ElementType;
},
> = OverrideProps<ButtonTypeMap<P, D>, D>;
export interface ButtonOwnerState extends ApplyColorInversion<ButtonProps> {
/**
* If `true`, the button's focus is visible.
*/
focusVisible?: boolean;
}
export type ExtendButton<M extends OverridableTypeMap> = ((
props: OverrideProps<ExtendButtonTypeMap<M>, 'a'>,
) => React.JSX.Element) &
OverridableComponent<ExtendButtonTypeMap<M>>;

View File

@@ -0,0 +1,78 @@
import { generateUtilityClass, generateUtilityClasses } from '../className';
export interface ButtonClasses {
/** Class name applied to the root element. */
root: string;
/** Class name applied to the root element if `color="primary"`. */
colorPrimary: string;
/** Class name applied to the root element if `color="neutral"`. */
colorNeutral: string;
/** Class name applied to the root element if `color="danger"`. */
colorDanger: string;
/** Class name applied to the root element if `color="success"`. */
colorSuccess: string;
/** Class name applied to the root element if `color="warning"`. */
colorWarning: string;
/** Class name applied to the root element when color inversion is triggered. */
colorContext: string;
/** Class name applied to the root element if `variant="plain"`. */
variantPlain: string;
/** Class name applied to the root element if `variant="outlined"`. */
variantOutlined: string;
/** Class name applied to the root element if `variant="soft"`. */
variantSoft: string;
/** Class name applied to the root element if `variant="solid"`. */
variantSolid: string;
/** State class applied to the ButtonBase root element if the button is keyboard focused. */
focusVisible: string;
/** State class applied to the root element if `disabled={true}`. */
disabled: string;
/** Class name applied to the root element if `size="sm"`. */
sizeSm: string;
/** Class name applied to the root element if `size="md"`. */
sizeMd: string;
/** Class name applied to the root element if `size="lg"`. */
sizeLg: string;
/** Class name applied to the root element if `fullWidth={true}`. */
fullWidth: string;
/** Class name applied to the startDecorator element if supplied. */
startDecorator: string;
/** Class name applied to the endDecorator element if supplied. */
endDecorator: string;
/** Class name applied to the root element if `loading={true}`. */
loading: string;
/** Class name applied to the loadingIndicatorCenter element. */
loadingIndicatorCenter: string;
}
export type ButtonClassKey = keyof ButtonClasses;
export function getButtonUtilityClass(slot: string): string {
return generateUtilityClass('MuiButton', slot);
}
const buttonClasses: ButtonClasses = generateUtilityClasses('MuiButton', [
'root',
'colorPrimary',
'colorNeutral',
'colorDanger',
'colorSuccess',
'colorWarning',
'colorContext',
'variantPlain',
'variantOutlined',
'variantSoft',
'variantSolid',
'focusVisible',
'disabled',
'sizeSm',
'sizeMd',
'sizeLg',
'fullWidth',
'startDecorator',
'endDecorator',
'loading',
'loadingIndicatorCenter',
]);
export default buttonClasses;

View File

@@ -0,0 +1,4 @@
export { default } from './Button';
export * from './ButtonProps';
export { default as buttonClasses } from './buttonClasses';
export * from './buttonClasses';

View File

@@ -0,0 +1,24 @@
import ButtonGroup from '@mui/joy/ButtonGroup';
<ButtonGroup />;
<ButtonGroup component="fieldset" />;
// `variant`
<ButtonGroup variant="plain" />;
<ButtonGroup variant="soft" />;
<ButtonGroup variant="outlined" />;
<ButtonGroup variant="solid" />;
// `color`
<ButtonGroup color="primary" />;
<ButtonGroup color="danger" />;
<ButtonGroup color="success" />;
<ButtonGroup color="warning" />;
<ButtonGroup color="neutral" />;
// @ts-expect-error there is no variant `filled`
<ButtonGroup variant="filled" />;
// @ts-expect-error there is no color `secondary`
<ButtonGroup color="secondary" />;

View File

@@ -0,0 +1,212 @@
import { expect } from 'chai';
import { createRenderer, screen } from '@mui/internal-test-utils';
import capitalize from '@mui/utils/capitalize';
import { ThemeProvider } from '@mui/joy/styles';
import ButtonGroup, {
buttonGroupClasses as classes,
ButtonGroupClassKey,
} from '@mui/joy/ButtonGroup';
import Button, { buttonClasses, ButtonClassKey } from '@mui/joy/Button';
import IconButton, { iconButtonClasses, IconButtonClassKey } from '@mui/joy/IconButton';
import describeConformance from '../../test/describeConformance';
describe('<ButtonGroup />', () => {
const { render } = createRenderer();
describeConformance(<ButtonGroup />, () => ({
classes,
inheritComponent: 'div',
render,
ThemeProvider,
muiName: 'JoyButtonGroup',
refInstanceof: window.HTMLDivElement,
testComponentPropWith: 'fieldset',
testVariantProps: { variant: 'solid' },
testCustomVariant: true,
skip: ['classesRoot', 'componentsProp'],
slots: {
root: {
expectedClassName: classes.root,
},
},
}));
describe('prop: variant', () => {
it('has role group', () => {
render(<ButtonGroup />);
expect(screen.getByRole('group')).toBeVisible();
});
it('plain by default', () => {
render(
<ButtonGroup data-testid="root">
<Button />
<IconButton />
</ButtonGroup>,
);
expect(screen.getByTestId('root')).to.have.class(classes.variantOutlined);
});
(['plain', 'outlined', 'soft', 'solid'] as const).forEach((variant) => {
it(`should render ${variant}`, () => {
const { container } = render(
<ButtonGroup data-testid="root" variant={variant}>
<Button />
<IconButton />
</ButtonGroup>,
);
expect(screen.getByTestId('root')).to.have.class(
classes[`variant${capitalize(variant)}` as ButtonGroupClassKey],
);
expect(container.firstChild?.firstChild).to.have.class(
buttonClasses[`variant${capitalize(variant)}` as ButtonClassKey],
);
expect(container.firstChild?.lastChild).to.have.class(
iconButtonClasses[`variant${capitalize(variant)}` as IconButtonClassKey],
);
});
});
it('should override button group value', () => {
render(
<ButtonGroup variant="soft">
<Button variant="solid" />
<IconButton variant="plain" />
</ButtonGroup>,
);
expect(screen.getAllByRole('button')[0]).to.have.class(buttonClasses.variantSolid);
expect(screen.getAllByRole('button')[1]).to.have.class(iconButtonClasses.variantPlain);
});
});
describe('prop: color', () => {
it('adds a neutral class by default', () => {
render(
<ButtonGroup data-testid="root">
<Button />
<IconButton />
</ButtonGroup>,
);
expect(screen.getByTestId('root')).to.have.class(classes.colorNeutral);
});
(['primary', 'success', 'danger', 'neutral', 'warning'] as const).forEach((color) => {
it(`should render ${color}`, () => {
const { container } = render(
<ButtonGroup data-testid="root" color={color}>
<Button />
<IconButton />
</ButtonGroup>,
);
expect(screen.getByTestId('root')).to.have.class(
classes[`color${capitalize(color)}` as ButtonGroupClassKey],
);
expect(container.firstChild?.firstChild).to.have.class(
buttonClasses[`color${capitalize(color)}` as ButtonClassKey],
);
expect(container.firstChild?.lastChild).to.have.class(
iconButtonClasses[`color${capitalize(color)}` as IconButtonClassKey],
);
});
});
it('should override button group value', () => {
render(
<ButtonGroup color="primary">
<Button color="danger" />
<IconButton color="success" />
</ButtonGroup>,
);
expect(screen.getAllByRole('button')[0]).to.have.class(buttonClasses.colorDanger);
expect(screen.getAllByRole('button')[1]).to.have.class(iconButtonClasses.colorSuccess);
});
});
it('can change size', () => {
const { container, rerender } = render(
<ButtonGroup>
<Button />
<IconButton />
</ButtonGroup>,
);
expect(container.firstChild).to.have.class(classes.sizeMd);
expect(screen.getAllByRole('button')[0]).to.have.class(buttonClasses.sizeMd);
expect(screen.getAllByRole('button')[1]).to.have.class(iconButtonClasses.sizeMd);
rerender(
<ButtonGroup size="lg">
<Button />
<IconButton />
</ButtonGroup>,
);
expect(container.firstChild).to.have.class(classes.sizeLg);
expect(screen.getAllByRole('button')[0]).to.have.class(buttonClasses.sizeLg);
expect(screen.getAllByRole('button')[1]).to.have.class(iconButtonClasses.sizeLg);
rerender(
<ButtonGroup size="lg">
<Button size="sm" />
<IconButton size="sm" />
</ButtonGroup>,
);
expect(screen.getAllByRole('button')[0]).to.have.class(buttonClasses.sizeSm);
expect(screen.getAllByRole('button')[1]).to.have.class(iconButtonClasses.sizeSm);
});
it('add data-attribute to the first and last child', () => {
const { container } = render(
<ButtonGroup>
<Button>First</Button>
<Button>Second</Button>
<Button>Third</Button>
</ButtonGroup>,
);
expect(container.querySelector('[data-first-child]')).to.have.text('First');
expect(container.querySelector('[data-last-child]')).to.have.text('Third');
});
it('should not add data-attribute to single child', () => {
const { container } = render(
<ButtonGroup>
<Button>Single</Button>
</ButtonGroup>,
);
expect(container.querySelector('[data-first-child]')).to.equal(null);
expect(container.querySelector('[data-last-child]')).to.equal(null);
});
it('pass disabled to buttons', () => {
render(
<ButtonGroup disabled>
<Button />
<IconButton />
</ButtonGroup>,
);
expect(screen.getAllByRole('button')[0]).to.have.property('disabled', true);
expect(screen.getAllByRole('button')[1]).to.have.property('disabled', true);
});
it('pass disabled to buttons unless it is overriden', () => {
render(
<ButtonGroup disabled>
<Button disabled={false} />
<IconButton disabled={false} />
</ButtonGroup>,
);
expect(screen.getAllByRole('button')[0]).not.to.have.property('disabled', true);
expect(screen.getAllByRole('button')[1]).not.to.have.property('disabled', true);
});
});

View File

@@ -0,0 +1,361 @@
'use client';
import * as React from 'react';
import clsx from 'clsx';
import PropTypes from 'prop-types';
import { unstable_composeClasses as composeClasses } from '@mui/base';
import { unstable_traverseBreakpoints as traverseBreakpoints } from '@mui/system/Grid';
import { OverridableComponent } from '@mui/types';
import capitalize from '@mui/utils/capitalize';
import isMuiElement from '@mui/utils/isMuiElement';
import { useThemeProps } from '../styles';
import { resolveSxValue } from '../styles/styleUtils';
import styled from '../styles/styled';
import { getButtonGroupUtilityClass } from './buttonGroupClasses';
import { ButtonGroupProps, ButtonGroupOwnerState, ButtonGroupTypeMap } from './ButtonGroupProps';
import ButtonGroupContext from './ButtonGroupContext';
import useSlot from '../utils/useSlot';
import buttonClasses from '../Button/buttonClasses';
import iconButtonClasses from '../IconButton/iconButtonClasses';
import { DividerProps } from '../Divider';
const useUtilityClasses = (ownerState: ButtonGroupOwnerState) => {
const { size, variant, color, orientation } = ownerState;
const slots = {
root: [
'root',
orientation,
variant && `variant${capitalize(variant)}`,
color && `color${capitalize(color)}`,
size && `size${capitalize(size)}`,
],
};
return composeClasses(slots, getButtonGroupUtilityClass, {});
};
export const StyledButtonGroup = styled('div')<{ ownerState: ButtonGroupOwnerState }>(({
theme,
ownerState,
}) => {
const { borderRadius: radius } = resolveSxValue({ theme, ownerState }, ['borderRadius']);
const firstChildRadius =
ownerState.orientation === 'vertical'
? 'var(--ButtonGroup-radius) var(--ButtonGroup-radius) var(--unstable_childRadius) var(--unstable_childRadius)'
: 'var(--ButtonGroup-radius) var(--unstable_childRadius) var(--unstable_childRadius) var(--ButtonGroup-radius)';
const lastChildRadius =
ownerState.orientation === 'vertical'
? 'var(--unstable_childRadius) var(--unstable_childRadius) var(--ButtonGroup-radius) var(--ButtonGroup-radius)'
: 'var(--unstable_childRadius) var(--ButtonGroup-radius) var(--ButtonGroup-radius) var(--unstable_childRadius)';
const margin =
ownerState.orientation === 'vertical'
? 'calc(var(--ButtonGroup-separatorSize) * -1) 0 0 0'
: '0 0 0 calc(var(--ButtonGroup-separatorSize) * -1)';
const styles = {};
traverseBreakpoints<string | number | null>(
theme.breakpoints,
ownerState.spacing,
(appendStyle, value) => {
if (value !== null) {
appendStyle(styles, {
// the buttons should be connected if the value is more than 0
'--ButtonGroup-connected': value.toString().match(/^0(?!\.)/) ? '1' : '0',
gap: typeof value === 'string' ? value : theme.spacing?.(value),
});
}
},
);
const outlinedStyle = theme.variants.outlined?.[ownerState.color!];
const outlinedDisabledStyle = theme.variants.outlinedDisabled?.[ownerState.color!];
const outlinedHoverStyle = theme.variants.outlinedHover?.[ownerState.color!];
return [
{
'--ButtonGroup-separatorSize':
ownerState.variant === 'outlined' ? '1px' : 'calc(var(--ButtonGroup-connected) * 1px)',
'--ButtonGroup-separatorColor': outlinedStyle?.borderColor,
'--ButtonGroup-radius': theme.vars.radius.sm,
'--Divider-inset': '0.5rem',
'--unstable_childRadius':
'calc((1 - var(--ButtonGroup-connected)) * var(--ButtonGroup-radius) - var(--variant-borderWidth, 0px))', // for internal usage
...styles,
display: 'flex',
borderRadius: 'var(--ButtonGroup-radius)',
flexDirection: ownerState.orientation === 'vertical' ? 'column' : 'row',
// first Button or IconButton
[`& > [data-first-child]`]: {
'--Button-radius': firstChildRadius,
'--IconButton-radius': firstChildRadius,
...(ownerState.orientation === 'horizontal' && {
borderRight: 'var(--ButtonGroup-separatorSize) solid var(--ButtonGroup-separatorColor)',
}),
...(ownerState.orientation === 'vertical' && {
borderBottom: 'var(--ButtonGroup-separatorSize) solid var(--ButtonGroup-separatorColor)',
}),
},
// middle Buttons or IconButtons
[`& > :not([data-first-child]):not([data-last-child]):not(:only-child)`]: {
'--Button-radius': 'var(--unstable_childRadius)',
'--IconButton-radius': 'var(--unstable_childRadius)',
borderRadius: 'var(--unstable_childRadius)',
...(ownerState.orientation === 'horizontal' && {
borderLeft: 'var(--ButtonGroup-separatorSize) solid var(--ButtonGroup-separatorColor)',
borderRight: 'var(--ButtonGroup-separatorSize) solid var(--ButtonGroup-separatorColor)',
}),
...(ownerState.orientation === 'vertical' && {
borderTop: 'var(--ButtonGroup-separatorSize) solid var(--ButtonGroup-separatorColor)',
borderBottom: 'var(--ButtonGroup-separatorSize) solid var(--ButtonGroup-separatorColor)',
}),
},
// last Button or IconButton
[`& > [data-last-child]`]: {
'--Button-radius': lastChildRadius,
'--IconButton-radius': lastChildRadius,
...(ownerState.orientation === 'horizontal' && {
borderLeft: 'var(--ButtonGroup-separatorSize) solid var(--ButtonGroup-separatorColor)',
}),
...(ownerState.orientation === 'vertical' && {
borderTop: 'var(--ButtonGroup-separatorSize) solid var(--ButtonGroup-separatorColor)',
}),
},
// single Button or IconButton
[`& > :only-child`]: {
'--Button-radius': 'var(--ButtonGroup-radius)',
'--IconButton-radius': 'var(--ButtonGroup-radius)',
},
[`& > :not([data-first-child]):not(:only-child)`]: {
'--Button-margin': margin,
'--IconButton-margin': margin,
},
[`& .${buttonClasses.root}, & .${iconButtonClasses.root}`]: {
'&:not(:disabled)': {
zIndex: 1, // to make borders appear above disabled buttons.
},
'&:disabled': {
'--ButtonGroup-separatorColor': outlinedDisabledStyle?.borderColor,
},
...(ownerState.variant === 'outlined' && {
'&:hover': {
'--ButtonGroup-separatorColor': outlinedHoverStyle?.borderColor,
},
}),
[`&:hover, ${theme.focus.selector}`]: {
zIndex: 2, // to make borders appear above sibling.
},
},
...(ownerState.buttonFlex && {
[`& > *:not(.${iconButtonClasses.root})`]: {
flex: ownerState.buttonFlex,
},
[`& > :not(button) > .${buttonClasses.root}`]: {
width: '100%', // for button to fill its wrapper.
},
}),
} as const,
radius !== undefined && {
'--ButtonGroup-radius': radius,
},
];
});
const ButtonGroupRoot = styled(StyledButtonGroup, {
name: 'JoyButtonGroup',
slot: 'Root',
overridesResolver: (props, styles) => styles.root,
})<{ ownerState: ButtonGroupOwnerState }>({});
/**
*
* Demos:
*
* - [Button Group](https://mui.com/joy-ui/react-button-group/)
*
* API:
*
* - [ButtonGroup API](https://mui.com/joy-ui/api/button-group/)
*/
const ButtonGroup = React.forwardRef(function ButtonGroup(inProps, ref) {
const props = useThemeProps<typeof inProps & ButtonGroupProps>({
props: inProps,
name: 'JoyButtonGroup',
});
const {
buttonFlex,
className,
component = 'div',
disabled = false,
size = 'md',
color = 'neutral',
variant = 'outlined',
children,
orientation = 'horizontal',
slots = {},
slotProps = {},
spacing = 0,
...other
} = props;
const ownerState = {
...props,
buttonFlex,
color,
component,
orientation,
spacing,
size,
variant,
};
const classes = useUtilityClasses(ownerState);
const externalForwardedProps = { ...other, component, slots, slotProps };
const [SlotRoot, rootProps] = useSlot('root', {
ref,
className: clsx(classes.root, className),
elementType: ButtonGroupRoot,
externalForwardedProps,
additionalProps: {
role: 'group',
},
ownerState,
});
const buttonGroupContext = React.useMemo(
() => ({ variant, color, size, disabled }),
[variant, color, size, disabled],
);
return (
<SlotRoot {...rootProps}>
<ButtonGroupContext.Provider value={buttonGroupContext}>
{React.Children.map(children, (child, index) => {
if (!React.isValidElement(child)) {
return child;
}
const extraProps: Record<string, any> = {};
if (isMuiElement(child, ['Divider'])) {
const childProps = child.props as DividerProps;
extraProps.inset = childProps?.inset ?? 'context';
const dividerOrientation = orientation === 'vertical' ? 'horizontal' : 'vertical';
extraProps.orientation = childProps?.orientation ?? dividerOrientation;
extraProps.role = 'presentation';
extraProps.component = 'span';
}
if (React.Children.count(children) > 1) {
if (index === 0) {
extraProps['data-first-child'] = '';
}
if (index === React.Children.count(children) - 1) {
extraProps['data-last-child'] = '';
}
}
return React.cloneElement(child, extraProps);
})}
</ButtonGroupContext.Provider>
</SlotRoot>
);
}) as OverridableComponent<ButtonGroupTypeMap>;
ButtonGroup.propTypes /* remove-proptypes */ = {
// ┌────────────────────────────── Warning ──────────────────────────────┐
// │ These PropTypes are generated from the TypeScript type definitions. │
// │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
// └─────────────────────────────────────────────────────────────────────┘
/**
* The flex value of the button.
* @example buttonFlex={1} will set flex: '1 1 auto' on each button (stretch the button to equally fill the available space).
*/
buttonFlex: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
/**
* Used to render icon or text elements inside the ButtonGroup if `src` is not set.
* This can be an element, or just a string.
*/
children: PropTypes.node,
/**
* @ignore
*/
className: PropTypes.string,
/**
* The color of the component. It supports those theme colors that make sense for this component.
* @default 'neutral'
*/
color: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([
PropTypes.oneOf(['danger', 'neutral', 'primary', 'success', 'warning']),
PropTypes.string,
]),
/**
* The component used for the root node.
* Either a string to use a HTML element or a component.
*/
component: PropTypes.elementType,
/**
* If `true`, all the buttons will be disabled.
* @default false
*/
disabled: PropTypes.bool,
/**
* The component orientation.
* @default 'horizontal'
*/
orientation: PropTypes.oneOf(['horizontal', 'vertical']),
/**
* The size of the component.
* It accepts theme values between 'sm' and 'lg'.
* @default 'md'
*/
size: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([
PropTypes.oneOf(['lg', 'md', 'sm']),
PropTypes.string,
]),
/**
* The props used for each slot inside.
* @default {}
*/
slotProps: PropTypes.shape({
root: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
}),
/**
* The components used for each slot inside.
* @default {}
*/
slots: PropTypes.shape({
root: PropTypes.elementType,
}),
/**
* Defines the space between the type `item` components.
* It can only be used on a type `container` component.
* @default 0
*/
spacing: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.number, PropTypes.string])),
PropTypes.number,
PropTypes.shape({
lg: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
md: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
sm: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
xl: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
xs: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
}),
PropTypes.string,
]),
/**
* The system prop that allows defining system overrides as well as additional CSS styles.
*/
sx: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.func, PropTypes.object, PropTypes.bool])),
PropTypes.func,
PropTypes.object,
]),
/**
* The [global variant](https://mui.com/joy-ui/main-features/global-variants/) to use.
* @default 'outlined'
*/
variant: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([
PropTypes.oneOf(['outlined', 'plain', 'soft', 'solid']),
PropTypes.string,
]),
} as any;
export default ButtonGroup;

View File

@@ -0,0 +1,20 @@
import * as React from 'react';
import type { ButtonGroupProps } from './ButtonGroupProps';
interface IButtonGroupContext {
color?: ButtonGroupProps['color'];
variant?: ButtonGroupProps['variant'];
size?: ButtonGroupProps['size'];
disabled?: boolean;
}
/**
* @ignore - internal component.
*/
const ButtonGroupContext = React.createContext<IButtonGroupContext>({});
if (process.env.NODE_ENV !== 'production') {
ButtonGroupContext.displayName = 'ButtonGroupContext';
}
export default ButtonGroupContext;

View File

@@ -0,0 +1,87 @@
import * as React from 'react';
import { Breakpoint } from '@mui/system';
import { OverridableStringUnion, OverrideProps } from '@mui/types';
import { ColorPaletteProp, VariantProp, SxProps, ApplyColorInversion } from '../styles/types';
import { SlotProps, CreateSlotsAndSlotProps } from '../utils/types';
type ResponsiveStyleValue<T> = T | Array<T | null> | { [key in Breakpoint]?: T | null };
export type ButtonGroupSlot = 'root';
export interface ButtonGroupSlots {
/**
* The component that renders the root.
* @default 'div'
*/
root?: React.ElementType;
}
export type ButtonGroupSlotsAndSlotProps = CreateSlotsAndSlotProps<
ButtonGroupSlots,
{
root: SlotProps<'div', {}, ButtonGroupOwnerState>;
}
>;
export interface ButtonGroupPropsColorOverrides {}
export interface ButtonGroupPropsVariantOverrides {}
export interface ButtonGroupPropsSizeOverrides {}
export interface ButtonGroupTypeMap<P = {}, D extends React.ElementType = 'div'> {
props: P & {
/**
* The flex value of the button.
* @example buttonFlex={1} will set flex: '1 1 auto' on each button (stretch the button to equally fill the available space).
*/
buttonFlex?: number | string;
/**
* Used to render icon or text elements inside the ButtonGroup if `src` is not set.
* This can be an element, or just a string.
*/
children?: React.ReactNode;
/**
* The color of the component. It supports those theme colors that make sense for this component.
* @default 'neutral'
*/
color?: OverridableStringUnion<ColorPaletteProp, ButtonGroupPropsColorOverrides>;
/**
* If `true`, all the buttons will be disabled.
* @default false
*/
disabled?: boolean;
/**
* The component orientation.
* @default 'horizontal'
*/
orientation?: 'horizontal' | 'vertical';
/**
* The size of the component.
* It accepts theme values between 'sm' and 'lg'.
* @default 'md'
*/
size?: OverridableStringUnion<'sm' | 'md' | 'lg', ButtonGroupPropsSizeOverrides>;
/**
* Defines the space between the type `item` components.
* It can only be used on a type `container` component.
* @default 0
*/
spacing?: ResponsiveStyleValue<number | string> | undefined;
/**
* The system prop that allows defining system overrides as well as additional CSS styles.
*/
sx?: SxProps;
/**
* The [global variant](https://mui.com/joy-ui/main-features/global-variants/) to use.
* @default 'outlined'
*/
variant?: OverridableStringUnion<VariantProp, ButtonGroupPropsVariantOverrides>;
} & ButtonGroupSlotsAndSlotProps;
defaultComponent: D;
}
export type ButtonGroupProps<
D extends React.ElementType = ButtonGroupTypeMap['defaultComponent'],
P = { component?: React.ElementType },
> = OverrideProps<ButtonGroupTypeMap<P, D>, D>;
export interface ButtonGroupOwnerState extends ApplyColorInversion<ButtonGroupProps> {}

View File

@@ -0,0 +1,63 @@
import { generateUtilityClass, generateUtilityClasses } from '../className';
export interface ButtonGroupClasses {
/** Class name applied to the root element. */
root: string;
/** Class name applied to the root element if `color="primary"`. */
colorPrimary: string;
/** Class name applied to the root element if `color="neutral"`. */
colorNeutral: string;
/** Class name applied to the root element if `color="danger"`. */
colorDanger: string;
/** Class name applied to the root element if `color="success"`. */
colorSuccess: string;
/** Class name applied to the root element if `color="warning"`. */
colorWarning: string;
/** Class name applied to the root element when color inversion is triggered. */
colorContext: string;
/** Class name applied to the root element if `variant="plain"`. */
variantPlain: string;
/** Class name applied to the root element if `variant="outlined"`. */
variantOutlined: string;
/** Class name applied to the root element if `variant="soft"`. */
variantSoft: string;
/** Class name applied to the root element if `variant="solid"`. */
variantSolid: string;
/** Class name applied to the root element if `size="sm"`. */
sizeSm: string;
/** Class name applied to the root element if `size="md"`. */
sizeMd: string;
/** Class name applied to the root element if `size="lg"`. */
sizeLg: string;
/** Class name applied to the root element if `orientation="horizontal"`. */
horizontal: string;
/** Class name applied to the root element if `orientation="vertical"`. */
vertical: string;
}
export type ButtonGroupClassKey = keyof ButtonGroupClasses;
export function getButtonGroupUtilityClass(slot: string): string {
return generateUtilityClass('MuiButtonGroup', slot);
}
const buttonGroupClasses: ButtonGroupClasses = generateUtilityClasses('MuiButtonGroup', [
'root',
'colorPrimary',
'colorNeutral',
'colorDanger',
'colorSuccess',
'colorWarning',
'colorContext',
'variantPlain',
'variantOutlined',
'variantSoft',
'variantSolid',
'sizeSm',
'sizeMd',
'sizeLg',
'horizontal',
'vertical',
]);
export default buttonGroupClasses;

View File

@@ -0,0 +1,4 @@
export { default } from './ButtonGroup';
export * from './buttonGroupClasses';
export { default as buttonGroupClasses } from './buttonGroupClasses';
export * from './ButtonGroupProps';

View File

@@ -0,0 +1,94 @@
import { expect } from 'chai';
import { createRenderer, screen } from '@mui/internal-test-utils';
import capitalize from '@mui/utils/capitalize';
import { ThemeProvider } from '@mui/joy/styles';
import Card, { cardClasses as classes, CardClassKey } from '@mui/joy/Card';
import describeConformance from '../../test/describeConformance';
describe('<Card />', () => {
const { render } = createRenderer();
describeConformance(<Card />, () => ({
classes,
inheritComponent: 'div',
render,
ThemeProvider,
muiName: 'JoyCard',
refInstanceof: window.HTMLDivElement,
testComponentPropWith: 'li',
testVariantProps: { variant: 'solid' },
testCustomVariant: true,
skip: ['classesRoot', 'componentsProp'],
slots: {
root: {
expectedClassName: classes.root,
},
},
}));
describe('prop: variant', () => {
it('outlined by default', () => {
render(<Card data-testid="root">Hello World</Card>);
expect(screen.getByTestId('root')).to.have.class(classes.variantOutlined);
});
(['plain', 'outlined', 'soft', 'solid'] as const).forEach((variant) => {
it(`should render ${variant}`, () => {
render(
<Card data-testid="root" variant={variant}>
Hello World
</Card>,
);
expect(screen.getByTestId('root')).to.have.class(
classes[`variant${capitalize(variant)}` as CardClassKey],
);
});
});
});
describe('prop: color', () => {
it('adds a neutral class by default', () => {
render(<Card data-testid="root">Hello World</Card>);
expect(screen.getByTestId('root')).to.have.class(classes.colorNeutral);
});
(['primary', 'success', 'danger', 'neutral', 'warning'] as const).forEach((color) => {
it(`should render ${color}`, () => {
render(
<Card data-testid="root" color={color}>
Hello World
</Card>,
);
expect(screen.getByTestId('root')).to.have.class(
classes[`color${capitalize(color)}` as CardClassKey],
);
});
});
});
it('can change size', () => {
const { container, rerender } = render(<Card />);
expect(container.firstChild).to.have.class(classes.sizeMd);
rerender(<Card size="lg" />);
expect(container.firstChild).to.have.class(classes.sizeLg);
});
it('add data-attribute to the first and last child', () => {
const { container } = render(
<Card>
<div>First</div>
<div>Second</div>
<div>Third</div>
</Card>,
);
expect(container.querySelector('[data-first-child]')).to.have.text('First');
expect(container.querySelector('[data-last-child]')).to.have.text('Third');
});
});

View File

@@ -0,0 +1,264 @@
'use client';
import * as React from 'react';
import clsx from 'clsx';
import PropTypes from 'prop-types';
import { unstable_composeClasses as composeClasses } from '@mui/base';
import { OverridableComponent } from '@mui/types';
import capitalize from '@mui/utils/capitalize';
import isMuiElement from '@mui/utils/isMuiElement';
import { useThemeProps } from '../styles';
import { applySolidInversion, applySoftInversion } from '../colorInversion';
import styled from '../styles/styled';
import { getCardUtilityClass } from './cardClasses';
import { CardProps, CardOwnerState, CardTypeMap } from './CardProps';
import { resolveSxValue } from '../styles/styleUtils';
import useSlot from '../utils/useSlot';
import { DividerProps } from '../Divider';
const useUtilityClasses = (ownerState: CardOwnerState) => {
const { size, variant, color, orientation } = ownerState;
const slots = {
root: [
'root',
orientation,
variant && `variant${capitalize(variant)}`,
color && `color${capitalize(color)}`,
size && `size${capitalize(size)}`,
],
};
return composeClasses(slots, getCardUtilityClass, {});
};
export const StyledCardRoot = styled('div')<{ ownerState: CardOwnerState }>(({
theme,
ownerState,
}) => {
const { p, padding, borderRadius } = resolveSxValue({ theme, ownerState }, [
'p',
'padding',
'borderRadius',
]);
return [
{
'--Icon-color':
ownerState.color !== 'neutral' || ownerState.variant === 'solid'
? 'currentColor'
: theme.vars.palette.text.icon,
// a context variable for any child component
'--Card-childRadius':
'max((var(--Card-radius) - var(--variant-borderWidth, 0px)) - var(--Card-padding), min(var(--Card-padding) / 2, (var(--Card-radius) - var(--variant-borderWidth, 0px)) / 2))',
// AspectRatio integration
'--AspectRatio-radius': 'var(--Card-childRadius)',
// Link integration
'--unstable_actionMargin': 'calc(-1 * var(--variant-borderWidth, 0px))',
// Link, Radio, Checkbox integration
'--unstable_actionRadius': 'var(--Card-radius)',
// CardCover integration
'--CardCover-radius': 'calc(var(--Card-radius) - var(--variant-borderWidth, 0px))',
// CardOverflow integration
'--CardOverflow-offset': `calc(-1 * var(--Card-padding))`,
'--CardOverflow-radius': 'calc(var(--Card-radius) - var(--variant-borderWidth, 0px))',
// Divider integration
'--Divider-inset': 'calc(-1 * var(--Card-padding))',
...(ownerState.size === 'sm' && {
'--Card-radius': theme.vars.radius.sm,
'--Card-padding': '0.625rem',
gap: '0.5rem',
}),
...(ownerState.size === 'md' && {
'--Card-radius': theme.vars.radius.md,
'--Card-padding': '1rem',
gap: '0.75rem 1rem',
}),
...(ownerState.size === 'lg' && {
'--Card-radius': theme.vars.radius.lg,
'--Card-padding': '1.5rem',
gap: '1rem 1.5rem',
}),
padding: 'var(--Card-padding)',
borderRadius: 'var(--Card-radius)',
backgroundColor: theme.vars.palette.background.surface,
position: 'relative',
display: 'flex',
flexDirection: ownerState.orientation === 'horizontal' ? 'row' : 'column',
...theme.typography[`body-${ownerState.size!}`],
...(ownerState.variant === 'solid' &&
ownerState.color &&
ownerState.invertedColors &&
applySolidInversion(ownerState.color)(theme)),
...(ownerState.variant === 'soft' &&
ownerState.color &&
ownerState.invertedColors &&
applySoftInversion(ownerState.color)(theme)),
...theme.variants[ownerState.variant!]?.[ownerState.color!],
} as const,
p !== undefined && { '--Card-padding': p },
padding !== undefined && { '--Card-padding': padding },
borderRadius !== undefined && { '--Card-radius': borderRadius },
];
});
const CardRoot = styled(StyledCardRoot, {
name: 'JoyCard',
slot: 'Root',
overridesResolver: (props, styles) => styles.root,
})<{ ownerState: CardOwnerState }>({});
/**
*
* Demos:
*
* - [Card](https://mui.com/joy-ui/react-card/)
*
* API:
*
* - [Card API](https://mui.com/joy-ui/api/card/)
*/
const Card = React.forwardRef(function Card(inProps, ref) {
const props = useThemeProps<typeof inProps & CardProps>({
props: inProps,
name: 'JoyCard',
});
const {
className,
color = 'neutral',
component = 'div',
invertedColors = false,
size = 'md',
variant = 'outlined',
children,
orientation = 'vertical',
slots = {},
slotProps = {},
...other
} = props;
const ownerState = {
...props,
color,
component,
orientation,
size,
variant,
invertedColors,
};
const classes = useUtilityClasses(ownerState);
const externalForwardedProps = { ...other, component, slots, slotProps };
const [SlotRoot, rootProps] = useSlot('root', {
ref,
className: clsx(classes.root, className),
elementType: CardRoot,
externalForwardedProps,
ownerState,
});
return (
<SlotRoot {...rootProps}>
{React.Children.map(children, (child, index) => {
if (!React.isValidElement(child)) {
return child;
}
const extraProps: Record<string, any> = {};
if (isMuiElement(child, ['Divider'])) {
const childProps = child.props as DividerProps;
extraProps.inset = childProps?.inset ?? 'context';
const dividerOrientation = orientation === 'vertical' ? 'horizontal' : 'vertical';
extraProps.orientation = childProps?.orientation ?? dividerOrientation;
}
if (index === 0) {
extraProps['data-first-child'] = '';
}
if (index === React.Children.count(children) - 1) {
extraProps['data-last-child'] = '';
}
return React.cloneElement(child, extraProps);
})}
</SlotRoot>
);
}) as OverridableComponent<CardTypeMap>;
Card.propTypes /* remove-proptypes */ = {
// ┌────────────────────────────── Warning ──────────────────────────────┐
// │ These PropTypes are generated from the TypeScript type definitions. │
// │ To update them, edit the TypeScript types and run `pnpm proptypes`. │
// └─────────────────────────────────────────────────────────────────────┘
/**
* Used to render icon or text elements inside the Card if `src` is not set.
* This can be an element, or just a string.
*/
children: PropTypes.node,
/**
* @ignore
*/
className: PropTypes.string,
/**
* The color of the component. It supports those theme colors that make sense for this component.
* @default 'neutral'
*/
color: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([
PropTypes.oneOf(['danger', 'neutral', 'primary', 'success', 'warning']),
PropTypes.string,
]),
/**
* The component used for the root node.
* Either a string to use a HTML element or a component.
*/
component: PropTypes.elementType,
/**
* If `true`, the children with an implicit color prop invert their colors to match the component's variant and color.
* @default false
*/
invertedColors: PropTypes.bool,
/**
* The component orientation.
* @default 'vertical'
*/
orientation: PropTypes.oneOf(['horizontal', 'vertical']),
/**
* The size of the component.
* It accepts theme values between 'sm' and 'lg'.
* @default 'md'
*/
size: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([
PropTypes.oneOf(['lg', 'md', 'sm']),
PropTypes.string,
]),
/**
* The props used for each slot inside.
* @default {}
*/
slotProps: PropTypes.shape({
root: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
}),
/**
* The components used for each slot inside.
* @default {}
*/
slots: PropTypes.shape({
root: PropTypes.elementType,
}),
/**
* The system prop that allows defining system overrides as well as additional CSS styles.
*/
sx: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.func, PropTypes.object, PropTypes.bool])),
PropTypes.func,
PropTypes.object,
]),
/**
* The [global variant](https://mui.com/joy-ui/main-features/global-variants/) to use.
* @default 'outlined'
*/
variant: PropTypes /* @typescript-to-proptypes-ignore */.oneOfType([
PropTypes.oneOf(['outlined', 'plain', 'soft', 'solid']),
PropTypes.string,
]),
} as any;
export default Card;

View File

@@ -0,0 +1,73 @@
import * as React from 'react';
import { OverridableStringUnion, OverrideProps } from '@mui/types';
import { ColorPaletteProp, VariantProp, SxProps, ApplyColorInversion } from '../styles/types';
import { SlotProps, CreateSlotsAndSlotProps } from '../utils/types';
export type CardSlot = 'root';
export interface CardSlots {
/**
* The component that renders the root.
* @default 'div'
*/
root?: React.ElementType;
}
export type CardSlotsAndSlotProps = CreateSlotsAndSlotProps<
CardSlots,
{
root: SlotProps<'div', {}, CardOwnerState>;
}
>;
export interface CardPropsColorOverrides {}
export interface CardPropsVariantOverrides {}
export interface CardPropsSizeOverrides {}
export interface CardTypeMap<P = {}, D extends React.ElementType = 'div'> {
props: P & {
/**
* Used to render icon or text elements inside the Card if `src` is not set.
* This can be an element, or just a string.
*/
children?: React.ReactNode;
/**
* The color of the component. It supports those theme colors that make sense for this component.
* @default 'neutral'
*/
color?: OverridableStringUnion<ColorPaletteProp, CardPropsColorOverrides>;
/**
* If `true`, the children with an implicit color prop invert their colors to match the component's variant and color.
* @default false
*/
invertedColors?: boolean;
/**
* The component orientation.
* @default 'vertical'
*/
orientation?: 'horizontal' | 'vertical';
/**
* The size of the component.
* It accepts theme values between 'sm' and 'lg'.
* @default 'md'
*/
size?: OverridableStringUnion<'sm' | 'md' | 'lg', CardPropsSizeOverrides>;
/**
* The system prop that allows defining system overrides as well as additional CSS styles.
*/
sx?: SxProps;
/**
* The [global variant](https://mui.com/joy-ui/main-features/global-variants/) to use.
* @default 'outlined'
*/
variant?: OverridableStringUnion<VariantProp, CardPropsVariantOverrides>;
} & CardSlotsAndSlotProps;
defaultComponent: D;
}
export type CardProps<
D extends React.ElementType = CardTypeMap['defaultComponent'],
P = { component?: React.ElementType },
> = OverrideProps<CardTypeMap<P, D>, D>;
export interface CardOwnerState extends ApplyColorInversion<CardProps> {}

View File

@@ -0,0 +1,63 @@
import { generateUtilityClass, generateUtilityClasses } from '../className';
export interface CardClasses {
/** Class name applied to the root element. */
root: string;
/** Class name applied to the root element if `color="primary"`. */
colorPrimary: string;
/** Class name applied to the root element if `color="neutral"`. */
colorNeutral: string;
/** Class name applied to the root element if `color="danger"`. */
colorDanger: string;
/** Class name applied to the root element if `color="success"`. */
colorSuccess: string;
/** Class name applied to the root element if `color="warning"`. */
colorWarning: string;
/** Class name applied to the root element when color inversion is triggered. */
colorContext: string;
/** Class name applied to the root element if `variant="plain"`. */
variantPlain: string;
/** Class name applied to the root element if `variant="outlined"`. */
variantOutlined: string;
/** Class name applied to the root element if `variant="soft"`. */
variantSoft: string;
/** Class name applied to the root element if `variant="solid"`. */
variantSolid: string;
/** Class name applied to the root element if `size="sm"`. */
sizeSm: string;
/** Class name applied to the root element if `size="md"`. */
sizeMd: string;
/** Class name applied to the root element if `size="lg"`. */
sizeLg: string;
/** Class name applied to the root element if `orientation="horizontal"`. */
horizontal: string;
/** Class name applied to the root element if `orientation="vertical"`. */
vertical: string;
}
export type CardClassKey = keyof CardClasses;
export function getCardUtilityClass(slot: string): string {
return generateUtilityClass('MuiCard', slot);
}
const cardClasses: CardClasses = generateUtilityClasses('MuiCard', [
'root',
'colorPrimary',
'colorNeutral',
'colorDanger',
'colorSuccess',
'colorWarning',
'colorContext',
'variantPlain',
'variantOutlined',
'variantSoft',
'variantSolid',
'sizeSm',
'sizeMd',
'sizeLg',
'horizontal',
'vertical',
]);
export default cardClasses;

View File

@@ -0,0 +1,4 @@
export { default } from './Card';
export * from './cardClasses';
export { default as cardClasses } from './cardClasses';
export * from './CardProps';

View File

@@ -0,0 +1,24 @@
import { createRenderer } from '@mui/internal-test-utils';
import { ThemeProvider } from '@mui/joy/styles';
import CardActions, { cardActionsClasses as classes } from '@mui/joy/CardActions';
import describeConformance from '../../test/describeConformance';
describe('<CardActions />', () => {
const { render } = createRenderer();
describeConformance(<CardActions />, () => ({
classes,
inheritComponent: 'div',
render,
ThemeProvider,
muiName: 'JoyCardActions',
refInstanceof: window.HTMLDivElement,
testComponentPropWith: 'span',
skip: ['classesRoot', 'componentsProp', 'themeVariants'],
slots: {
root: {
expectedClassName: classes.root,
},
},
}));
});

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