diff --git a/src/components/examples.js b/src/components/examples.js
index 02a8dbf39..1709228a2 100644
--- a/src/components/examples.js
+++ b/src/components/examples.js
@@ -22,6 +22,7 @@ import links from 'componentsdir/link/examples/Links.vue';
import list from 'componentsdir/list/examples/Lists.vue';
import mediaObject from 'componentsdir/mediaObject/examples/MediaObject.vue';
import modal from 'componentsdir/modal/examples/Modal.vue';
+import objectOverlay from 'componentsdir/objectOverlay/examples/ObjectOverlay.vue';
import pagination from 'componentsdir/pagination/examples/Pagination.vue';
import picture from 'componentsdir/picture/examples/Picture.vue';
import popover from 'componentsdir/popover/examples/Popover.vue';
@@ -70,6 +71,7 @@ export default {
list,
mediaObject,
modal,
+ objectOverlay,
pagination,
picture,
popover,
diff --git a/src/components/objectOverlay/CdrObjectOverlay.vue b/src/components/objectOverlay/CdrObjectOverlay.vue
new file mode 100644
index 000000000..06954793d
--- /dev/null
+++ b/src/components/objectOverlay/CdrObjectOverlay.vue
@@ -0,0 +1,227 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/components/objectOverlay/__tests__/CdrObjectOverlay.spec.js b/src/components/objectOverlay/__tests__/CdrObjectOverlay.spec.js
new file mode 100644
index 000000000..0cb7eb265
--- /dev/null
+++ b/src/components/objectOverlay/__tests__/CdrObjectOverlay.spec.js
@@ -0,0 +1,229 @@
+import { mount } from '../../../../test/vue-jest-style-workaround.js';
+import CdrObjectOverlay from '../CdrObjectOverlay.vue';
+
+describe('CdrObjectOverlay.vue', () => {
+ describe('default rendering', () => {
+ let wrapper;
+ let elem;
+
+ beforeEach(() => {
+ elem = document.createElement('div');
+ document.body.appendChild(elem);
+ wrapper = mount(CdrObjectOverlay, {
+ props: {},
+ slots: {
+ container: '
Container Content
',
+ content: 'Overlay Content
',
+ },
+ attachTo: elem,
+ });
+ });
+
+ afterEach(() => {
+ wrapper.unmount();
+ elem.remove();
+ });
+
+ it('renders correctly with default props', () => {
+ // Default tag is "div"
+ expect(wrapper.element.tagName).toBe('DIV');
+ // Check default data attribute for position
+ expect(wrapper.attributes('data-position')).toBe('center-center');
+ // Verify that the "container" and "content" slots are rendered properly
+ const containerSlot = wrapper.find('.cdr-object-overlay__container');
+ expect(containerSlot.exists()).toBe(true);
+ expect(containerSlot.text()).toContain('Container Content');
+
+ const contentSlot = wrapper.find('.cdr-object-overlay__content');
+ expect(contentSlot.exists()).toBe(true);
+ expect(contentSlot.text()).toContain('Overlay Content');
+ // Check that content has default margin style
+ expect(contentSlot.attributes('style')).toContain('--margin: var(--cdr-space-zero)');
+ });
+
+ it('matches the snapshot', () => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+ });
+
+ describe('prop handling', () => {
+ it('applies the correct data attribute for each position', () => {
+ const positions = [
+ 'top-left',
+ 'top-center',
+ 'top-right',
+ 'center-left',
+ 'center-center',
+ 'center-right',
+ 'bottom-left',
+ 'bottom-center',
+ 'bottom-right',
+ ];
+ positions.forEach((position) => {
+ const wrapper = mount(CdrObjectOverlay, {
+ props: { position },
+ slots: {
+ container: 'Container
',
+ content: 'Content
',
+ },
+ });
+ expect(wrapper.attributes('data-position')).toBe(position);
+ wrapper.unmount();
+ });
+ });
+
+ it('applies margin through CSS variables', () => {
+ const wrapper = mount(CdrObjectOverlay, {
+ props: { margin: 'half-x' },
+ slots: {
+ container: 'Container
',
+ content: 'Content
',
+ },
+ });
+ const contentEl = wrapper.find('.cdr-object-overlay__content');
+ expect(contentEl.attributes('style')).toContain('--margin: var(--cdr-space-half-x)');
+ wrapper.unmount();
+ });
+
+ it('applies padding through CSS variables', () => {
+ const wrapper = mount(CdrObjectOverlay, {
+ props: { padding: 'half-x' },
+ slots: {
+ container: 'Container
',
+ content: 'Content
',
+ },
+ });
+ const contentEl = wrapper.find('.cdr-object-overlay__content');
+ expect(contentEl.attributes('style')).toContain('--padding: var(--cdr-space-half-x)');
+ wrapper.unmount();
+ });
+
+ it('handles responsive position values', () => {
+ const wrapper = mount(CdrObjectOverlay, {
+ props: {
+ position: {
+ xs: 'center-center',
+ lg: 'bottom-right'
+ }
+ },
+ slots: {
+ container: 'Container
',
+ content: 'Content
',
+ },
+ });
+ expect(wrapper.attributes('data-position')).toBe('center-center');
+ expect(wrapper.attributes('data-position-lg')).toBe('bottom-right');
+ wrapper.unmount();
+ });
+
+ it('handles responsive margin values', () => {
+ const wrapper = mount(CdrObjectOverlay, {
+ props: {
+ margin: {
+ xs: 'zero',
+ lg: 'half-x'
+ }
+ },
+ slots: {
+ container: 'Container
',
+ content: 'Content
',
+ },
+ });
+ const contentEl = wrapper.find('.cdr-object-overlay__content');
+ expect(contentEl.attributes('style')).toContain('--margin-xs: var(--cdr-space-zero)');
+ expect(contentEl.attributes('style')).toContain('--margin-lg: var(--cdr-space-half-x)');
+ wrapper.unmount();
+ });
+
+ it('uses a custom HTML tag when the "tag" prop is specified', () => {
+ const wrapper = mount(CdrObjectOverlay, {
+ props: { tag: 'section' },
+ slots: {
+ container: 'Container
',
+ content: 'Content
',
+ },
+ });
+ expect(wrapper.element.tagName).toBe('SECTION');
+ wrapper.unmount();
+ });
+
+ // New tests for gradientTheme
+ it('applies dark gradient theme by default when withGradient is true', () => {
+ const wrapper = mount(CdrObjectOverlay, {
+ props: {
+ withGradient: true,
+ position: 'left-top'
+ },
+ slots: {
+ container: 'Container
',
+ content: 'Content
',
+ },
+ });
+ expect(wrapper.attributes('data-gradient')).toBe('to-bottom');
+ expect(wrapper.attributes('data-gradient-theme')).toBe('dark');
+ wrapper.unmount();
+ });
+
+ it('applies light gradient theme when specified', () => {
+ const wrapper = mount(CdrObjectOverlay, {
+ props: {
+ withGradient: true,
+ gradientTheme: 'light',
+ position: 'left-top'
+ },
+ slots: {
+ container: 'Container
',
+ content: 'Content
',
+ },
+ });
+ expect(wrapper.attributes('data-gradient')).toBe('to-bottom');
+ expect(wrapper.attributes('data-gradient-theme')).toBe('light');
+ wrapper.unmount();
+ });
+
+ it('applies correct gradient direction based on position with light theme', () => {
+ const positionsAndDirections = [
+ { position: 'left-top', direction: 'to-bottom' },
+ { position: 'center-top', direction: 'to-bottom' },
+ { position: 'right-top', direction: 'to-bottom' },
+ { position: 'left-center', direction: 'to-right' },
+ { position: 'right-center', direction: 'to-left' },
+ { position: 'left-bottom', direction: 'to-top' },
+ { position: 'center-bottom', direction: 'to-top' },
+ { position: 'right-bottom', direction: 'to-top' },
+ ];
+
+ positionsAndDirections.forEach(({ position, direction }) => {
+ const wrapper = mount(CdrObjectOverlay, {
+ props: {
+ withGradient: true,
+ gradientTheme: 'light',
+ position
+ },
+ slots: {
+ container: 'Container
',
+ content: 'Content
',
+ },
+ });
+ expect(wrapper.attributes('data-gradient')).toBe(direction);
+ expect(wrapper.attributes('data-gradient-theme')).toBe('light');
+ wrapper.unmount();
+ });
+ });
+
+ it('does not apply gradient when position is center-center', () => {
+ const wrapper = mount(CdrObjectOverlay, {
+ props: {
+ withGradient: true,
+ position: 'center-center'
+ },
+ slots: {
+ container: 'Container
',
+ content: 'Content
',
+ },
+ });
+ expect(wrapper.attributes('data-gradient')).toBeUndefined();
+ wrapper.unmount();
+ });
+ });
+});
\ No newline at end of file
diff --git a/src/components/objectOverlay/__tests__/__snapshots__/CdrObjectOverlay.spec.js.snap b/src/components/objectOverlay/__tests__/__snapshots__/CdrObjectOverlay.spec.js.snap
new file mode 100644
index 000000000..0fafd137c
--- /dev/null
+++ b/src/components/objectOverlay/__tests__/__snapshots__/CdrObjectOverlay.spec.js.snap
@@ -0,0 +1,34 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`CdrObjectOverlay.vue > default rendering > matches the snapshot 1`] = `
+
+
+
+
+
+ Container Content
+
+
+
+
+
+
+
+ Overlay Content
+
+
+
+
+`;
diff --git a/src/components/objectOverlay/examples/ObjectOverlay.vue b/src/components/objectOverlay/examples/ObjectOverlay.vue
new file mode 100644
index 000000000..20fadf0ba
--- /dev/null
+++ b/src/components/objectOverlay/examples/ObjectOverlay.vue
@@ -0,0 +1,242 @@
+
+
+
+
+
Media Object with Overlay
+
+
+
+
+
+
+
+
+
+ Positioned Content
+
+
+
+
+
Media Object with Multiple Overlays
+
+
+
+
+
+
+
+
+ Top Left Overlay
+
+
+
+
+
+
+
+
+
+ Bottom Right Overlay
+
+
+
+
+
+
+
Media Object Title
+
This is the content of the media object.
+
+
+
+
+
+
+
Media Object with Responsive Overlay
+
+
+
+
+
+
+
+
+ Responsive Overlay
+
+
+
+
+
+
+
Media Object Title
+
This is the content of the media object.
+
+
+
+
+
+
+
Media Object with Multiple Overlays and Content
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Bottom Right Overlay
+
+
+
+
+
+
+
+
+
+ Center Overlay
+
+
+
+
+
+
+
Media Object Title
+
This is the content of the media object.
+
+
+
+
+
+
+
Media Object with Square Image
+
+
+
+
+
+
+
+
+ Center Overlay
+
+
+
+
+
+
+
Media Object Title
+
This is the content of the media object.
+
+
+
+
+
+
+
Media Object with Tall Image
+
+
+
+
+
+
+
+
+ Center Overlay
+
+
+
+
+
+
+
Media Object Title
+
This is the content of the media object.
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/components/objectOverlay/styles/CdrObjectOverlay.module.scss b/src/components/objectOverlay/styles/CdrObjectOverlay.module.scss
new file mode 100644
index 000000000..7ae7ec0e5
--- /dev/null
+++ b/src/components/objectOverlay/styles/CdrObjectOverlay.module.scss
@@ -0,0 +1,133 @@
+@use 'sass:map';
+@import '../../../styles/settings/index';
+
+$breakpoints: (
+ '': '',
+ '-sm': $cdr-breakpoint-sm,
+ '-md': $cdr-breakpoint-md,
+ '-lg': $cdr-breakpoint-lg
+);
+
+$positions: (
+ 'left-top': (top: 0, left: 0, transform: none),
+ 'center-top': (top: 0, left: 50%, transform: translateX(-50%)),
+ 'right-top': (top: 0, right: 0, transform: none),
+ 'left-center': (top: 50%, left: 0, transform: translateY(-50%)),
+ 'center-center': (top: 50%, left: 50%, transform: translate(-50%, -50%)),
+ 'right-center': (top: 50%, right: 0, transform: translateY(-50%)),
+ 'left-bottom': (bottom: 0, left: 0, transform: none),
+ 'center-bottom': (bottom: 0, left: 50%, transform: translateX(-50%)),
+ 'right-bottom': (bottom: 0, right: 0, transform: none)
+);
+
+// Dark theme gradients (default)
+$dark-gradients: (
+ 'to-top': linear-gradient(to top, black 0%, rgba(0, 0, 0, 0.5) 20%, transparent 40%),
+ 'to-bottom': linear-gradient(to bottom, black 0%, rgba(0, 0, 0, 0.5) 20%, transparent 40%),
+ 'to-left': linear-gradient(to left, black 0%, rgba(0, 0, 0, 0.5) 20%, transparent 40%),
+ 'to-right': linear-gradient(to right, black 0%, rgba(0, 0, 0, 0.5) 20%, transparent 40%)
+);
+
+// Light theme gradients
+$light-gradients: (
+ 'to-top': linear-gradient(to top, white 0%, rgba($cdr-color-background-primary, 0.5) 20%, transparent 40%),
+ 'to-bottom': linear-gradient(to bottom, white 0%, rgba($cdr-color-background-primary, 0.5) 20%, transparent 40%),
+ 'to-left': linear-gradient(to left, white 0%, rgba($cdr-color-background-primary, 0.5) 20%, transparent 40%),
+ 'to-right': linear-gradient(to right, white 0%, rgba($cdr-color-background-primary, 0.5) 20%, transparent 40%)
+);
+
+.cdr-object-overlay {
+ overflow: hidden;
+ position: relative;
+ width: 100%;
+ height: 100%;
+
+ &__container {
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+ }
+
+ &__content {
+ position: absolute;
+ margin: var(--margin);
+ padding: var(--padding);
+ z-index: 2;
+
+ @each $breakpoint-suffix, $min-width in $breakpoints {
+ @if $breakpoint-suffix != '' {
+ @media (min-width: $min-width) {
+ margin: var(--margin#{$breakpoint-suffix}, var(--margin));
+ padding: var(--padding#{$breakpoint-suffix}, var(--padding));
+ }
+ }
+ }
+ }
+
+ // Create a mixin for breakpoints
+ @mixin at-breakpoint($breakpoint) {
+ @if $breakpoint == '' {
+ @content;
+ } @else {
+ @media (min-width: map.get($breakpoints, $breakpoint)) {
+ @content;
+ }
+ }
+ }
+
+ // Generate positions for all breakpoints
+ @each $breakpoint-suffix, $min-width in $breakpoints {
+ @include at-breakpoint($breakpoint-suffix) {
+ // Position
+ @each $position, $properties in $positions {
+ $selector: if($breakpoint-suffix == '',
+ '[data-position="#{$position}"]',
+ '[data-position#{$breakpoint-suffix}="#{$position}"]');
+
+ {$selector} &__content {
+ @each $prop, $value in $properties {
+ #{$prop}: $value;
+ }
+ // Set unused positions to auto
+ @each $side in top, right, bottom, left {
+ @if not map-has-key($properties, $side) {
+ #{$side}: auto;
+ }
+ }
+ }
+ }
+
+ // Dark theme gradients (default)
+ @each $gradient, $direction in $dark-gradients {
+ $selector: if($breakpoint-suffix == '',
+ '[data-gradient="#{$gradient}"]',
+ '[data-gradient#{$breakpoint-suffix}="#{$gradient}"]');
+
+ {$selector}:not([data-gradient-theme="light"])::before {
+ background-image: $direction;
+ border-radius: inherit;
+ display: block;
+ content: '';
+ position: absolute;
+ inset: 0;
+ }
+ }
+
+ // Light theme gradients
+ @each $gradient, $direction in $light-gradients {
+ $selector: if($breakpoint-suffix == '',
+ '[data-gradient="#{$gradient}"]',
+ '[data-gradient#{$breakpoint-suffix}="#{$gradient}"]');
+
+ {$selector}[data-gradient-theme="light"]::before {
+ background-image: $direction;
+ border-radius: inherit;
+ display: block;
+ content: '';
+ position: absolute;
+ inset: 0;
+ }
+ }
+ }
+ }
+}
diff --git a/src/dev/router.js b/src/dev/router.js
index 8bf672da5..bc7ed085e 100644
--- a/src/dev/router.js
+++ b/src/dev/router.js
@@ -42,6 +42,7 @@ const routes = [
{ path: '/links', name: 'Links', component: Examples.links },
{ path: '/lists', name: 'Lists', component: Examples.list },
{ path: '/mediaObject', name: 'Media Object', component: Examples.mediaObject },
+ { path: '/object-overlay', name: 'Object Overlay', component: Examples.objectOverlay },
{ path: '/modals', name: 'Modals', component: Examples.modal },
{ path: '/pagination', name: 'Pagination', component: Examples.pagination },
{ path: '/picture', name: 'Picture', component: Examples.picture },
diff --git a/src/lib.ts b/src/lib.ts
index 4ad55daed..6cace409a 100644
--- a/src/lib.ts
+++ b/src/lib.ts
@@ -28,6 +28,7 @@ export { default as CdrLink } from './components/link/CdrLink.vue';
export { default as CdrList } from './components/list/CdrList.vue';
export { default as CdrMediaObject } from './components/mediaObject/CdrMediaObject.vue';
export { default as CdrModal } from './components/modal/CdrModal.vue';
+export { default as CdrObjectOverlay } from './components/objectOverlay/CdrObjectOverlay.vue';
export { default as CdrPagination } from './components/pagination/CdrPagination.vue';
export { default as CdrPicture } from './components/picture/CdrPicture.vue';
export { default as CdrPopover } from './components/popover/CdrPopover.vue';
diff --git a/src/types/interfaces.ts b/src/types/interfaces.ts
index d7a1e1658..6500804e2 100644
--- a/src/types/interfaces.ts
+++ b/src/types/interfaces.ts
@@ -3,7 +3,6 @@ import type {
Tag,
Space,
SpaceFixed,
- SpaceOption,
Shadow,
Radius,
BorderColor,
@@ -17,7 +16,7 @@ import type {
Position,
Alignment,
AlignmentValue,
- MediaMeasurement,
+ MediaMeasurement
} from './other';
/* eslint-disable @typescript-eslint/no-explicit-any */
@@ -361,7 +360,55 @@ export interface MediaObject extends Layout {
/**
* The spacing token to use for padding around the content. This can be an object with values for each Cedar breakpoint (xs, sm, md, lg).
* @demoSelectMultiple false
- * @values zero, one-x, two-x, scale-4, scale-3--5
+ * @values zero, one-x, two-x
*/
- contentPadding?: SpaceOption;
+ contentPadding?: SpaceFixed;
}
+
+export type ObjectPosition =
+ | 'left-top'
+ | 'center-top'
+ | 'right-top'
+ | 'left-center'
+ | 'center-center'
+ | 'right-center'
+ | 'left-bottom'
+ | 'center-bottom'
+ | 'right-bottom';
+
+export type ResponsivePosition = {
+ xs?: ObjectPosition;
+ sm?: ObjectPosition;
+ md?: ObjectPosition;
+ lg?: ObjectPosition;
+};
+
+export type SpaceTuple =
+ | [SpaceFixed]
+ | [SpaceFixed, SpaceFixed]
+ | [SpaceFixed, SpaceFixed, SpaceFixed]
+ | [SpaceFixed, SpaceFixed, SpaceFixed, SpaceFixed];
+
+export type Spacing = SpaceFixed | SpaceTuple;
+
+export type ResponsiveSpace = {
+ xs?: Spacing;
+ sm?: Spacing;
+ md?: Spacing;
+ lg?: Spacing;
+};
+
+export interface ObjectOverlayProps {
+ /** Determines if the container will have a gradient based on position */
+ withGradient?: boolean;
+ /** Theme for the gradient (dark or light) */
+ gradientTheme?: 'dark' | 'light';
+ /** Position of the content relative to the container */
+ position?: ResponsivePosition | ObjectPosition;
+ /** Margin space around the positioned content */
+ margin?: ResponsiveSpace | Spacing;
+ /** Padding space around the positioned content */
+ padding?: ResponsiveSpace | Spacing;
+ /** Sets the HTML tag for the container element */
+ tag?: string;
+}
\ No newline at end of file