diff --git a/src/stable/components/News/News.css b/src/stable/components/News/News.css new file mode 100644 index 00000000..37297243 --- /dev/null +++ b/src/stable/components/News/News.css @@ -0,0 +1,81 @@ +@import url('https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,300;0,400&display=swap'); +.news-card { + min-width: 340px; + width: 100%; + box-sizing: border-box; + background: #fff; + border-radius: 8px; + padding: 20px 24px; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06); +} + +.news-header { + display: block; + margin-bottom: 4px; +} + +.news-title { + font-family: 'Montserrat', sans-serif; + font-weight: 400; + font-size: 16px; + line-height: 1; + letter-spacing: 0; + margin: 0; + display: inline; + color: #222; + cursor: pointer; +} +.news-title:hover .chevron-icon, +.news-title .chevron-icon:hover { + transform: translateX(4px); + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.chevron-icon { + stroke: #333; + width: 1em; + height: 1em; + vertical-align: -0.15em; + margin-left: 0.15em; + display: inline-block; + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); + pointer-events: auto; + cursor: pointer; +} + +.news-meta { + margin-top: 10px; + display: flex; + align-items: center; + gap: 14px; +} + +#news-date { + font-family: 'Montserrat', sans-serif; + font-weight: 300; + font-style: italic; + font-size: 14px; + line-height: 1; + letter-spacing: 0; + color: #444; + cursor: pointer; +} +#news-date:hover ~ .news-title .chevron-icon { + transform: translateX(4px); +} + +.news-tags { + font-family: 'Montserrat', sans-serif; + font-weight: 300; + font-style: italic; + font-size: 14px; + line-height: 1; + letter-spacing: 0; + color: #444; + cursor: pointer; +} +.news-tags:hover ~ .news-title .chevron-icon { + transform: translateX(4px); +} + +/*# sourceMappingURL=News.css.map */ diff --git a/src/stable/components/News/News.css.map b/src/stable/components/News/News.css.map new file mode 100644 index 00000000..459716b8 --- /dev/null +++ b/src/stable/components/News/News.css.map @@ -0,0 +1 @@ +{"version":3,"sourceRoot":"","sources":["News.scss"],"names":[],"mappings":"AAAQ;AAER;EACE;EACA;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EAQA;;AALA;AAAA;EAEE;EACA;;;AAKJ;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;EACA;EACA;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAGA;EACE;;;AAIJ;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAGA;EACE","file":"News.css"} \ No newline at end of file diff --git a/src/stable/components/News/News.js b/src/stable/components/News/News.js new file mode 100644 index 00000000..c8a400d8 --- /dev/null +++ b/src/stable/components/News/News.js @@ -0,0 +1,78 @@ +import styles from '!!raw-loader!./News.css'; + +const template = document.createElement('template'); +template.innerHTML = ` + + +
+ +
+

+ Default news title: Lorem ipsum dolor sit amet. + +

+
+ + +
+ + + + +
+ +
+`; + +class News extends HTMLElement { + constructor() { + super(); + const shadow = this.attachShadow({ mode: 'open' }); + shadow.appendChild(template.content.cloneNode(true)); + } + + static get observedAttributes() { + return ['datetime']; + } + + attributeChangedCallback(name, oldValue, newValue) { + if (name === 'datetime') { + this.formatDate(newValue); + } + } + + connectedCallback() { + const date = this.getAttribute('datetime'); + this.formatDate(date); + } + + formatDate(dateStr) { + // Parse the string into a Date object + const date = new Date(dateStr); + + // Validate the date + if (isNaN(date.getTime())) { + this.shadowRoot.querySelector('#news-date').textContent = 'Invalid date'; + return; + } + + // Format the date — e.g., "June 1, 2024" + const formatted = date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + }); + + // Insert the formatted date into the DOM + const dateElement = this.shadowRoot.querySelector('#news-date'); + if (dateElement) { + dateElement.textContent = formatted; + } + } +} + +export { News as default }; diff --git a/src/stable/components/News/News.scss b/src/stable/components/News/News.scss new file mode 100644 index 00000000..744f7b34 --- /dev/null +++ b/src/stable/components/News/News.scss @@ -0,0 +1,86 @@ +@import url('https://fonts.googleapis.com/css2?family=Montserrat:ital,wght@0,300;0,400&display=swap'); + +.news-card { + min-width: 340px; + width: 100%; + box-sizing: border-box; + background: #fff; + border-radius: 8px; + padding: 20px 24px; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06); +} + +.news-header { + display: block; + margin-bottom: 4px; +} + +.news-title { + font-family: 'Montserrat', sans-serif; + font-weight: 400; + font-size: 16px; + line-height: 1; + letter-spacing: 0; + margin: 0; + display: inline; + color: #222; + + // Chevron hover effect + &:hover .chevron-icon, + .chevron-icon:hover { + transform: translateX(4px); + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); + } + cursor: pointer; +} + +.chevron-icon { + stroke: #333; + width: 1em; + height: 1em; + vertical-align: -0.15em; + margin-left: 0.15em; + display: inline-block; + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); + pointer-events: auto; + cursor: pointer; +} + +.news-meta { + margin-top: 10px; + display: flex; + align-items: center; + gap: 14px; +} + +#news-date { + font-family: 'Montserrat', sans-serif; + font-weight: 300; + font-style: italic; + font-size: 14px; + line-height: 1; + letter-spacing: 0; + color: #444; + cursor: pointer; + + // Chevron hover effect when hovering date + &:hover ~ .news-title .chevron-icon { + transform: translateX(4px); + } +} + +.news-tags { + font-family: 'Montserrat', sans-serif; + font-weight: 300; + font-style: italic; + font-size: 14px; + line-height: 1; + letter-spacing: 0; + color: #444; + cursor: pointer; + + // Chevron hover effect when hovering tags + &:hover ~ .news-title .chevron-icon { + transform: translateX(4px); + } +} diff --git a/src/stable/components/News/cod-news.js b/src/stable/components/News/cod-news.js new file mode 100644 index 00000000..b06814a9 --- /dev/null +++ b/src/stable/components/News/cod-news.js @@ -0,0 +1,2 @@ +import News from './News'; +customElements.define('cod-news', News); diff --git a/src/stable/docs/News.mdx b/src/stable/docs/News.mdx new file mode 100644 index 00000000..0036eb15 --- /dev/null +++ b/src/stable/docs/News.mdx @@ -0,0 +1,94 @@ +import { + Meta, + Title, + Subtitle, + Description, + Primary, + Controls, + Stories, + Story, + Source, +} from '@storybook/blocks'; + +import * as NewsStories from '../stories/news.stories'; + +import '../../../.storybook/docs'; // Import all documentation components + + + +# News + +
+ {' '} + The **News** component displays a stylized news card with a title, date, and optional + tags slot. It formats the `datetime` attribute automatically. +
+ +## Examples + +### Basic + + + + + +## Slots + + + + +## HTML Attributes / JS Properties + +Supported HTML attributes. If the attribute is reflected in a JS property, that attribute will have a 'reflects' tag next to the name. + + + + +## Events + + + + +## Methods + + + + +## Custom CSS Properties + + + + +## CSS Parts + + + + +## Dependencies + + + +## Accessibility + +The News component uses semantic HTML: + +- The title is wrapped in a `

` for hierarchy. +- Date text is plain text. +- The chevron icon uses `aria-hidden="true"` and is not focusable. diff --git a/src/stable/index-stable.js b/src/stable/index-stable.js index 50a9c274..ded78403 100644 --- a/src/stable/index-stable.js +++ b/src/stable/index-stable.js @@ -2,3 +2,4 @@ import './components/GovBanner/cod-gov-banner.js'; import './components/SectionNavigation/cod-section-navigation.js'; import './components/ServiceButton/cod-service-button.js'; import './components/Tag/cod-tag.js'; +import './components/News/cod-news.js'; diff --git a/src/stable/stories/news.stories.js b/src/stable/stories/news.stories.js new file mode 100644 index 00000000..d5e97844 --- /dev/null +++ b/src/stable/stories/news.stories.js @@ -0,0 +1,81 @@ +import { html } from 'lit-html'; +import { expect } from '@storybook/test'; +import { userEvent } from '@storybook/test'; +import { waitFor } from '@storybook/test'; +import '../components/News/cod-news.js'; +import '../components/Tag/cod-tag.js'; + +export default { + title: 'Components/News', + tags: ['stable'], +}; + +export const Basic = { + render: () => html` + + + + + Today's Headlines: Lorem ipsum dolor sit amet. + + + Breaking News + + + `, + // ✅ Click handler version: + play: async ({ canvasElement }) => { + const news = canvasElement.querySelector('cod-news'); + expect(news).toBeTruthy(); + + await waitFor(() => { + if (!news.shadowRoot) throw new Error('Shadow root not ready'); + }); + + const shadow = news.shadowRoot; + + const slot = shadow.querySelector('#newsTitleSlot'); + expect(slot).toBeTruthy(); + + await waitFor(() => { + if (slot.assignedNodes().length === 0) { + throw new Error('Slot has no assigned nodes'); + } + }); + + const assigned = slot.assignedNodes({ flatten: true }); + const link = assigned.find((n) => n.nodeType === Node.ELEMENT_NODE); + expect(link).toBeTruthy(); + expect(link.tagName).toBe('A'); + expect(link.getAttribute('href')).toBe('https://example.com/article'); + expect(link.textContent).toContain("Today's Headlines"); + + // ✅ Prevent default to stay on Storybook page: + link.addEventListener('click', (e) => e.preventDefault()); + + await userEvent.click(link); + + const dateEl = shadow.querySelector('#news-date'); + expect(dateEl).toBeTruthy(); + expect(dateEl.textContent).toBe('June 9, 2025'); + + const tagsSlot = shadow.querySelector('#tagsSlot'); + expect(tagsSlot).toBeTruthy(); + await waitFor(() => { + if (tagsSlot.assignedNodes().length === 0) { + throw new Error('Tags slot empty'); + } + }); + + const tagNode = tagsSlot + .assignedNodes() + .find((n) => n.nodeType === Node.ELEMENT_NODE); + expect(tagNode).toBeTruthy(); + expect(tagNode.tagName).toBe('COD-TAG'); + }, +};