diff --git a/src/main/resources/assets/styles/_timeline.scss b/src/main/resources/assets/styles/_timeline.scss new file mode 100644 index 0000000000..5d3d3418c6 --- /dev/null +++ b/src/main/resources/assets/styles/_timeline.scss @@ -0,0 +1,207 @@ +.ssb-timeline { + .title-container { + display: grid; + grid-template-columns: repeat(12, 1fr); + margin-top: 4rem; + margin-bottom: 5rem; + + .title-ingress-wrapper { + grid-column: 2 / span 10; + + .title { + display: block; + margin: 0 auto; + } + .ingress { + display: block; + margin: 0 auto; + font-size: 1.25rem; + } + } + } + + .filter-container { + display: flex; + justify-content: center; + + .filter { + display: flex; + grid-column: 3 / span 8; + flex-wrap: wrap; + gap: 0.75rem; + margin-bottom: 4rem; + width: 740px; + + .ssb-tag { + &.active { + background: $ssb-dark-5; + border: 2px solid $ssb-dark-5; + color: $ssb-white; + } + } + } + } + + .timeline-container { + display: flex; + justify-content: center; + } + + .timeline { + position: relative; + padding-left: 20px; + width: 740px; + + .circle { + width: 16px; + height: 16px; + background-color: #274247; + border-radius: 50%; + position: absolute; + top: -10px; + transform: translateX(-40%); + } + + &:before { + content: ''; + width: 4px; + height: 100%; + background: #274247; + position: absolute; + top: 0; + } + + &:after { + content: ''; + display: block; + width: 100%; + clear: both; + } + + .timeline-elements { + padding: 25px 0; + list-style: none; + } + + .timeline-content { + left: -20px; + position: relative; + margin-bottom: 30px; + + .year { + width: 180px; + height: 80px; + background: #274247; + border-radius: 4px; + position: absolute; + align-content: center; + + &:after { + content: ''; + position: absolute; + top: 0; + right: -1px; + width: 0; + height: 0; + border-top: 40px solid transparent; + border-bottom: 40px solid transparent; + border-right: 20px solid #f0f8f9; + border-radius: 4px; + } + + span { + @include roboto-condenced; + color: #fff; + padding: 20px 40px; + font-size: 28px; + } + } + + .events { + padding-top: 90px; + width: 100%; + min-width: 400px; + position: relative; + + .event { + margin-bottom: 20px; + + &:first-child { + margin-top: 20px; + } + + &:last-child { + margin-bottom: 80px; + } + + .ssb-card { + max-width: 400px; + } + } + } + + .event-box { + background: #fff; + border: 1px solid #274247; + border-radius: 8px; + display: grid; + padding: 20px 40px; + width: 100%; + .title { + @include roboto; + font-weight: bold; + font-size: 20px; + } + .text { + margin-top: 10px; + } + } + } + } + + .button-more { + left: -20px; + top: -30px; + position: relative; + } + + @include media-breakpoint-down(lg) { + .title-container { + display: block; + margin-top: 2rem; + } + + .filter-container { + .filter { + margin-top: 3rem; + margin-bottom: 4rem; + } + } + + .timeline-container { + justify-content: start; + } + + .timeline { + padding-left: 10px; + width: 100%; + + .timeline-content { + width: calc(100% + 10px); + left: -10px; + + .events { + min-width: 0; + } + + .event-box { + padding: 20px; + } + } + } + + .button-more { + left: -10px; + } + } +} diff --git a/src/main/resources/assets/styles/main.scss b/src/main/resources/assets/styles/main.scss index 5c32c8db83..91908b4a8a 100644 --- a/src/main/resources/assets/styles/main.scss +++ b/src/main/resources/assets/styles/main.scss @@ -91,6 +91,7 @@ $container-max-widths: ( @import './statisticContact'; @import './popup'; @import './print'; +@import './timeline'; body { -moz-osx-font-smoothing: grayscale; diff --git a/src/main/resources/index.d.ts b/src/main/resources/index.d.ts index 8a0d2dbac5..9f3ccbdd8a 100644 --- a/src/main/resources/index.d.ts +++ b/src/main/resources/index.d.ts @@ -71,6 +71,7 @@ declare global { export type StatisticDescription = _PartComponent<'mimir:statisticDescription'> export type SubjectArticleList = _PartComponent<'mimir:subjectArticleList'> export type Table = _PartComponent<'mimir:table'> + export type Timeline = _PartComponent<'mimir:timeline'> export type UpcomingReleases = _PartComponent<'mimir:upcomingReleases'> export type Variables = _PartComponent<'mimir:variables'> export type VideoEmbed = _PartComponent<'mimir:videoEmbed'> diff --git a/src/main/resources/lib/types/partTypes/timeline.ts b/src/main/resources/lib/types/partTypes/timeline.ts new file mode 100644 index 0000000000..ba5e93b84f --- /dev/null +++ b/src/main/resources/lib/types/partTypes/timeline.ts @@ -0,0 +1,69 @@ +export interface TimelineProps { + title: string + ingress: string + timelineElements: TimelineElement[] + showFilter: boolean + showMoreButtonText: string + countYear: number +} + +export interface TimelineElement { + year: string + event: TimelineEvent[] + //event?: TimelineEvent | TimelineEvent[] +} + +export interface TimelineEvent { + eventType: string + title: string + text?: string + directorImage?: string + directorImageAltText?: string + timelineCategory?: string + targetUrl?: string +} +export interface SimpleBox { + title: string + text?: string + timelineCategory: string + urlContentSelector?: hrefManual | hrefContent +} + +export interface ExpansionBox { + title: string + text?: string + timelineCategory: string +} + +export interface DirectorBox { + title: string + text?: string + directorImage?: string + urlContentSelector?: hrefManual | hrefContent +} + +export interface Event { + simpleBox?: SimpleBox + expansionBox?: ExpansionBox + directorBox?: DirectorBox + _selected: 'simpleBox' | 'expansionBox' | 'directorBox' +} + +export interface TimelineItemSet { + year: string + event: Event[] +} + +interface hrefManual { + _selected: 'optionLink' + optionLink: { + link?: string + } +} + +interface hrefContent { + _selected: 'optionXPContent' + optionXPContent: { + xpContent?: string + } +} diff --git a/src/main/resources/site/i18n/phrases.properties b/src/main/resources/site/i18n/phrases.properties index 02dc803796..ce04877787 100644 --- a/src/main/resources/site/i18n/phrases.properties +++ b/src/main/resources/site/i18n/phrases.properties @@ -393,6 +393,7 @@ highcharts.legendLabelNoTitle = Bytt synlighet på serie, {chartTitle} button.showMore = Vis flere button.showAll = Vis alle +button.showMoreYears = Vis flere år methodsAndDocumentation = Metoder og dokumentasjon nameSearch.title = Navnesøk diff --git a/src/main/resources/site/i18n/phrases_en.properties b/src/main/resources/site/i18n/phrases_en.properties index 128d8282f1..1d957e6124 100644 --- a/src/main/resources/site/i18n/phrases_en.properties +++ b/src/main/resources/site/i18n/phrases_en.properties @@ -393,6 +393,7 @@ highcharts.legendLabelNoTitle = Toggle series visibility, {chartTitle} button.showMore = Show more button.showAll = Show all +button.showMoreYears = Show more years methodsAndDocumentation = Methods and documentation nameSearch.title = Name search diff --git a/src/main/resources/site/i18n/phrases_nn.properties b/src/main/resources/site/i18n/phrases_nn.properties index 0fb0ddfd1a..05242a8ab2 100644 --- a/src/main/resources/site/i18n/phrases_nn.properties +++ b/src/main/resources/site/i18n/phrases_nn.properties @@ -386,6 +386,7 @@ highcharts.legendLabelNoTitle = Bytt synlighet på serie, {chartTitle} button.showMore = Vis fleire button.showAll = Vis alle +button.showMoreYears = Vis fleire år methodsAndDocumentation = Metoder og dokumentasjon nameSearch.title = Namnsøk diff --git a/src/main/resources/site/mixins/linkUrlOrContent/linkUrlOrContent.xml b/src/main/resources/site/mixins/linkUrlOrContent/linkUrlOrContent.xml new file mode 100644 index 0000000000..9a8c318d03 --- /dev/null +++ b/src/main/resources/site/mixins/linkUrlOrContent/linkUrlOrContent.xml @@ -0,0 +1,33 @@ + + Lenke + + + Lenke + false + + + + URL + + + Lenke + + + + + + XP-innhold + + + Innhold i XP + + + ${site} + + + + + + + + diff --git a/src/main/resources/site/mixins/timelineCategory/timelineCategory.xml b/src/main/resources/site/mixins/timelineCategory/timelineCategory.xml new file mode 100644 index 0000000000..9f0136a2f8 --- /dev/null +++ b/src/main/resources/site/mixins/timelineCategory/timelineCategory.xml @@ -0,0 +1,14 @@ + + Hendelsestype Tidslinje + + + Hendelsestype + + + Statistikk + Om SSB + + aboutSsb + + + diff --git a/src/main/resources/site/parts/timeline/timeline.ts b/src/main/resources/site/parts/timeline/timeline.ts new file mode 100644 index 0000000000..b792985596 --- /dev/null +++ b/src/main/resources/site/parts/timeline/timeline.ts @@ -0,0 +1,147 @@ +import { getComponent, getContent, pageUrl } from '/lib/xp/portal' +import { localize } from '/lib/xp/i18n' +import { render } from '/lib/enonic/react4xp' +import { renderError } from '/lib/ssb/error/error' +import { imageUrl, getImageAlt } from '/lib/ssb/utils/imageUtils' +import { + type Event, + type SimpleBox, + type ExpansionBox, + type DirectorBox, + type TimelineElement, + type TimelineEvent, +} from '/lib/types/partTypes/timeline' +import { forceArray } from '/lib/ssb/utils/arrayUtils' +import { type Timeline as TimelinePartConfig } from '/site/parts/timeline' + +export function get(req: XP.Request): XP.Response { + try { + return renderPart(req) + } catch (e) { + return renderError(req, 'Error in part: ', e) + } +} + +function renderPart(req: XP.Request) { + const part = getComponent() + const page = getContent() + if (!part || !page) throw new Error('No page or part') + + const language: string = page.language ? page.language : 'nb' + + const timelineConfig: TimelinePartConfig = part.config + const timelineItems: TimelinePartConfig['TimelineItemSet'] = forceArray(timelineConfig.TimelineItemSet) + + const timelineElements: TimelineElement[] = timelineItems.map((element) => { + const events: Event[] = element.event ? forceArray(element.event) : [] + const parsedEvents: TimelineEvent[] = parseEvents(events) + return { + year: element.year, + event: parsedEvents, + } + }) + + const showMoreText: string = localize({ + key: 'button.showMoreYears', + locale: language === 'nb' ? 'no' : language, + }) + + const props = { + title: timelineConfig.title, + ingress: timelineConfig.ingress, + timelineElements, + showFilter: timelineConfig.showFilter, + showMoreButtonText: showMoreText ?? 'Vis flere år', + countYear: timelineConfig.numberOfYear ?? 10, + } + + return render('site/parts/timeline/timeline', props, req, { + body: '', + }) +} + +function parseEvents(events: Event[]): TimelineEvent[] { + return events.map((event: Event) => { + return parseEvent(event) + }) +} + +function parseEvent(event: Event): TimelineEvent { + if (event.simpleBox) { + return parseSimpleBox(event.simpleBox) + } + + if (event.expansionBox) { + return parseExpansionBox(event.expansionBox) + } + if (event.directorBox) { + return parseDirector(event.directorBox) + } + return { + eventType: '', + title: '', + text: '', + directorImage: undefined, + directorImageAltText: '', + timelineCategory: '', + targetUrl: '', + } +} + +function parseSimpleBox(event: SimpleBox): TimelineEvent { + const simpleBox = { + eventType: 'simpleBox', + title: event.title, + text: event.text ?? '', + directorImage: undefined, + directorImageAltText: '', + timelineCategory: event.timelineCategory, + targetUrl: event.urlContentSelector ? getLinkTargetUrl(event) : '', + } + return simpleBox +} + +function parseExpansionBox(event: ExpansionBox): TimelineEvent { + return { + eventType: 'expansionBox', + title: event.title, + text: event.text, + directorImage: undefined, + directorImageAltText: '', + timelineCategory: event.timelineCategory, + targetUrl: '', + } +} + +function parseDirector(event: DirectorBox): TimelineEvent { + return { + eventType: 'directorBox', + title: event.title, + text: event.text, + directorImage: event.directorImage + ? imageUrl({ + id: event.directorImage as string, + scale: 'block(100,100)', + format: 'jpg', + }) + : undefined, + directorImageAltText: event.directorImage ? getImageAlt(event.directorImage) : '', + timelineCategory: 'director', + targetUrl: event.urlContentSelector ? getLinkTargetUrl(event) : '', + } +} + +function getLinkTargetUrl(event: SimpleBox | DirectorBox): string { + if (event.urlContentSelector?._selected == 'optionLink') { + return event.urlContentSelector.optionLink.link ?? '' + } + + if (event.urlContentSelector?._selected == 'optionXPContent') { + return event.urlContentSelector.optionXPContent.xpContent + ? pageUrl({ + id: event.urlContentSelector.optionXPContent.xpContent, + }) + : '' + } + return '' +} diff --git a/src/main/resources/site/parts/timeline/timeline.tsx b/src/main/resources/site/parts/timeline/timeline.tsx new file mode 100644 index 0000000000..02cf08f014 --- /dev/null +++ b/src/main/resources/site/parts/timeline/timeline.tsx @@ -0,0 +1,260 @@ +import React, { useEffect, useState, useRef, useMemo } from 'react' +import { + Button, + CategoryLink, + Card, + ExpansionBox, + Link, + Tag, + Text, + Title, +} from '@statisticsnorway/ssb-component-library' +import { ChevronDown } from 'react-feather' +import { type TimelineProps, type TimelineElement, type TimelineEvent } from '/lib/types/partTypes/timeline' +import { sanitize } from '/lib/ssb/utils/htmlUtils' +import { usePaginationKeyboardNavigation } from '/lib/ssb/utils/customHooks/paginationHooks' + +function Timeline(props: TimelineProps) { + const { timelineElements, countYear, showMoreButtonText, title, ingress, showFilter } = props + const [selectedTag, setSelectedTag] = useState('all') + const [timelineCount, setTimeLineCount] = useState(countYear) + const [keyboardNavigation, setKeyboardNavigation] = useState(false) + + const firstNewTimelineRef = useRef(null) + + const filterElementsByCategory = (elements: TimelineElement[], category: string) => { + if (category === 'all') { + return elements + } + const filteredElements: TimelineElement[] = [] + elements.forEach((element) => { + if (element.event) { + const filteredEvents = element.event.filter((event) => event.timelineCategory === category) + if (filteredEvents.length > 0) { + filteredElements.push({ ...element, event: filteredEvents }) + } + } + }) + return filteredElements + } + + const filteredElements = useMemo(() => { + if (selectedTag !== 'all') { + return filterElementsByCategory(timelineElements, selectedTag) + } + return timelineElements + }, [selectedTag, timelineElements]) + + useEffect(() => { + if (keyboardNavigation && firstNewTimelineRef.current) { + const firstEvent = firstNewTimelineRef.current.querySelector('.event') + if (firstEvent) { + const firstFocusable = firstEvent.querySelector('a, button, input, [tabindex]:not([tabindex="-1"])') + if (firstFocusable) { + ;(firstFocusable as HTMLElement).focus() + } + } + } + }, [timelineCount]) + + const fetchMoreYear = () => { + setTimeLineCount((prevCount) => Number(prevCount) + Number(countYear)) + } + + function setFilter(filter: string) { + setSelectedTag(filter) + } + + const handleOnClick = () => { + setKeyboardNavigation(false) + fetchMoreYear() + } + + const handleKeyboardNavigation = usePaginationKeyboardNavigation(() => { + setKeyboardNavigation(true) + fetchMoreYear() + }) + + function isExternalUrl(url?: string): boolean { + return !!url && !url.startsWith('/') && !url.includes('ssb.no') + } + + function renderShowMoreButton() { + return ( + + + {showMoreButtonText} + + ) + } + + function addCategoryLink(event: TimelineEvent) { + return ( + + ) + } + + function addDirectorCard(event: TimelineEvent) { + return ( + } + profiled + > + {event.text} + + ) + } + + function addEventBox(event: TimelineEvent) { + return ( + + {event.targetUrl ? ( + + {event.title} + + ) : ( + {event.title} + )} + {event.text && {event.text}} + + ) + } + + function addEventExpansionBox(event: TimelineEvent) { + const text = event.text ? ( + + ) : ( + '' + ) + + return + } + + function addEvents(events: TimelineEvent[]) { + return ( + + {events.map((event, index) => { + return ( + + {addEvent(event)} + + ) + })} + + ) + } + + function addEvent(event: TimelineEvent) { + if (event.eventType === 'directorBox') { + return addDirectorCard(event) + } + if (event.eventType === 'simpleBox' && event.targetUrl) { + return addCategoryLink(event) + } + + if (event.eventType === 'simpleBox' && !event.targetUrl) { + return addEventBox(event) + } + + if (event.eventType === 'expansionBox') { + return addEventExpansionBox(event) + } + return addEventBox(event) + } + + function addTimelineYear(timeline: TimelineElement, i: number) { + const events = timeline.event ? (Array.isArray(timeline.event) ? timeline.event : [timeline.event]) : [] + if (events.length === 0) { + return null + } + return ( + + + {timeline.year} + + {events?.length && addEvents(events)} + + ) + } + + function addFilter() { + return ( + + + setFilter('all')}> + Vis alt + + setFilter('statistic')}> + Statistikk + + setFilter('aboutSsb')}> + Om SSB + + setFilter('director')}> + Direktører + + + + ) + } + + function addTitle() { + return ( + + + + {title} + + {ingress} + + + ) + } + + function addTimeLine() { + return ( + + + + + {filteredElements?.slice(0, timelineCount).map((timeline, i) => { + return <>{addTimelineYear(timeline, i)}> + })} + + {filteredElements.length > countYear && filteredElements.length > timelineCount && renderShowMoreButton()} + + + ) + } + + return ( + + {title && addTitle()} + {showFilter && addFilter()} + {addTimeLine()} + + ) +} + +export default (props: TimelineProps) => diff --git a/src/main/resources/site/parts/timeline/timeline.xml b/src/main/resources/site/parts/timeline/timeline.xml new file mode 100644 index 0000000000..c2711a222c --- /dev/null +++ b/src/main/resources/site/parts/timeline/timeline.xml @@ -0,0 +1,100 @@ + + Tidslinje + + + Tidslinje informasjon + + + Tittel for tidslinje + + + + Ingress + + + + Vis filtering + + right + + + + Antall år per side + Hvor mange år skal vises før man må trykke Vis flere år + 10 + + + + + + Vimpel + + + + År + + + + Hendelse + false + + Velg type hendelse + + + Enkel tekstboks med eller uten lenke + Brukes hvis man kun har en tittel og eventuelt litt tekst + + + Tittel + + + + Tekst + + + + + + + + Utvidbar box (ExpansionBox) + Brukes hvis man ønsker å ha en lengre tekst som ikke lenker til artikkel + + + Tittel + + + + Tekst + + Bold Italic Underline Strike Subscript Superscript Cut Copy Blockquote + h2 h3 h4 h5 + + + + + + + Direktør + + + Navn + + + + Tekst + + + + Direktørbilde + + + + + + + + + + +
{ingress}