diff --git a/CHANGELOG.react.md b/CHANGELOG.react.md
index dcdc92fba..505ba3783 100644
--- a/CHANGELOG.react.md
+++ b/CHANGELOG.react.md
@@ -14,11 +14,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Multiple Autocomplete component [#209](https://github.com/lumapps/design-system/pull/209)
- `isHighlighted` prop for `Chip` component [#209](https://github.com/lumapps/design-system/pull/209)
- Added `isClearable` and `chips` props for Autocomplete [#209](https://github.com/lumapps/design-system/pull/209)
+- Uploader component [#208](https://github.com/lumapps/design-system/pull/208)
### Changed
- `Popover` bug that made the content be displayed on the top left corner.
- Make autocomplete multiple demo remove a value upon clicking on the entire chip.
+- Deprecated `ThumbnailAspectRatio` (use `AspectRatio` instead) [#208](https://github.com/lumapps/design-system/pull/208)
### Removed
diff --git a/demo/angularjs/app.js b/demo/angularjs/app.js
index 20747f4e8..235950def 100644
--- a/demo/angularjs/app.js
+++ b/demo/angularjs/app.js
@@ -346,6 +346,16 @@ function AppDefaultConfig($locationProvider, $stateProvider, markedProvider) {
},
},
})
+ .state('app.product.components.uploader', {
+ url: 'uploader',
+ views: {
+ 'main@': {
+ controller: 'DemoUploaderController',
+ controllerAs: 'vm',
+ template: require('./components/uploader/demo.html'),
+ },
+ },
+ })
.state('app.product.components.user-block', {
url: 'user-block',
views: {
@@ -438,5 +448,6 @@ require('./components/table/controller.js');
require('./components/tabs/controller.js');
require('./components/text-field/controller.js');
require('./components/toolbar/controller.js');
+require('./components/uploader/controller.js');
require('./components/user-block/controller.js');
/* eslint-enable import/no-unassigned-import */
diff --git a/demo/angularjs/components/uploader/controller.js b/demo/angularjs/components/uploader/controller.js
new file mode 100644
index 000000000..b710b1d55
--- /dev/null
+++ b/demo/angularjs/components/uploader/controller.js
@@ -0,0 +1,34 @@
+import { mdiImagePlus } from 'LumX/icons';
+
+/////////////////////////////
+
+function DemoUploaderController() {
+ 'ngInject';
+
+ const vm = this;
+
+ /////////////////////////////
+ // //
+ // Public attributes //
+ // //
+ /////////////////////////////
+
+ /**
+ * The icons to use in the template.
+ *
+ * @type {Object}
+ * @constant
+ * @readonly
+ */
+ vm.icons = {
+ mdiImagePlus,
+ };
+}
+
+/////////////////////////////
+
+angular.module('design-system').controller('DemoUploaderController', DemoUploaderController);
+
+/////////////////////////////
+
+export { DemoUploaderController };
diff --git a/demo/angularjs/components/uploader/demo.html b/demo/angularjs/components/uploader/demo.html
new file mode 100644
index 000000000..51c9b2663
--- /dev/null
+++ b/demo/angularjs/components/uploader/demo.html
@@ -0,0 +1,2 @@
+
+
diff --git a/demo/angularjs/components/uploader/doc.md b/demo/angularjs/components/uploader/doc.md
new file mode 100644
index 000000000..4b4d2f1c1
--- /dev/null
+++ b/demo/angularjs/components/uploader/doc.md
@@ -0,0 +1,9 @@
+# Uploader
+
+## Default
+
+
+
+## Circle
+
+
diff --git a/demo/angularjs/components/uploader/partials/circle.html b/demo/angularjs/components/uploader/partials/circle.html
new file mode 100644
index 000000000..c5a92d657
--- /dev/null
+++ b/demo/angularjs/components/uploader/partials/circle.html
@@ -0,0 +1,8 @@
+
diff --git a/demo/angularjs/components/uploader/partials/default.html b/demo/angularjs/components/uploader/partials/default.html
new file mode 100644
index 000000000..0230e5f5b
--- /dev/null
+++ b/demo/angularjs/components/uploader/partials/default.html
@@ -0,0 +1,5 @@
+
diff --git a/demo/angularjs/layout/main-nav/main-nav_controller.js b/demo/angularjs/layout/main-nav/main-nav_controller.js
index 40a01536a..8f301b142 100644
--- a/demo/angularjs/layout/main-nav/main-nav_controller.js
+++ b/demo/angularjs/layout/main-nav/main-nav_controller.js
@@ -157,6 +157,10 @@ function MainNavController($state) {
label: 'Tooltip',
state: 'app.product.components.tooltip',
},
+ {
+ label: 'Uploader',
+ state: 'app.product.components.uploader',
+ },
{
label: 'User block',
state: 'app.product.components.user-block',
diff --git a/demo/react/components/slideshow/default.tsx b/demo/react/components/slideshow/default.tsx
index b779db0dd..3369135d9 100644
--- a/demo/react/components/slideshow/default.tsx
+++ b/demo/react/components/slideshow/default.tsx
@@ -1,13 +1,13 @@
import React, { CSSProperties, ReactElement } from 'react';
import {
+ AspectRatio,
ImageBlock,
ImageBlockCaptionPosition,
ImageBlockProps,
Slideshow,
SlideshowItem,
Theme,
- ThumbnailAspectRatio,
} from 'LumX';
/////////////////////////////
@@ -49,14 +49,14 @@ const DemoComponent: React.FC = ({ theme }: IProps): ReactElement => (
@@ -66,7 +66,7 @@ const DemoComponent: React.FC = ({ theme }: IProps): ReactElement => (
= ({ theme }: IProps): ReactElement => (
= ({ theme }: IProps): ReactElement => (
= ({ theme }: IProps): ReactElement => (
}
- aspectRatio={ThumbnailAspectRatio.horizontal}
+ aspectRatio={AspectRatio.horizontal}
description="Lorem ipsum dolor sit amet, consectur adipiscing "
tags={
@@ -89,7 +89,7 @@ A custom padding can be added.
```javascript jsx withThemeSwitcher disableGrid
(theme) => (
(
(
diff --git a/demo/react/doc/product/components/thumbnail.mdx b/demo/react/doc/product/components/thumbnail.mdx
index a95915f8c..585781cf1 100644
--- a/demo/react/doc/product/components/thumbnail.mdx
+++ b/demo/react/doc/product/components/thumbnail.mdx
@@ -1,5 +1,5 @@
```javascript import
-import { Size, Theme, Thumbnail, ThumbnailAspectRatio, ThumbnailVariant } from '@lumx/react';
+import { Size, Theme, Thumbnail, AspectRatio, ThumbnailVariant } from '@lumx/react';
```
# Thumbnail
@@ -33,10 +33,10 @@ Thumbnails come in 4 ratios: _square_, _original_, _vertical_, and _horizontal_.
```javascript jsx
(theme) => (
<>
-
-
-
-
+
+
+
+
>
);
```
@@ -60,21 +60,21 @@ Thumbnails come in two variants: _squared_ and _rounded_
(theme) => (
<>
(
+
+);
+```
+
+## Rounded
+
+```javascript jsx withThemeSwitcher
+(theme) => (
+
+);
+```
+
+## Circle
+
+```javascript jsx withThemeSwitcher
+(theme) => (
+
+);
+```
+
+### Properties
+
+
diff --git a/demo/react/layout/MainNav.tsx b/demo/react/layout/MainNav.tsx
index aa085c27d..b14f32989 100644
--- a/demo/react/layout/MainNav.tsx
+++ b/demo/react/layout/MainNav.tsx
@@ -76,6 +76,7 @@ const ITEMS: Item[] = [
'Thumbnail',
'Toolbar',
'Tooltip',
+ 'Uploader',
'User block',
],
},
diff --git a/src/angularjs.index.js b/src/angularjs.index.js
index a51b1f405..b7b9185a4 100644
--- a/src/angularjs.index.js
+++ b/src/angularjs.index.js
@@ -59,4 +59,5 @@ import './components/text-field/angularjs/text-field_directive';
import './components/thumbnail/angularjs/thumbnail_directive';
import './components/toolbar/angularjs/toolbar_directive';
import './components/tooltip/angularjs/tooltip_directive';
+import './components/uploader/angularjs/uploader_directive';
import './components/user-block/angularjs/user-block_directive';
diff --git a/src/components/editable-media/react/EditableMedia.tsx b/src/components/editable-media/react/EditableMedia.tsx
index a12817393..95dfd299e 100644
--- a/src/components/editable-media/react/EditableMedia.tsx
+++ b/src/components/editable-media/react/EditableMedia.tsx
@@ -9,7 +9,7 @@ import { Callback, IGenericProps, getRootClassName } from 'LumX/core/react/utils
import { Size, Theme } from 'LumX/components/index';
import { handleBasicClasses, onEnterPressed } from 'LumX/core/utils';
-import { Avatar, Icon, Thumbnail, ThumbnailAspectRatio, ThumbnailVariant } from 'LumX';
+import { AspectRatio, Avatar, Icon, Thumbnail, ThumbnailVariant } from 'LumX';
import { mdiImagePlus } from 'LumX/icons';
import noop from 'lodash/noop';
@@ -147,7 +147,7 @@ const EditableMedia: React.FC = ({
{image && variant === 'thumbnail' && (
= ({
}),
{
[`${CLASSNAME}--fill-height`]: fillHeight,
- [`${CLASSNAME}--format-crop`]: aspectRatio && aspectRatio !== ThumbnailAspectRatio.original,
- [`${CLASSNAME}--format-original`]: !aspectRatio || aspectRatio === ThumbnailAspectRatio.original,
+ [`${CLASSNAME}--format-crop`]: aspectRatio && aspectRatio !== AspectRatio.original,
+ [`${CLASSNAME}--format-original`]: !aspectRatio || aspectRatio === AspectRatio.original,
},
)}
{...restProps}
diff --git a/src/components/index.ts b/src/components/index.ts
index 5cd2d5dbe..0b49fbe1c 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -80,6 +80,27 @@ enum Emphasis {
high = 'high',
}
+/**
+ * All available aspect ratios.
+ */
+enum AspectRatio {
+ original = 'original',
+ horizontal = 'horizontal',
+ vertical = 'vertical',
+ square = 'square',
+}
+
/////////////////////////////
-export { Alignment, ComplexPropDefault, Color, ColorPalette, ColorVariant, Theme, Size, Orientation, Emphasis };
+export {
+ Alignment,
+ AspectRatio,
+ ComplexPropDefault,
+ Color,
+ ColorPalette,
+ ColorVariant,
+ Theme,
+ Size,
+ Orientation,
+ Emphasis,
+};
diff --git a/src/components/post-block/react/PostBlock.tsx b/src/components/post-block/react/PostBlock.tsx
index 68db1f85f..477305429 100644
--- a/src/components/post-block/react/PostBlock.tsx
+++ b/src/components/post-block/react/PostBlock.tsx
@@ -6,7 +6,7 @@ import { COMPONENT_PREFIX } from 'LumX/core/react/constants';
import isObject from 'lodash/isObject';
-import { Orientation, Theme, Thumbnail, ThumbnailAspectRatio, ThumbnailVariant } from 'LumX';
+import { AspectRatio, Orientation, Theme, Thumbnail, ThumbnailVariant } from 'LumX';
import { IGenericProps, getRootClassName } from 'LumX/core/react/utils';
@@ -39,7 +39,7 @@ interface IPostBlockProps extends IGenericProps {
/* Thumbnail image source */
thumbnail: string;
/* The image aspect ratio. */
- thumbnailAspectRatio?: ThumbnailAspectRatio;
+ thumbnailAspectRatio?: AspectRatio;
/* Post title */
title: string;
/* Theme. */
@@ -79,7 +79,7 @@ const DEFAULT_PROPS: IDefaultPropsType = {
orientation: Orientation.horizontal,
text: undefined,
theme: Theme.light,
- thumbnailAspectRatio: ThumbnailAspectRatio.horizontal,
+ thumbnailAspectRatio: AspectRatio.horizontal,
};
/////////////////////////////
diff --git a/src/components/thumbnail/react/Thumbnail.tsx b/src/components/thumbnail/react/Thumbnail.tsx
index 8c592b04f..46ba846d0 100644
--- a/src/components/thumbnail/react/Thumbnail.tsx
+++ b/src/components/thumbnail/react/Thumbnail.tsx
@@ -2,7 +2,7 @@ import React, { CSSProperties, ReactElement } from 'react';
import classNames from 'classnames';
-import { Alignment, Size, Theme } from 'LumX';
+import { Alignment, AspectRatio, Size, Theme } from 'LumX';
import { COMPONENT_PREFIX } from 'LumX/core/react/constants';
@@ -25,13 +25,14 @@ declare module 'react' {
/**
* All available aspect ratios.
+ * @deprecated
*/
-enum ThumbnailAspectRatio {
- original = 'original',
- horizontal = 'horizontal',
- vertical = 'vertical',
- square = 'square',
-}
+const ThumbnailAspectRatio: Record = {
+ horizontal: AspectRatio.horizontal,
+ original: AspectRatio.original,
+ square: AspectRatio.square,
+ vertical: AspectRatio.vertical,
+};
/**
* Authorized size values.
@@ -64,7 +65,7 @@ interface IThumbnailProps extends IGenericProps {
/** The thumbnail alignment. */
align?: Alignment;
/** The image aspect ratio. */
- aspectRatio?: ThumbnailAspectRatio;
+ aspectRatio?: AspectRatio;
/** Whether the image has to fill its container's height. */
fillHeight?: boolean;
/** Avatar image. */
@@ -108,7 +109,7 @@ const CLASSNAME: string = getRootClassName(COMPONENT_NAME);
*/
const DEFAULT_PROPS: IDefaultPropsType = {
align: Alignment.left,
- aspectRatio: ThumbnailAspectRatio.original,
+ aspectRatio: AspectRatio.original,
fillHeight: false,
loading: ImageLoading.lazy,
size: undefined,
@@ -155,7 +156,7 @@ const Thumbnail: React.FC = ({
onKeyDown={onEnterPressed(onClick)}
{...restProps}
>
- {aspectRatio === ThumbnailAspectRatio.original ? (
+ {aspectRatio === AspectRatio.original ? (
) : (
diff --git a/src/components/uploader/angularjs/uploader.html b/src/components/uploader/angularjs/uploader.html
new file mode 100644
index 000000000..ed4908478
--- /dev/null
+++ b/src/components/uploader/angularjs/uploader.html
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
{{ lumx.label }}
+
+
diff --git a/src/components/uploader/angularjs/uploader_directive.js b/src/components/uploader/angularjs/uploader_directive.js
new file mode 100644
index 000000000..bc3d0ef0b
--- /dev/null
+++ b/src/components/uploader/angularjs/uploader_directive.js
@@ -0,0 +1,96 @@
+import { CSS_PREFIX } from 'LumX/core/constants';
+import { COMPONENT_PREFIX, MODULE_NAME } from 'LumX/angularjs/constants/common_constants';
+
+import template from './uploader.html';
+
+/////////////////////////////
+
+function UploaderController() {
+ 'ngInject';
+
+ // eslint-disable-next-line consistent-this
+ const lumx = this;
+
+ /////////////////////////////
+ // //
+ // Private attributes //
+ // //
+ /////////////////////////////
+
+ /**
+ * The default props.
+ *
+ * @type {Object}
+ * @constant
+ * @readonly
+ */
+ const _DEFAULT_PROPS = {
+ aspectRatio: 'horizontal',
+ size: 'xl',
+ theme: 'light',
+ variant: 'squared',
+ };
+
+ /////////////////////////////
+ // //
+ // Public functions //
+ // //
+ /////////////////////////////
+
+ /**
+ * Get button classes.
+ *
+ * @return {Array} The list of button classes.
+ */
+ function getClasses() {
+ const aspectRatio = lumx.aspectRatio ? lumx.aspectRatio : _DEFAULT_PROPS.aspectRatio;
+ const size = lumx.size ? lumx.size : _DEFAULT_PROPS.size;
+ const theme = lumx.theme ? lumx.theme : _DEFAULT_PROPS.theme;
+ const variant = lumx.variant ? lumx.variant : _DEFAULT_PROPS.variant;
+
+ // Adjust to square aspect ratio when using circle variants.
+ const adjustedAspectRatio = variant === 'circle' ? 'square' : aspectRatio;
+
+ return [
+ `${CSS_PREFIX}-uploader--aspect-ratio-${adjustedAspectRatio}`,
+ `${CSS_PREFIX}-uploader--size-${size}`,
+ `${CSS_PREFIX}-uploader--theme-${theme}`,
+ `${CSS_PREFIX}-uploader--variant-${variant}`,
+ ];
+ }
+
+ /////////////////////////////
+
+ lumx.getClasses = getClasses;
+}
+
+/////////////////////////////
+
+function UploaderDirective() {
+ 'ngInject';
+
+ return {
+ bindToController: true,
+ controller: UploaderController,
+ controllerAs: 'lumx',
+ replace: true,
+ restrict: 'E',
+ scope: {
+ aspectRatio: '@?lumxAspectRatio',
+ icon: '@?lumxIcon',
+ label: '@?lumxLabel',
+ size: '@?lumxSize',
+ theme: '@?lumxTheme',
+ variant: '@?lumxVariant',
+ },
+ template,
+ };
+}
+
+/////////////////////////////
+
+angular.module(`${MODULE_NAME}.uploader`).directive(`${COMPONENT_PREFIX}Uploader`, UploaderDirective);
+
+/////////////////////////////
+
+export { UploaderDirective };
diff --git a/src/components/uploader/react/Uploader.test.tsx b/src/components/uploader/react/Uploader.test.tsx
new file mode 100644
index 000000000..ce88a037a
--- /dev/null
+++ b/src/components/uploader/react/Uploader.test.tsx
@@ -0,0 +1,120 @@
+import React, { ReactElement } from 'react';
+
+import { mount, shallow } from 'enzyme';
+
+import { ICommonSetup, Wrapper, commonTestsSuite } from 'LumX/core/testing/utils.test';
+import { getBasicClass } from 'LumX/core/utils';
+
+import { CLASSNAME, DEFAULT_PROPS, Uploader, UploaderProps } from './Uploader';
+
+/////////////////////////////
+
+/**
+ * Define the overriding properties waited by the `setup` function.
+ */
+type ISetupProps = Partial;
+
+/**
+ * Defines what the `setup` function will return.
+ */
+interface ISetup extends ICommonSetup {
+ props: ISetupProps;
+
+ /**
+ * [Enter the description of this wrapper].
+ * [You should also probably change the name of the wrapper to something more meaningful].
+ */
+ wrapper: Wrapper;
+}
+
+/////////////////////////////
+
+/**
+ * Mounts the component and returns common DOM elements / data needed in multiple tests further down.
+ *
+ * @param props The props to use to override the default props of the component.
+ * @param [shallowRendering=true] Indicates if we want to do a shallow or a full rendering.
+ * @return An object with the props, the component wrapper and some shortcut to some element inside of the component.
+ */
+const setup = (props: ISetupProps = {}, shallowRendering: boolean = true): ISetup => {
+ const renderer: (el: ReactElement) => Wrapper = shallowRendering ? shallow : mount;
+
+ // @ts-ignore
+ const wrapper: Wrapper = renderer();
+
+ return {
+ props,
+ wrapper,
+ };
+};
+
+describe(`<${Uploader.displayName}>`, (): void => {
+ // 1. Test render via snapshot (default states of component).
+ describe('Snapshots and structure', (): void => {
+ // Here is an example of a basic rendering check, with snapshot.
+
+ it('should render correctly', (): void => {
+ const { wrapper } = setup();
+ expect(wrapper).toMatchSnapshot();
+
+ expect(wrapper).toExist();
+ expect(wrapper).toHaveClassName(CLASSNAME);
+ });
+ });
+
+ /////////////////////////////
+
+ // 2. Test defaultProps value and important props custom values.
+ describe('Props', (): void => {
+ // Here are some examples of basic props check.
+
+ it('should use default props', (): void => {
+ const { wrapper } = setup();
+
+ Object.keys(DEFAULT_PROPS).forEach((prop: string): void => {
+ expect(wrapper).toHaveClassName(
+ getBasicClass({ prefix: CLASSNAME, type: prop, value: DEFAULT_PROPS[prop] }),
+ );
+ });
+ });
+ });
+
+ /////////////////////////////
+
+ // 3. Test events.
+ describe('Events', (): void => {
+ // Here is an example how to check a `onClick` event.
+
+ const onClick: jest.Mock = jest.fn();
+
+ beforeEach((): void => {
+ onClick.mockClear();
+ });
+
+ it('should trigger `onClick` when clicked', (): void => {
+ const { wrapper } = setup({ onClick }, false);
+
+ wrapper.simulate('click');
+
+ expect(onClick).toHaveBeenCalled();
+ });
+ });
+ /////////////////////////////
+
+ // 4. Test conditions (i.e. things that display or not in the UI based on props).
+ describe('Conditions', (): void => {
+ // Nothing to do here.
+ });
+
+ /////////////////////////////
+
+ // 5. Test state.
+ describe('State', (): void => {
+ // Nothing to do here.
+ });
+
+ /////////////////////////////
+
+ // Common tests suite.
+ commonTestsSuite(setup, { className: 'wrapper', prop: 'wrapper' }, { className: CLASSNAME });
+});
diff --git a/src/components/uploader/react/Uploader.tsx b/src/components/uploader/react/Uploader.tsx
new file mode 100644
index 000000000..2c19cf41c
--- /dev/null
+++ b/src/components/uploader/react/Uploader.tsx
@@ -0,0 +1,137 @@
+import React, { MouseEventHandler, ReactElement } from 'react';
+
+import classNames from 'classnames';
+
+import { AspectRatio, Icon, Size, Theme } from 'LumX';
+import { COMPONENT_PREFIX } from 'LumX/core/react/constants';
+import { handleBasicClasses } from 'LumX/core/utils';
+import { IGenericProps, getRootClassName } from 'LumX/react/utils';
+
+/////////////////////////////
+
+enum UploaderVariant {
+ square = 'square',
+ rounded = 'rounded',
+ circle = 'circle',
+}
+
+type UploaderSize = Size.xl | Size.xxl;
+
+/**
+ * Defines the props of the component.
+ */
+interface IUploaderProps extends IGenericProps {
+ /**
+ * Aspect ratio
+ */
+ aspectRatio?: AspectRatio;
+ /**
+ * Icon
+ */
+ icon?: string;
+ /**
+ * Label
+ */
+ label?: string;
+ /**
+ * Size
+ */
+ size?: UploaderSize;
+ /**
+ * Theme
+ */
+ theme?: Theme;
+ /**
+ * Uploader variant
+ */
+ variant?: UploaderVariant;
+ /**
+ * On click handler
+ */
+ onClick?: MouseEventHandler;
+}
+type UploaderProps = IUploaderProps;
+
+/////////////////////////////
+
+/////////////////////////////
+// //
+// Public attributes //
+// //
+/////////////////////////////
+
+/**
+ * The display name of the component.
+ */
+const COMPONENT_NAME = `${COMPONENT_PREFIX}Uploader`;
+
+/**
+ * The default class name and classes prefix for this component.
+ */
+const CLASSNAME = getRootClassName(COMPONENT_NAME);
+
+/**
+ * The default value of props.
+ */
+const DEFAULT_PROPS: Partial = {
+ aspectRatio: AspectRatio.horizontal,
+ size: Size.xl,
+ theme: Theme.light,
+ variant: UploaderVariant.square,
+};
+
+/////////////////////////////
+
+/**
+ * [Enter the description of the component here].
+ *
+ * @return The component.
+ */
+const Uploader: React.FC = (props: UploaderProps): ReactElement => {
+ const {
+ aspectRatio = DEFAULT_PROPS.aspectRatio,
+ className,
+ label,
+ icon,
+ size = DEFAULT_PROPS.size,
+ theme = DEFAULT_PROPS.theme,
+ variant = DEFAULT_PROPS.variant,
+ ...forwardedProps
+ } = props;
+
+ // Adjust to square aspect ratio when using circle variants.
+ const adjustedAspectRatio = variant === UploaderVariant.circle ? AspectRatio.square : aspectRatio;
+
+ return (
+
+
+
+
+ {icon && (
+
+
+
+ )}
+
+ {label &&
{label}}
+
+
+ );
+};
+Uploader.displayName = COMPONENT_NAME;
+
+/////////////////////////////
+
+export { CLASSNAME, DEFAULT_PROPS, Uploader, UploaderProps, UploaderVariant };
diff --git a/src/components/uploader/react/__snapshots__/Uploader.test.tsx.snap b/src/components/uploader/react/__snapshots__/Uploader.test.tsx.snap
new file mode 100644
index 000000000..c3bf65cb3
--- /dev/null
+++ b/src/components/uploader/react/__snapshots__/Uploader.test.tsx.snap
@@ -0,0 +1,14 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[` Snapshots and structure should render correctly 1`] = `
+
+`;
diff --git a/src/components/uploader/style/lumapps/_index.scss b/src/components/uploader/style/lumapps/_index.scss
new file mode 100644
index 000000000..b2ad9de5e
--- /dev/null
+++ b/src/components/uploader/style/lumapps/_index.scss
@@ -0,0 +1,115 @@
+/* ==========================================================================
+ Uploader
+ ========================================================================== */
+
+.#{$lumx-base-prefix}-uploader {
+ $self: &;
+
+ @include lumx-state-transition;
+
+ position: relative;
+ cursor: pointer;
+ outline: none;
+
+ &--theme-light {
+ @include lumx-state('state-default', 'emphasis-medium', 'dark');
+
+ &:hover {
+ @include lumx-state('state-hover', 'emphasis-medium', 'dark');
+ }
+
+ &:active {
+ @include lumx-state('state-active', 'emphasis-medium', 'dark');
+ }
+
+ &[data-focus-visible-added] {
+ @include lumx-state('state-focus', 'emphasis-medium', 'dark');
+ }
+ }
+
+ &--theme-dark {
+ @include lumx-state('state-default', 'emphasis-medium', 'light');
+
+ &:hover {
+ @include lumx-state('state-hover', 'emphasis-medium', 'light');
+ }
+
+ &:active {
+ @include lumx-state('state-active', 'emphasis-medium', 'light');
+ }
+
+ &[data-focus-visible-added] {
+ @include lumx-state('state-focus', 'emphasis-medium', 'light');
+ }
+ }
+
+ &--variant-square {
+ border-radius: 0;
+ }
+
+ &--variant-rounded {
+ border-radius: $lumx-theme-border-radius;
+ }
+
+ &--variant-circle {
+ border-radius: 50%;
+ }
+
+ &__wrapper {
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 0 $lumx-spacing-unit * 2;
+ user-select: none;
+ }
+
+ &__icon {
+ margin-bottom: $lumx-spacing-unit;
+
+ #{$self}--theme-light & {
+ color: lumx-theme-color-variant('dark', 'N');
+ }
+
+ #{$self}--theme-dark & {
+ color: lumx-theme-color-variant('light', 'N');
+ }
+ }
+
+ &__label {
+ @include lumx-typography('subtitle1');
+
+ text-align: center;
+
+ #{$self}--theme-light & {
+ color: lumx-theme-color-variant('dark', 'N');
+ }
+
+ #{$self}--theme-dark & {
+ color: lumx-theme-color-variant('light', 'N');
+ }
+ }
+}
+
+/* Uploader sizes
+ ========================================================================== */
+
+@each $key, $size in $lumx-sizes {
+ .#{$lumx-base-prefix}-uploader--size-#{$key} {
+ width: $size;
+ }
+}
+
+/* Uploader aspect ratio
+ ========================================================================== */
+
+@each $key, $aspect-ratio in $lumx-thumbnail-aspect-ratio {
+ .#{$lumx-base-prefix}-uploader--aspect-ratio-#{$key} .#{$lumx-base-prefix}-uploader__background {
+ padding-top: $aspect-ratio;
+ }
+}
diff --git a/src/core/angularjs/lumx.js b/src/core/angularjs/lumx.js
index 9c039677a..b1ad6640e 100644
--- a/src/core/angularjs/lumx.js
+++ b/src/core/angularjs/lumx.js
@@ -52,6 +52,7 @@ angular.module(`${MODULE_NAME}.theme`, []);
angular.module(`${MODULE_NAME}.thumbnail`, []);
angular.module(`${MODULE_NAME}.toolbar`, []);
angular.module(`${MODULE_NAME}.tooltip`, []);
+angular.module(`${MODULE_NAME}.uploader`, []);
angular.module(`${MODULE_NAME}.user-block`, []);
angular.module(MODULE_NAME, [
@@ -90,5 +91,6 @@ angular.module(MODULE_NAME, [
`${MODULE_NAME}.thumbnail`,
`${MODULE_NAME}.toolbar`,
`${MODULE_NAME}.tooltip`,
+ `${MODULE_NAME}.uploader`,
`${MODULE_NAME}.user-block`,
]);
diff --git a/src/core/style/lumx-theme-lumapps.scss b/src/core/style/lumx-theme-lumapps.scss
index 7369d5199..44946dec4 100644
--- a/src/core/style/lumx-theme-lumapps.scss
+++ b/src/core/style/lumx-theme-lumapps.scss
@@ -42,4 +42,5 @@
@import '../../components/thumbnail/style/lumapps/index';
@import '../../components/toolbar/style/lumapps/index';
@import '../../components/tooltip/style/lumapps/index';
+@import '../../components/uploader/style/lumapps/index';
@import '../../components/user-block/style/lumapps/index';
diff --git a/src/react.index.ts b/src/react.index.ts
index c2fdd4e7a..81d9f98e4 100644
--- a/src/react.index.ts
+++ b/src/react.index.ts
@@ -116,3 +116,5 @@ export { SideNavigationItem, SideNavigationItemProps } from 'LumX/components/sid
export { Slider } from 'LumX/components/slider/react/Slider';
export { Dialog, DialogSizes } from 'LumX/components/dialog/react/Dialog';
+
+export { Uploader, UploaderProps, UploaderVariant } from 'LumX/components/uploader/react/Uploader';