mirror of
https://github.com/navikt/aksel.git
synced 2026-10-16 18:52:02 +00:00
Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1b6332fa1 | ||
|
|
99c08b8e4a | ||
|
|
d39ed906c6 | ||
|
|
3b31bd3d75 | ||
|
|
b1625ba388 | ||
|
|
269d06c277 | ||
|
|
0c56273f3b | ||
|
|
ab6a96aed7 | ||
|
|
9ee8a72082 | ||
|
|
c2e5a92d8e | ||
|
|
6059aa59aa | ||
|
|
b877ada408 | ||
|
|
4e339f183e | ||
|
|
1d62bf26ff | ||
|
|
da0fbc3299 | ||
|
|
9f59e67e2a | ||
|
|
ed7aafcac8 | ||
|
|
dfbbe92a63 | ||
|
|
42b5af64ab | ||
|
|
329119fa04 | ||
|
|
0fc80f45cb | ||
|
|
337b9fda13 | ||
|
|
659e835da9 | ||
|
|
e10f4375b1 | ||
|
|
9a8bb26c36 |
No files matched your search
@@ -13,7 +13,7 @@ jobs:
|
||||
playwright:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.33.0-focal
|
||||
image: mcr.microsoft.com/playwright:v1.35.1-jammy
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
name: Smoketest Aksel
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.33.0-focal
|
||||
image: mcr.microsoft.com/playwright:v1.35.1-jammy
|
||||
steps:
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
|
||||
+2
-1
@@ -150,4 +150,5 @@ playwright-report/
|
||||
/test-results/
|
||||
backup-*.tar.gz
|
||||
sitemap-urls.json
|
||||
*.tar.gz
|
||||
*.tar.gz
|
||||
searchindex.json
|
||||
@@ -1,5 +1,13 @@
|
||||
# @navikt/aksel-icons
|
||||
|
||||
## 4.3.0
|
||||
|
||||
## 4.2.0
|
||||
|
||||
## 4.1.7
|
||||
|
||||
## 4.1.6
|
||||
|
||||
## 4.1.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/aksel-icons",
|
||||
"version": "4.1.5",
|
||||
"version": "4.3.0",
|
||||
"author": "Aksel | NAV",
|
||||
"homepage": "https://aksel.nav.no/ikoner",
|
||||
"repository": {
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
# @navikt/aksel-stylelint
|
||||
|
||||
## 4.3.0
|
||||
|
||||
## 4.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#2048](https://github.com/navikt/aksel/pull/2048) [`b1625ba38`](https://github.com/navikt/aksel/commit/b1625ba3882b430d678d0623922d7fb56aa724b3) Thanks [@HalvorHaugan](https://github.com/HalvorHaugan)! - Oppdatert Chat
|
||||
|
||||
- :sparkles: Ny prop `size` som kan settes til "small" for en mer kompakt layout
|
||||
- :sparkles: Ny prop `variant` som erstatter `backgroundColor` og `avatarBgColor`
|
||||
- :sparkles: `avatar` er nå valgfritt
|
||||
- :wheelchair: Forbedret UU
|
||||
- :lipstick: Oppdatert utseende
|
||||
|
||||
## 4.1.7
|
||||
|
||||
## 4.1.6
|
||||
|
||||
## 4.1.5
|
||||
|
||||
## 4.1.4
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/aksel-stylelint",
|
||||
"version": "4.1.5",
|
||||
"version": "4.3.0",
|
||||
"author": "Aksel | NAV",
|
||||
"homepage": "https://aksel.nav.no/grunnleggende/kode/stylelint",
|
||||
"repository": {
|
||||
@@ -35,8 +35,8 @@
|
||||
"dev": "yarn watch:lint"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@navikt/ds-css": "^4.1.5",
|
||||
"@navikt/ds-tokens": "^4.1.5",
|
||||
"@navikt/ds-css": "^4.3.0",
|
||||
"@navikt/ds-tokens": "^4.3.0",
|
||||
"@types/jest": "^29.0.0",
|
||||
"concurrently": "7.2.1",
|
||||
"copyfiles": "2.4.1",
|
||||
|
||||
@@ -1,5 +1,33 @@
|
||||
# @navikt/aksel
|
||||
|
||||
## 4.3.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`99c08b8e4`](https://github.com/navikt/aksel/commit/99c08b8e4ab33acd20a21719986452652ffe3c57), [`d39ed906c`](https://github.com/navikt/aksel/commit/d39ed906c61149ac823ebab2e8974a105e592163)]:
|
||||
- @navikt/ds-css@4.3.0
|
||||
|
||||
## 4.2.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`b1625ba38`](https://github.com/navikt/aksel/commit/b1625ba3882b430d678d0623922d7fb56aa724b3)]:
|
||||
- @navikt/ds-css@4.2.0
|
||||
|
||||
## 4.1.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`42b5af64a`](https://github.com/navikt/aksel/commit/42b5af64ab35d0f2d126d41f8fc3e61fe2834b36)]:
|
||||
- @navikt/ds-css@4.1.7
|
||||
|
||||
## 4.1.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @navikt/ds-css@4.1.6
|
||||
|
||||
## 4.1.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/aksel",
|
||||
"version": "4.1.5",
|
||||
"version": "4.3.0",
|
||||
"description": "Aksel command line interface. Handles css-imports, codemods and more",
|
||||
"author": "Aksel | NAV designsystem team",
|
||||
"license": "MIT",
|
||||
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"homepage": "https://aksel.nav.no/grunnleggende/kode/kommandolinje",
|
||||
"dependencies": {
|
||||
"@navikt/ds-css": "4.1.5",
|
||||
"@navikt/ds-css": "4.3.0",
|
||||
"axios": "1.3.6",
|
||||
"chalk": "4.1.0",
|
||||
"clipboardy": "^2.3.0",
|
||||
|
||||
@@ -1,5 +1,35 @@
|
||||
# @navikt/ds-css
|
||||
|
||||
## 4.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#2069](https://github.com/navikt/aksel/pull/2069) [`99c08b8e4`](https://github.com/navikt/aksel/commit/99c08b8e4ab33acd20a21719986452652ffe3c57) Thanks [@KenAJoh](https://github.com/KenAJoh)! - Popover og Helptext har nå luft mot siden av skjerm på mindre flater
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#2067](https://github.com/navikt/aksel/pull/2067) [`d39ed906c`](https://github.com/navikt/aksel/commit/d39ed906c61149ac823ebab2e8974a105e592163) Thanks [@KenAJoh](https://github.com/KenAJoh)! - :bug: Nested ExpansionCard fikk styling fra parent
|
||||
|
||||
## 4.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#2048](https://github.com/navikt/aksel/pull/2048) [`b1625ba38`](https://github.com/navikt/aksel/commit/b1625ba3882b430d678d0623922d7fb56aa724b3) Thanks [@HalvorHaugan](https://github.com/HalvorHaugan)! - Oppdatert Chat
|
||||
|
||||
- :sparkles: Ny prop `size` som kan settes til "small" for en mer kompakt layout
|
||||
- :sparkles: Ny prop `variant` som erstatter `backgroundColor` og `avatarBgColor`
|
||||
- :sparkles: `avatar` er nå valgfritt
|
||||
- :wheelchair: Forbedret UU
|
||||
- :lipstick: Oppdatert utseende
|
||||
|
||||
## 4.1.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [`42b5af64a`](https://github.com/navikt/aksel/commit/42b5af64ab35d0f2d126d41f8fc3e61fe2834b36) Thanks [@KenAJoh](https://github.com/KenAJoh)! - Fikset jsdom-problemer ved rendring av Timeline
|
||||
|
||||
## 4.1.6
|
||||
|
||||
## 4.1.5
|
||||
|
||||
## 4.1.4
|
||||
|
||||
+30
-16
@@ -1,14 +1,12 @@
|
||||
.navds-chat {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: var(--a-spacing-4);
|
||||
padding-right: var(--a-spacing-16);
|
||||
gap: var(--a-spacing-3);
|
||||
max-width: 40.75rem;
|
||||
}
|
||||
|
||||
.navds-chat--right {
|
||||
flex-direction: row-reverse;
|
||||
padding-right: 0;
|
||||
padding-left: var(--a-spacing-16);
|
||||
}
|
||||
|
||||
.navds-chat__bubble-wrapper {
|
||||
@@ -26,15 +24,19 @@
|
||||
|
||||
.navds-chat__avatar {
|
||||
align-items: center;
|
||||
background: var(--ac-chat-avatar-bg, var(--a-bg-subtle));
|
||||
color: var(--ac-chat-avatar-color, var(--a-text-default));
|
||||
box-shadow: var(--a-shadow-xsmall);
|
||||
background-color: var(--ac-chat-avatar-bg, var(--a-surface-neutral-subtle));
|
||||
border: 1px solid var(--ac-chat-border-color, var(--a-border-subtle));
|
||||
border-radius: var(--a-border-radius-full);
|
||||
color: var(--ac-chat-avatar-color, var(--a-text-default));
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
height: 3rem;
|
||||
width: 3rem;
|
||||
height: 2.5rem;
|
||||
width: 2.5rem;
|
||||
font-size: 1.06rem;
|
||||
letter-spacing: 0.024rem;
|
||||
}
|
||||
|
||||
.navds-chat__avatar svg {
|
||||
@@ -44,11 +46,11 @@
|
||||
}
|
||||
|
||||
.navds-chat__bubble {
|
||||
padding: 1rem;
|
||||
box-shadow: var(--a-shadow-small);
|
||||
padding: var(--a-spacing-4);
|
||||
box-shadow: var(--a-shadow-xsmall);
|
||||
width: fit-content;
|
||||
max-width: 37.5rem;
|
||||
background-color: var(--ac-chat-bubble-bg, var(--a-bg-subtle));
|
||||
background-color: var(--ac-chat-bubble-bg, var(--a-surface-neutral-subtle));
|
||||
border: 1px solid var(--ac-chat-border-color, var(--a-border-subtle));
|
||||
border-radius: var(--a-border-radius-xlarge);
|
||||
border-bottom-left-radius: 2px;
|
||||
display: flex;
|
||||
@@ -61,11 +63,27 @@
|
||||
border-bottom-right-radius: 2px;
|
||||
}
|
||||
|
||||
.navds-chat--small .navds-chat__bubble {
|
||||
padding: var(--a-spacing-3);
|
||||
}
|
||||
|
||||
.navds-chat--info .navds-chat__bubble,
|
||||
.navds-chat--info .navds-chat__avatar {
|
||||
background-color: var(--a-surface-info-subtle);
|
||||
}
|
||||
|
||||
.navds-chat--neutral .navds-chat__bubble,
|
||||
.navds-chat--neutral .navds-chat__avatar {
|
||||
background-color: var(--a-surface-default);
|
||||
}
|
||||
|
||||
.navds-chat__top-text {
|
||||
color: var(--ac-chat-top-text, var(--a-text-default));
|
||||
display: flex;
|
||||
gap: var(--a-spacing-2);
|
||||
align-items: baseline;
|
||||
font-weight: normal;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.navds-chat--right .navds-chat__top-text {
|
||||
@@ -87,7 +105,3 @@
|
||||
.navds-chat--right .navds-chat__top-text--left {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.navds-chat__name {
|
||||
font-weight: var(--a-font-weight-bold);
|
||||
}
|
||||
@@ -77,7 +77,7 @@
|
||||
margin-top: var(--a-spacing-1);
|
||||
}
|
||||
|
||||
.navds-expansioncard--small :where(.navds-expansioncard__title--small) {
|
||||
.navds-expansioncard--small > :where(.navds-expansioncard__header) :where(.navds-expansioncard__title--small) {
|
||||
margin-top: var(--a-spacing-05);
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@
|
||||
transition: transform 150ms ease-in-out;
|
||||
}
|
||||
|
||||
.navds-expansioncard--open :where(.navds-expansioncard__header-chevron) {
|
||||
.navds-expansioncard--open > :where(.navds-expansioncard__header) :where(.navds-expansioncard__header-chevron) {
|
||||
transform: translateY(0) rotate(180deg);
|
||||
}
|
||||
|
||||
@@ -135,7 +135,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
.navds-expansioncard--open :where(.navds-expansioncard__header-button):hover :where(.navds-expansioncard__header-chevron) {
|
||||
.navds-expansioncard--open
|
||||
> :where(.navds-expansioncard__header)
|
||||
> :where(.navds-expansioncard__header-button):hover
|
||||
:where(.navds-expansioncard__header-chevron) {
|
||||
transform: translateY(-1px) rotate(180deg);
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
|
||||
.navds-help-text__popover.navds-popover {
|
||||
background-color: var(--ac-help-text-popover-bg, var(--a-surface-info-subtle));
|
||||
max-width: 65ch;
|
||||
max-width: min(65ch, calc(100vw - 1.5rem));
|
||||
|
||||
--ac-popover-border: var(--a-border-info);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/ds-css",
|
||||
"version": "4.1.5",
|
||||
"version": "4.3.0",
|
||||
"description": "CSS for NAV Designsystem",
|
||||
"author": "Aksel | NAV designsystem team",
|
||||
"keywords": [
|
||||
@@ -27,7 +27,7 @@
|
||||
"css:get-version": "node config/get-version.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@navikt/ds-tokens": "^4.1.5",
|
||||
"@navikt/ds-tokens": "^4.3.0",
|
||||
"cssnano": "6.0.0",
|
||||
"fast-glob": "3.2.11",
|
||||
"lodash": "4.17.21",
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
border: 1px solid;
|
||||
border-color: var(--ac-popover-border, var(--a-border-default));
|
||||
border-radius: var(--a-border-radius-medium);
|
||||
max-width: calc(100vw - var(--a-spacing-6));
|
||||
}
|
||||
|
||||
.navds-popover__content {
|
||||
|
||||
@@ -61,6 +61,10 @@
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
.navds-timeline__row-periods:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.navds-timeline__period {
|
||||
height: 100%;
|
||||
border-radius: var(--a-border-radius-full);
|
||||
|
||||
@@ -80,9 +80,10 @@
|
||||
"--ac-button-tertiary-neutral-active-hover-bg": "--a-surface-neutral-active"
|
||||
},
|
||||
"chat": {
|
||||
"--ac-chat-avatar-bg": "--a-bg-subtle",
|
||||
"--ac-chat-border-color": "--a-border-subtle",
|
||||
"--ac-chat-avatar-bg": "--a-surface-neutral-subtle",
|
||||
"--ac-chat-avatar-color": "--a-text-default",
|
||||
"--ac-chat-bubble-bg": "--a-bg-subtle",
|
||||
"--ac-chat-bubble-bg": "--a-surface-neutral-subtle",
|
||||
"--ac-chat-top-text": "--a-text-default"
|
||||
},
|
||||
"chips": {
|
||||
|
||||
@@ -1,5 +1,53 @@
|
||||
# @navikt/ds-react
|
||||
|
||||
## 4.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#2069](https://github.com/navikt/aksel/pull/2069) [`99c08b8e4`](https://github.com/navikt/aksel/commit/99c08b8e4ab33acd20a21719986452652ffe3c57) Thanks [@KenAJoh](https://github.com/KenAJoh)! - Popover og Helptext har nå luft mot siden av skjerm på mindre flater
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @navikt/aksel-icons@4.3.0
|
||||
|
||||
## 4.2.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#2048](https://github.com/navikt/aksel/pull/2048) [`b1625ba38`](https://github.com/navikt/aksel/commit/b1625ba3882b430d678d0623922d7fb56aa724b3) Thanks [@HalvorHaugan](https://github.com/HalvorHaugan)! - Oppdatert Chat
|
||||
|
||||
- :sparkles: Ny prop `size` som kan settes til "small" for en mer kompakt layout
|
||||
- :sparkles: Ny prop `variant` som erstatter `backgroundColor` og `avatarBgColor`
|
||||
- :sparkles: `avatar` er nå valgfritt
|
||||
- :wheelchair: Forbedret UU
|
||||
- :lipstick: Oppdatert utseende
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @navikt/aksel-icons@4.2.0
|
||||
|
||||
## 4.1.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [`42b5af64a`](https://github.com/navikt/aksel/commit/42b5af64ab35d0f2d126d41f8fc3e61fe2834b36) Thanks [@KenAJoh](https://github.com/KenAJoh)! - Fikset jsdom-problemer ved rendring av Timeline
|
||||
|
||||
- [#2056](https://github.com/navikt/aksel/pull/2056) [`dfbbe92a6`](https://github.com/navikt/aksel/commit/dfbbe92a63210278d9dd4d154c01f116ad921fc1) Thanks [@KenAJoh](https://github.com/KenAJoh)! - La til `wrapperClassname`-prop for HelpText
|
||||
|
||||
- Updated dependencies []:
|
||||
- @navikt/aksel-icons@4.1.7
|
||||
|
||||
## 4.1.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [`9a8bb26c3`](https://github.com/navikt/aksel/commit/9a8bb26c36cfbb1a95cf28a043d2df3dbfe7185d) Thanks [@KenAJoh](https://github.com/KenAJoh)! - :bug: Timeline-period brakk ved bruk av jsdom-tester
|
||||
|
||||
- Updated dependencies []:
|
||||
- @navikt/aksel-icons@4.1.6
|
||||
|
||||
## 4.1.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/ds-react",
|
||||
"version": "4.1.5",
|
||||
"version": "4.3.0",
|
||||
"description": "Aksel react-components for NAV designsystem",
|
||||
"author": "Aksel | NAV designsystem team",
|
||||
"license": "MIT",
|
||||
@@ -38,7 +38,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "0.24.1",
|
||||
"@navikt/aksel-icons": "^4.1.5",
|
||||
"@navikt/aksel-icons": "^4.3.0",
|
||||
"@radix-ui/react-tabs": "1.0.0",
|
||||
"@radix-ui/react-toggle-group": "1.0.0",
|
||||
"clsx": "^1.2.1",
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface BubbleProps extends HTMLAttributes<HTMLDivElement> {
|
||||
timestamp?: string;
|
||||
/**
|
||||
* Background color on bubble
|
||||
* @deprecated Use `variant` on Chat instead
|
||||
*/
|
||||
backgroundColor?: string;
|
||||
/**
|
||||
@@ -46,17 +47,20 @@ const Bubble = forwardRef<HTMLDivElement, BubbleProps>(
|
||||
{...rest}
|
||||
>
|
||||
{(timestamp || name) && (
|
||||
<div
|
||||
<h3
|
||||
className={cl(
|
||||
`navds-chat__top-text`,
|
||||
toptextPosition && `navds-chat__top-text--${toptextPosition}`
|
||||
)}
|
||||
>
|
||||
{name && <Detail className="navds-chat__name">{name}</Detail>}
|
||||
{timestamp && (
|
||||
<Detail className="navds-chat__timestamp">{timestamp}</Detail>
|
||||
{name && <Detail as="span">{name}</Detail>}
|
||||
{name && timestamp && (
|
||||
<Detail as="span" aria-hidden>
|
||||
•
|
||||
</Detail>
|
||||
)}
|
||||
</div>
|
||||
{timestamp && <Detail as="span">{timestamp}</Detail>}
|
||||
</h3>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import React, { forwardRef, HTMLAttributes } from "react";
|
||||
import cl from "clsx";
|
||||
import Bubble, { BubbleProps } from "./Bubble";
|
||||
import { BodyLong, BodyShort } from "../typography";
|
||||
import Bubble from "./Bubble";
|
||||
import { BodyLong } from "../typography";
|
||||
|
||||
export const POSITIONS = ["left", "right"] as const;
|
||||
export const SIZES = ["medium", "small"] as const;
|
||||
|
||||
export interface ChatProps extends HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
@@ -17,27 +20,40 @@ export interface ChatProps extends HTMLAttributes<HTMLDivElement> {
|
||||
*/
|
||||
timestamp?: string;
|
||||
/**
|
||||
* Avatar for messenger. Regular text for initials works to
|
||||
* Avatar for messenger. Regular text for initials works too, but it will be hidden for screen readers.
|
||||
*/
|
||||
avatar: React.ReactNode;
|
||||
avatar?: React.ReactNode;
|
||||
/**
|
||||
* Changes background color on avatar and bubbles.
|
||||
* Avoid using the same background as the surface behind Chat.
|
||||
* @default "subtle"
|
||||
*/
|
||||
variant?: "subtle" | "info" | "neutral";
|
||||
/**
|
||||
* Background color on bubbles
|
||||
* @deprecated Use `variant` instead
|
||||
*/
|
||||
backgroundColor?: string;
|
||||
/**
|
||||
* Background color for avatar
|
||||
* @deprecated Use `variant` instead
|
||||
*/
|
||||
avatarBgColor?: string;
|
||||
/**
|
||||
* Positions avatar and Bubbles
|
||||
* Positions avatar and bubbles
|
||||
* @default "left"
|
||||
*/
|
||||
position?: "left" | "right";
|
||||
position?: (typeof POSITIONS)[number];
|
||||
/**
|
||||
* Hoizontal position of toptext
|
||||
* @default Same as chat
|
||||
* @default Same as position
|
||||
*/
|
||||
toptextPosition?: "left" | "right";
|
||||
toptextPosition?: (typeof POSITIONS)[number];
|
||||
/**
|
||||
* Affects padding and font size in bubbles
|
||||
* @default "medium"
|
||||
*/
|
||||
size?: (typeof SIZES)[number];
|
||||
}
|
||||
|
||||
interface ChatComponent
|
||||
@@ -47,24 +63,23 @@ interface ChatComponent
|
||||
/**
|
||||
* @see 🏷️ {@link BubbleProps}
|
||||
*/
|
||||
Bubble: React.ForwardRefExoticComponent<
|
||||
BubbleProps & React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
Bubble: typeof Bubble;
|
||||
}
|
||||
|
||||
/**
|
||||
* A component for displaying chat messages.
|
||||
* A component for communicating dialogs between two parties.
|
||||
*
|
||||
* @see [📝 Documentation](https://aksel.nav.no/komponenter/core/chat)
|
||||
* @see 🏷️ {@link ChatProps}
|
||||
*
|
||||
* @example
|
||||
* ```jsx
|
||||
* <Chat>
|
||||
* <Chat.Bubble avatar="A" name="Alice">Hello!</Chat.Bubble>
|
||||
* <Chat avatar="A" name="Alice" timestamp="01.01.21 14:00">
|
||||
* <Chat.Bubble>Hello!</Chat.Bubble>
|
||||
* <Chat.Bubble>How can I help you?</Chat.Bubble>
|
||||
* </Chat>
|
||||
* <Chat>
|
||||
* <Chat.Bubble avatar="B" name="Bob">Hi there!</Chat.Bubble>
|
||||
* <Chat avatar="B" name="Bob" timestamp="01.01.21 14:01" position="right">
|
||||
* <Chat.Bubble>Hi there!</Chat.Bubble>
|
||||
* </Chat>
|
||||
* ```
|
||||
*/
|
||||
@@ -77,51 +92,54 @@ export const Chat = forwardRef<HTMLDivElement, ChatProps>(
|
||||
timestamp,
|
||||
avatar,
|
||||
position = "left",
|
||||
variant = "subtle",
|
||||
avatarBgColor,
|
||||
backgroundColor,
|
||||
toptextPosition,
|
||||
size = "medium",
|
||||
...rest
|
||||
},
|
||||
}: ChatProps,
|
||||
ref
|
||||
) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cl(
|
||||
"navds-chat",
|
||||
className,
|
||||
`navds-chat--${position} navds-chat--top-text-${
|
||||
toptextPosition ?? position
|
||||
}`
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
<BodyShort
|
||||
as="div"
|
||||
) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cl(
|
||||
"navds-chat",
|
||||
className,
|
||||
`navds-chat--${position}`,
|
||||
`navds-chat--top-text-${toptextPosition ?? position}`,
|
||||
`navds-chat--${size}`,
|
||||
`navds-chat--${variant}`
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{avatar && (
|
||||
<div
|
||||
className="navds-chat__avatar"
|
||||
aria-hidden
|
||||
style={{ backgroundColor: avatarBgColor }}
|
||||
>
|
||||
{avatar}
|
||||
</BodyShort>
|
||||
<ol className="navds-chat__bubble-wrapper">
|
||||
{React.Children.map(children, (child, i) => {
|
||||
if (React.isValidElement(child)) {
|
||||
return (
|
||||
<BodyLong as="li">
|
||||
{React.cloneElement(child, {
|
||||
name: name && i === 0 ? name : undefined,
|
||||
timestamp: timestamp && i === 0 ? timestamp : undefined,
|
||||
backgroundColor,
|
||||
...child.props,
|
||||
})}
|
||||
</BodyLong>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
<ol className="navds-chat__bubble-wrapper">
|
||||
{React.Children.map(children, (child, i) => {
|
||||
if (React.isValidElement(child)) {
|
||||
return (
|
||||
<BodyLong as="li" size={size}>
|
||||
{React.cloneElement(child, {
|
||||
name: name && i === 0 ? name : undefined,
|
||||
timestamp: timestamp && i === 0 ? timestamp : undefined,
|
||||
backgroundColor,
|
||||
...child.props,
|
||||
})}
|
||||
</BodyLong>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
)
|
||||
) as ChatComponent;
|
||||
|
||||
Chat.Bubble = Bubble;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from "react";
|
||||
import { Chat } from "../index";
|
||||
import { Meta } from "@storybook/react";
|
||||
import { POSITIONS, SIZES } from "./Chat";
|
||||
|
||||
export default {
|
||||
title: "ds-react/Chat",
|
||||
@@ -16,19 +17,25 @@ export default {
|
||||
type: "text",
|
||||
},
|
||||
},
|
||||
position: {
|
||||
control: { type: "radio" },
|
||||
options: POSITIONS,
|
||||
},
|
||||
toptextPosition: {
|
||||
control: { type: "radio" },
|
||||
options: POSITIONS,
|
||||
},
|
||||
size: {
|
||||
control: { type: "radio" },
|
||||
options: SIZES,
|
||||
},
|
||||
},
|
||||
} as Meta;
|
||||
} satisfies Meta<typeof Chat>;
|
||||
|
||||
export const Default = {
|
||||
render: (props) => {
|
||||
return (
|
||||
<Chat
|
||||
avatar={props?.avatar ?? "ON"}
|
||||
name={props?.name ?? "Ola Normann"}
|
||||
timestamp={props?.timestamp ?? "01.01.21 14:00"}
|
||||
avatarBgColor={props?.avatarBgColor}
|
||||
backgroundColor={props?.backgroundColor}
|
||||
>
|
||||
<Chat {...props}>
|
||||
<Chat.Bubble>
|
||||
Aute minim nisi sunt mollit duis sunt nulla minim non proident.
|
||||
</Chat.Bubble>
|
||||
@@ -48,141 +55,156 @@ export const Default = {
|
||||
},
|
||||
};
|
||||
|
||||
export const Farger = () => {
|
||||
return (
|
||||
export const Small = () => (
|
||||
<Chat avatar="ON" name="Ola Normann" timestamp="01.01.21 14:00" size="small">
|
||||
<Chat.Bubble>
|
||||
Aute minim nisi sunt mollit duis sunt nulla minim non proident.
|
||||
</Chat.Bubble>
|
||||
<Chat.Bubble>Tempor fugiat amet eu sint in in ullamco.</Chat.Bubble>
|
||||
<Chat.Bubble>
|
||||
Adipisicing laborum est eu laborum est sit in commodo enim sint laboris
|
||||
labore nisi ut.
|
||||
</Chat.Bubble>
|
||||
</Chat>
|
||||
);
|
||||
|
||||
export const Colors = () => (
|
||||
<Chat
|
||||
avatar="NAV"
|
||||
name="NAV"
|
||||
timestamp="01.01.21 14:00"
|
||||
avatarBgColor="var(--a-surface-alt-1-subtle)"
|
||||
backgroundColor="var(--a-surface-warning-subtle)"
|
||||
>
|
||||
<Chat.Bubble>
|
||||
Aute minim nisi sunt mollit duis sunt nulla minim non proident.
|
||||
</Chat.Bubble>
|
||||
<Chat.Bubble backgroundColor="lightYellow">
|
||||
Tempor fugiat amet eu sint in in ullamco.
|
||||
</Chat.Bubble>
|
||||
<Chat.Bubble>
|
||||
Adipisicing laborum est eu laborum est sit in commodo enim sint laboris
|
||||
labore nisi ut.
|
||||
</Chat.Bubble>
|
||||
</Chat>
|
||||
);
|
||||
|
||||
export const Variants = () => (
|
||||
<div className="colgap">
|
||||
<Chat avatar="NAV" name="NAV" timestamp="01.01.21 14:00" variant="info">
|
||||
<Chat.Bubble>
|
||||
Aute minim nisi sunt mollit duis sunt nulla minim non proident.
|
||||
</Chat.Bubble>
|
||||
</Chat>
|
||||
<Chat
|
||||
avatar="KN"
|
||||
name="Kari Nordmann"
|
||||
timestamp="01.01.21 14:03"
|
||||
variant="neutral"
|
||||
position="right"
|
||||
>
|
||||
<Chat.Bubble>Tempor fugiat amet eu sint in in ullamco.</Chat.Bubble>
|
||||
</Chat>
|
||||
<Chat
|
||||
avatar="ON"
|
||||
name="Ola Nordmann"
|
||||
timestamp="01.01.21 14:07"
|
||||
variant="subtle"
|
||||
position="right"
|
||||
>
|
||||
<Chat.Bubble>
|
||||
Adipisicing laborum est eu laborum est sit in commodo enim sint laboris
|
||||
labore nisi ut.
|
||||
</Chat.Bubble>
|
||||
</Chat>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Position = () => (
|
||||
<div className="colgap">
|
||||
<Chat avatar="ON" name="Ola Normann" timestamp="01.01.21 14:00">
|
||||
<Chat.Bubble>
|
||||
Adipisicing laborum est eu laborum est sit in commodo enim sint laboris
|
||||
labore nisi ut.
|
||||
</Chat.Bubble>
|
||||
</Chat>
|
||||
<Chat
|
||||
avatar="KH"
|
||||
name="Kari Høyli"
|
||||
timestamp="01.01.21 14:32"
|
||||
position="right"
|
||||
variant="info"
|
||||
>
|
||||
<Chat.Bubble>
|
||||
Reprehenderit pariatur officia exercitation laboris.
|
||||
</Chat.Bubble>
|
||||
<Chat.Bubble>
|
||||
Enim velit deserunt do quis. Eu fugiat magna esse dolore ad sunt sit est
|
||||
dolore incididunt. Occaecat cupidatat magna.
|
||||
</Chat.Bubble>
|
||||
</Chat>
|
||||
<Chat avatar="ON" name="Ola Normann" timestamp="01.01.21 15:00">
|
||||
<Chat.Bubble>
|
||||
Exercitation irure Lorem cupidatat culpa anim cillum esse ullamco qui
|
||||
dolore laborum et Lorem. Labore sunt duis id Lorem voluptate commodo ea
|
||||
esse. Dolore esse aliqua proident ea ad commodo ut dolore voluptate
|
||||
labore sunt aute.
|
||||
</Chat.Bubble>
|
||||
</Chat>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const ToptextPosition = () => (
|
||||
<div className="colgap">
|
||||
<Chat
|
||||
avatar="ON"
|
||||
name="Ola Normann"
|
||||
timestamp="01.01.21 14:00"
|
||||
avatarBgColor="var(--a-lightblue-200)"
|
||||
backgroundColor="var(--a-lightblue-100)"
|
||||
toptextPosition="right"
|
||||
>
|
||||
<Chat.Bubble>
|
||||
Aute minim nisi sunt mollit duis sunt nulla minim non proident.
|
||||
</Chat.Bubble>
|
||||
<Chat.Bubble>Tempor fugiat amet eu sint in in ullamco.</Chat.Bubble>
|
||||
<Chat.Bubble>
|
||||
Adipisicing laborum est eu laborum est sit in commodo enim sint laboris
|
||||
labore nisi ut.
|
||||
</Chat.Bubble>
|
||||
</Chat>
|
||||
);
|
||||
};
|
||||
|
||||
export const Position = () => {
|
||||
return (
|
||||
<div className="colgap">
|
||||
<Chat
|
||||
avatar="ON"
|
||||
name="Ola Normann"
|
||||
timestamp="01.01.21 14:00"
|
||||
backgroundColor="rgba(255, 249, 240, 1)"
|
||||
avatarBgColor="rgba(255, 236, 204, 1)"
|
||||
>
|
||||
<Chat.Bubble>
|
||||
Adipisicing laborum est eu laborum est sit in commodo enim sint
|
||||
laboris labore nisi ut.
|
||||
</Chat.Bubble>
|
||||
</Chat>
|
||||
<Chat
|
||||
avatar="KH"
|
||||
name="Kari Høyli"
|
||||
timestamp="01.01.21 14:32"
|
||||
position="right"
|
||||
backgroundColor="rgba(230, 240, 255, 1)"
|
||||
avatarBgColor="rgba(204, 225, 255, 1)"
|
||||
>
|
||||
<Chat.Bubble>
|
||||
Reprehenderit pariatur officia exercitation laboris.
|
||||
</Chat.Bubble>
|
||||
<Chat.Bubble>
|
||||
Enim velit deserunt do quis. Eu fugiat magna esse dolore ad sunt sit
|
||||
est dolore incididunt. Occaecat cupidatat magna.
|
||||
</Chat.Bubble>
|
||||
</Chat>
|
||||
<Chat
|
||||
avatar="ON"
|
||||
name="Ola Normann"
|
||||
timestamp="01.01.21 15:00"
|
||||
backgroundColor="rgba(255, 249, 240, 1)"
|
||||
avatarBgColor="rgba(255, 236, 204, 1)"
|
||||
>
|
||||
<Chat.Bubble>
|
||||
Exercitation irure Lorem cupidatat culpa anim cillum esse ullamco qui
|
||||
dolore laborum et Lorem. Labore sunt duis id Lorem voluptate commodo
|
||||
ea esse. Dolore esse aliqua proident ea ad commodo ut dolore voluptate
|
||||
labore sunt aute.
|
||||
</Chat.Bubble>
|
||||
</Chat>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ToptextPosition = () => {
|
||||
return (
|
||||
<div className="colgap">
|
||||
<Chat
|
||||
avatar="ON"
|
||||
name="Ola Normann"
|
||||
timestamp="01.01.21 14:00"
|
||||
backgroundColor="rgba(255, 249, 240, 1)"
|
||||
avatarBgColor="rgba(255, 236, 204, 1)"
|
||||
toptextPosition="right"
|
||||
>
|
||||
<Chat.Bubble>
|
||||
Adipisicing laborum est eu laborum est sit in commodo enim sint
|
||||
laboris labore nisi ut.
|
||||
</Chat.Bubble>
|
||||
</Chat>
|
||||
<Chat
|
||||
avatar="KH"
|
||||
name="Kari Høyli"
|
||||
timestamp="01.01.21 14:32"
|
||||
position="right"
|
||||
backgroundColor="rgba(230, 240, 255, 1)"
|
||||
avatarBgColor="rgba(204, 225, 255, 1)"
|
||||
toptextPosition="left"
|
||||
>
|
||||
<Chat.Bubble>
|
||||
Reprehenderit pariatur officia exercitation laboris.
|
||||
</Chat.Bubble>
|
||||
<Chat.Bubble>
|
||||
Enim velit deserunt do quis. Eu fugiat magna esse dolore ad sunt sit
|
||||
est dolore incididunt. Occaecat cupidatat magna.
|
||||
</Chat.Bubble>
|
||||
</Chat>
|
||||
<Chat
|
||||
avatar="ON"
|
||||
name="Ola Normann"
|
||||
timestamp="01.01.21 15:00"
|
||||
backgroundColor="rgba(255, 249, 240, 1)"
|
||||
avatarBgColor="rgba(255, 236, 204, 1)"
|
||||
>
|
||||
<Chat.Bubble toptextPosition="right">
|
||||
Exercitation irure Lorem cupidatat culpa anim cillum esse ullamco qui
|
||||
dolore laborum et Lorem. Labore sunt duis id Lorem voluptate commodo
|
||||
ea esse. Dolore esse aliqua proident ea ad commodo ut dolore voluptate
|
||||
labore sunt aute.
|
||||
</Chat.Bubble>
|
||||
</Chat>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Avatar = () => {
|
||||
return (
|
||||
<Chat avatar={<Illustration />} timestamp="01.01.21 14:00">
|
||||
<Chat
|
||||
name="Kari Høyli"
|
||||
timestamp="01.01.21 14:32"
|
||||
position="right"
|
||||
variant="info"
|
||||
toptextPosition="left"
|
||||
>
|
||||
<Chat.Bubble>
|
||||
Aute minim nisi sunt mollit duis sunt nulla minim non proident.
|
||||
Reprehenderit pariatur officia exercitation laboris.
|
||||
</Chat.Bubble>
|
||||
<Chat.Bubble>Tempor fugiat amet eu sint in in ullamco.</Chat.Bubble>
|
||||
<Chat.Bubble>
|
||||
Adipisicing laborum est eu laborum est sit in commodo enim sint laboris
|
||||
labore nisi ut.
|
||||
Enim velit deserunt do quis. Eu fugiat magna esse dolore ad sunt sit est
|
||||
dolore incididunt. Occaecat cupidatat magna.
|
||||
</Chat.Bubble>
|
||||
</Chat>
|
||||
);
|
||||
};
|
||||
<Chat avatar="ON" name="Ola Normann" timestamp="01.01.21 15:00">
|
||||
<Chat.Bubble toptextPosition="right">
|
||||
Exercitation irure Lorem cupidatat culpa anim cillum esse ullamco qui
|
||||
dolore laborum et Lorem. Labore sunt duis id Lorem voluptate commodo ea
|
||||
esse. Dolore esse aliqua proident ea ad commodo ut dolore voluptate
|
||||
labore sunt aute.
|
||||
</Chat.Bubble>
|
||||
</Chat>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Avatar = () => (
|
||||
<Chat avatar={<Illustration />} timestamp="01.01.21 14:00">
|
||||
<Chat.Bubble>
|
||||
Aute minim nisi sunt mollit duis sunt nulla minim non proident.
|
||||
</Chat.Bubble>
|
||||
<Chat.Bubble>Tempor fugiat amet eu sint in in ullamco.</Chat.Bubble>
|
||||
<Chat.Bubble>
|
||||
Adipisicing laborum est eu laborum est sit in commodo enim sint laboris
|
||||
labore nisi ut.
|
||||
</Chat.Bubble>
|
||||
</Chat>
|
||||
);
|
||||
|
||||
const Illustration = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 54 93">
|
||||
|
||||
@@ -29,6 +29,18 @@ interface ErrorSummaryComponent
|
||||
extends React.ForwardRefExoticComponent<
|
||||
ErrorSummaryProps & React.RefAttributes<HTMLDivElement>
|
||||
> {
|
||||
/**
|
||||
* Link to error
|
||||
*
|
||||
* @see [🤖 OverridableComponent](https://aksel.nav.no/grunnleggende/kode/overridablecomponent) support
|
||||
*
|
||||
* @example
|
||||
* ```jsx
|
||||
* <ErrorSummary.Item href="#1">
|
||||
* Felt må fylles ut med alder
|
||||
* </ErrorSummary.Item>
|
||||
* ```
|
||||
*/
|
||||
Item: ErrorSummaryItemType;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,10 @@ export interface ErrorSummaryItemProps
|
||||
* Link text
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* Link to errormessage
|
||||
*/
|
||||
href?: string;
|
||||
}
|
||||
|
||||
export type ErrorSummaryItemType = OverridableComponent<
|
||||
|
||||
@@ -29,6 +29,10 @@ export interface HelpTextProps
|
||||
| "right-end"
|
||||
| "left-start"
|
||||
| "left-end";
|
||||
/**
|
||||
* Classname for wrapper
|
||||
*/
|
||||
wrapperClassName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,6 +57,7 @@ export const HelpText = forwardRef<HTMLButtonElement, HelpTextProps>(
|
||||
strategy = "absolute",
|
||||
title = "hjelp",
|
||||
onClick,
|
||||
wrapperClassName,
|
||||
...rest
|
||||
},
|
||||
ref
|
||||
@@ -62,7 +67,7 @@ export const HelpText = forwardRef<HTMLButtonElement, HelpTextProps>(
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="navds-help-text">
|
||||
<div className={cl("navds-help-text", wrapperClassName)}>
|
||||
<button
|
||||
{...rest}
|
||||
ref={mergedRef}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { HelpText } from "..";
|
||||
import { BodyLong, Heading, HelpText } from "..";
|
||||
|
||||
export default {
|
||||
title: "ds-react/HelpText",
|
||||
@@ -65,3 +65,36 @@ export const Open = () => {
|
||||
</HelpText>
|
||||
);
|
||||
};
|
||||
|
||||
export const WrapperClassName = {
|
||||
render: () => {
|
||||
return (
|
||||
<div>
|
||||
<BodyLong spacing>
|
||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Soluta
|
||||
perspiciatis vero voluptatum, asperiores cumque, deserunt accusantium
|
||||
ullam ipsa accusamus officia cupiditate quae unde esse culpa, ratione
|
||||
ab quam ea quas?
|
||||
</BodyLong>
|
||||
|
||||
<Heading level="2" size="medium">
|
||||
67 år og 1 måneder øklasdjkl askdak døkasøk daøkdkølasøkld asølkdøka
|
||||
<HelpText wrapperClassName="testClass">
|
||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Soluta
|
||||
perspiciatis vero voluptatum, asperiores cumque, deserunt
|
||||
accusantium ullam ipsa accusamus officia cupiditate quae unde esse
|
||||
culpa, ratione ab quam ea quas?
|
||||
</HelpText>
|
||||
<style>{`.testClass {display: inline-block; margin-left: 0.5rem; vertical-align: middle;}`}</style>
|
||||
</Heading>
|
||||
|
||||
<BodyLong spacing>
|
||||
Lorem ipsum dolor sit amet consectetur adipisicing elit. Soluta
|
||||
perspiciatis vero voluptatum, asperiores cumque, deserunt accusantium
|
||||
ullam ipsa accusamus officia cupiditate quae unde esse culpa, ratione
|
||||
ab quam ea quas?
|
||||
</BodyLong>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -1,8 +1,8 @@
|
||||
import {
|
||||
arrow as flArrow,
|
||||
autoUpdate,
|
||||
flip,
|
||||
arrow as flArrow,
|
||||
offset as flOffset,
|
||||
flip,
|
||||
shift,
|
||||
useClick,
|
||||
useDismiss,
|
||||
@@ -11,8 +11,8 @@ import {
|
||||
} from "@floating-ui/react";
|
||||
import cl from "clsx";
|
||||
import React, {
|
||||
forwardRef,
|
||||
HTMLAttributes,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
@@ -141,8 +141,8 @@ export const Popover = forwardRef<HTMLDivElement, PopoverProps>(
|
||||
onOpenChange: onClose,
|
||||
middleware: [
|
||||
flOffset(offset ?? (arrow ? 16 : 4)),
|
||||
shift(),
|
||||
flip({ padding: 5, fallbackPlacements: ["bottom", "top"] }),
|
||||
shift({ padding: 12 }),
|
||||
flArrow({ element: arrowRef, padding: 8 }),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -11,15 +11,9 @@ import {
|
||||
useHover,
|
||||
useInteractions,
|
||||
} from "@floating-ui/react";
|
||||
import { useEventListener, mergeRefs } from "../util";
|
||||
import { format } from "date-fns";
|
||||
import React, {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import React, { forwardRef, useMemo, useRef, useState } from "react";
|
||||
import { mergeRefs } from "../util";
|
||||
import { useTimelineContext } from "./hooks/useTimelineContext";
|
||||
import { position } from "./utils/calc";
|
||||
import { TimelineComponentTypes } from "./utils/types.internal";
|
||||
@@ -83,22 +77,6 @@ export const Pin = forwardRef<HTMLButtonElement, TimelinePinProps>(
|
||||
[ref, refs.setReference]
|
||||
);
|
||||
|
||||
useEventListener(
|
||||
"focusin",
|
||||
useCallback(
|
||||
(e: FocusEvent) => {
|
||||
if (
|
||||
![refs.domReference.current, refs?.floating?.current].some(
|
||||
(element) => element?.contains(e.target as Node)
|
||||
)
|
||||
) {
|
||||
open && setOpen(false);
|
||||
}
|
||||
},
|
||||
[open, refs.domReference, refs?.floating]
|
||||
)
|
||||
);
|
||||
|
||||
const staticSide = {
|
||||
top: "bottom",
|
||||
right: "left",
|
||||
@@ -139,6 +117,10 @@ export const Pin = forwardRef<HTMLButtonElement, TimelinePinProps>(
|
||||
ref={refs.setFloating}
|
||||
{...getFloatingProps({
|
||||
tabIndex: -1,
|
||||
onBlur: (e) =>
|
||||
e.target.previousSibling !== document.activeElement &&
|
||||
open &&
|
||||
setOpen(false),
|
||||
})}
|
||||
style={{
|
||||
...floatingStyles,
|
||||
|
||||
@@ -72,6 +72,7 @@ export const TimelineRow = forwardRef<HTMLOListElement, TimelineRowProps>(
|
||||
>
|
||||
<ol
|
||||
{...rest}
|
||||
tabIndex={-1}
|
||||
ref={ref}
|
||||
aria-label={
|
||||
periods.length === 0
|
||||
|
||||
@@ -11,9 +11,9 @@ import {
|
||||
useHover,
|
||||
useInteractions,
|
||||
} from "@floating-ui/react";
|
||||
import { useEventListener, mergeRefs } from "../../util";
|
||||
import cl from "clsx";
|
||||
import React, { useCallback, useMemo, useRef, useState } from "react";
|
||||
import React, { useMemo, useRef, useState } from "react";
|
||||
import { mergeRefs } from "../../util";
|
||||
import { usePeriodContext } from "../hooks/usePeriodContext";
|
||||
import { useRowContext } from "../hooks/useRowContext";
|
||||
import { useTimelineContext } from "../hooks/useTimelineContext";
|
||||
@@ -85,22 +85,6 @@ const ClickablePeriod = React.memo(
|
||||
[periodRef, refs.setReference]
|
||||
);
|
||||
|
||||
useEventListener(
|
||||
"focusin",
|
||||
useCallback(
|
||||
(e: FocusEvent) => {
|
||||
if (
|
||||
![refs.domReference.current, refs?.floating?.current].some(
|
||||
(element) => element?.contains(e.target as Node)
|
||||
)
|
||||
) {
|
||||
open && setOpen(false);
|
||||
}
|
||||
},
|
||||
[open, refs.domReference, refs?.floating]
|
||||
)
|
||||
);
|
||||
|
||||
const staticSide = {
|
||||
top: "bottom",
|
||||
right: "left",
|
||||
@@ -165,6 +149,10 @@ const ClickablePeriod = React.memo(
|
||||
ref={refs.setFloating}
|
||||
{...getFloatingProps({
|
||||
tabIndex: -1,
|
||||
onBlur: (e) =>
|
||||
e.target.previousSibling !== document.activeElement &&
|
||||
open &&
|
||||
setOpen(false),
|
||||
})}
|
||||
style={{
|
||||
...floatingStyles,
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# @navikt/ds-tailwind
|
||||
|
||||
## 4.3.0
|
||||
|
||||
## 4.2.0
|
||||
|
||||
## 4.1.7
|
||||
|
||||
## 4.1.6
|
||||
|
||||
## 4.1.5
|
||||
|
||||
## 4.1.4
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/ds-tailwind",
|
||||
"version": "4.1.5",
|
||||
"version": "4.3.0",
|
||||
"description": "Tailwind config based on @navikt/ds-tokens",
|
||||
"author": "Aksel | NAV designsystem team",
|
||||
"keywords": [
|
||||
@@ -22,7 +22,7 @@
|
||||
"directory": "@navikt/core/tailwind"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@navikt/ds-tokens": "^4.1.5",
|
||||
"@navikt/ds-tokens": "^4.3.0",
|
||||
"@types/jest": "^29.0.0",
|
||||
"color": "4.2.3",
|
||||
"jest": "^29.0.0",
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# @navikt/ds-tokens
|
||||
|
||||
## 4.3.0
|
||||
|
||||
## 4.2.0
|
||||
|
||||
## 4.1.7
|
||||
|
||||
## 4.1.6
|
||||
|
||||
## 4.1.5
|
||||
|
||||
## 4.1.4
|
||||
|
||||
@@ -238,15 +238,15 @@
|
||||
"name": "--a-text-action-on-action-subtle",
|
||||
"value": "rgba(0, 86, 180, 1)"
|
||||
},
|
||||
{ "name": "--a-text-on-warning", "value": "rgba(38, 38, 38, 1)" },
|
||||
{ "name": "--a-text-on-info", "value": "rgba(38, 38, 38, 1)" },
|
||||
{ "name": "--a-text-on-alt-2", "value": "rgba(38, 38, 38, 1)" },
|
||||
{ "name": "--a-text-on-inverted", "value": "rgba(255, 255, 255, 1)" },
|
||||
{ "name": "--a-text-on-neutral", "value": "rgba(255, 255, 255, 1)" },
|
||||
{ "name": "--a-text-on-action", "value": "rgba(255, 255, 255, 1)" },
|
||||
{ "name": "--a-text-on-success", "value": "rgba(255, 255, 255, 1)" },
|
||||
{ "name": "--a-text-on-danger", "value": "rgba(255, 255, 255, 1)" },
|
||||
{ "name": "--a-text-on-warning", "value": "rgba(38, 38, 38, 1)" },
|
||||
{ "name": "--a-text-on-info", "value": "rgba(38, 38, 38, 1)" },
|
||||
{ "name": "--a-text-on-alt-1", "value": "rgba(255, 255, 255, 1)" },
|
||||
{ "name": "--a-text-on-alt-2", "value": "rgba(38, 38, 38, 1)" },
|
||||
{ "name": "--a-text-on-alt-3", "value": "rgba(255, 255, 255, 1)" }
|
||||
],
|
||||
"semantic-surface-default": [
|
||||
@@ -317,15 +317,15 @@
|
||||
],
|
||||
"semantic-surface-warning": [
|
||||
{ "name": "--a-surface-warning-subtle", "value": "rgba(255, 236, 204, 1)" },
|
||||
{
|
||||
"name": "--a-surface-warning-subtle-hover",
|
||||
"value": "rgba(255, 215, 153, 1)"
|
||||
},
|
||||
{
|
||||
"name": "--a-surface-warning-moderate",
|
||||
"value": "rgba(255, 215, 153, 1)"
|
||||
},
|
||||
{ "name": "--a-surface-warning", "value": "rgba(255, 145, 0, 1)" },
|
||||
{
|
||||
"name": "--a-surface-warning-subtle-hover",
|
||||
"value": "rgba(255, 215, 153, 1)"
|
||||
}
|
||||
{ "name": "--a-surface-warning", "value": "rgba(255, 145, 0, 1)" }
|
||||
],
|
||||
"semantic-surface-info": [
|
||||
{ "name": "--a-surface-info-subtle", "value": "rgba(216, 249, 255, 1)" },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/ds-tokens",
|
||||
"version": "4.1.5",
|
||||
"version": "4.3.0",
|
||||
"description": "Design-tokens for NAV designsystem",
|
||||
"author": "Aksel | NAV designsystem team",
|
||||
"keywords": [
|
||||
|
||||
+2
-11
@@ -1,21 +1,12 @@
|
||||
FROM node:18.1-alpine3.14
|
||||
RUN apk add --no-cache libc6-compat
|
||||
FROM gcr.io/distroless/nodejs:18 as runtime
|
||||
|
||||
ENV NODE_ENV production
|
||||
|
||||
WORKDIR /app
|
||||
COPY aksel.nav.no/website/.next/standalone ./
|
||||
COPY aksel.nav.no/website/next-logger.config.js ./
|
||||
# COPY aksel.nav.no/website/package.json .
|
||||
# COPY aksel.nav.no/website/.next/ .next/
|
||||
# COPY aksel.nav.no/website/public/ public
|
||||
# COPY aksel.nav.no/website/.env .
|
||||
# COPY aksel.nav.no/website/next.config.js .
|
||||
# COPY node_modules/ node_modules/
|
||||
# COPY @navikt/ @navikt/
|
||||
# CMD yarn start
|
||||
|
||||
ENV NODE_OPTIONS '-r next-logger'
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["node", "aksel.nav.no/website/server.js"]
|
||||
CMD ["aksel.nav.no/website/server.js"]
|
||||
@@ -17,14 +17,6 @@ spec:
|
||||
path: /api/isAlive
|
||||
initialDelay: 10
|
||||
|
||||
resources:
|
||||
limits:
|
||||
cpu: 2000m
|
||||
memory: 2048Mi
|
||||
requests:
|
||||
cpu: 500m
|
||||
memory: 1024Mi
|
||||
|
||||
accessPolicy:
|
||||
outbound:
|
||||
external:
|
||||
@@ -35,3 +27,7 @@ spec:
|
||||
- host: cdn.sanity.io
|
||||
rules: []
|
||||
env:
|
||||
filesFrom:
|
||||
- emptyDir:
|
||||
medium: Memory
|
||||
mountPath: /app/aksel.nav.no/website/.next/cache
|
||||
@@ -21,13 +21,6 @@ spec:
|
||||
path: /api/isAlive
|
||||
initialDelay: 20
|
||||
timeout: 1
|
||||
resources:
|
||||
limits:
|
||||
cpu: 2000m
|
||||
memory: 2048Mi
|
||||
requests:
|
||||
cpu: 500m
|
||||
memory: 1024Mi
|
||||
accessPolicy:
|
||||
outbound:
|
||||
external:
|
||||
@@ -46,3 +39,7 @@ spec:
|
||||
age: 7
|
||||
numNewerVersions: 3
|
||||
withState: ANY
|
||||
filesFrom:
|
||||
- emptyDir:
|
||||
medium: Memory
|
||||
mountPath: /app/aksel.nav.no/website/.next/cache
|
||||
@@ -15,11 +15,11 @@
|
||||
"website"
|
||||
],
|
||||
"dependencies": {
|
||||
"@navikt/aksel-icons": "^4.1.5",
|
||||
"@navikt/ds-css": "^4.1.5",
|
||||
"@navikt/ds-react": "^4.1.5",
|
||||
"@navikt/ds-tailwind": "^4.1.5",
|
||||
"@navikt/ds-tokens": "^4.1.5"
|
||||
"@navikt/aksel-icons": "^4.3.0",
|
||||
"@navikt/ds-css": "^4.3.0",
|
||||
"@navikt/ds-react": "^4.3.0",
|
||||
"@navikt/ds-tailwind": "^4.3.0",
|
||||
"@navikt/ds-tokens": "^4.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"prettier-plugin-tailwindcss": "^0.2.3"
|
||||
|
||||
@@ -101,6 +101,7 @@ const serializers = {
|
||||
spacing
|
||||
level="4"
|
||||
size="small"
|
||||
id={`h${node._key}`}
|
||||
{...textProps}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import "../dist/tw.css";
|
||||
import { Provider } from "@navikt/ds-react";
|
||||
import { IdContext } from "components/website-modules/utils/contexts";
|
||||
|
||||
export const NextDecorator = ({ children }: { children: React.ReactNode }) => (
|
||||
<Provider>
|
||||
<IdContext.Provider value={{ id: "storybook-testid" }}>
|
||||
<div id="__next" className="aksel antialiased">
|
||||
{children}
|
||||
</div>
|
||||
</IdContext.Provider>
|
||||
</Provider>
|
||||
);
|
||||
@@ -13,15 +13,13 @@ export { default as PropTable } from "./sanity-modules/props/PropTabell";
|
||||
export { default as SideModul } from "./sanity-modules/SideModul";
|
||||
export { default as Tabell } from "./sanity-modules/Tabell";
|
||||
export { default as TastaturModul } from "./sanity-modules/TastaturTabell";
|
||||
export { default as Tips } from "./sanity-modules/tips";
|
||||
export { default as Tips } from "./sanity-modules/Tips";
|
||||
export { default as TokenTable } from "./sanity-modules/TokenTable";
|
||||
export * from "./sanity-modules/UuFeedback";
|
||||
export { default as Video } from "./sanity-modules/VideoEmbed";
|
||||
export * from "./website-modules/BreadCrumbs";
|
||||
export { default as EditButton } from "./website-modules/EditButton";
|
||||
export { default as Feedback } from "./website-modules/feedback";
|
||||
export * from "./website-modules/H2";
|
||||
export { default as PreviewBanner } from "./website-modules/PreviewBanner";
|
||||
export * from "./website-modules/TilToppen";
|
||||
export * from "./website-modules/TOC";
|
||||
export * from "./website-modules/utils";
|
||||
@@ -75,7 +75,7 @@ export const WithSidebar = ({
|
||||
<>
|
||||
<Header />
|
||||
<div className="bg-bg-default">
|
||||
<div className="mx-auto mt-6 mb-24 flex w-full max-w-screen-2xl gap-6">
|
||||
<div className="mx-auto mb-24 mt-6 flex w-full max-w-screen-2xl gap-6">
|
||||
<Sidebar kategori={pageType.type} links={sidebar} />
|
||||
<div className="relative w-full">
|
||||
<main
|
||||
@@ -83,83 +83,84 @@ export const WithSidebar = ({
|
||||
id="hovedinnhold"
|
||||
className="min-h-screen-header md:max-w-screen-sidebar relative w-full px-4 focus:outline-none sm:pl-6 sm:pr-6 md:pl-0"
|
||||
>
|
||||
<div
|
||||
className={cl(
|
||||
"relative mb-10 min-h-[12.5rem] overflow-hidden rounded-xl pl-6 pr-4 md:pl-10 lg:pr-10",
|
||||
{
|
||||
"bg-surface-subtle flex items-center justify-between":
|
||||
variant === "page",
|
||||
"bg-deepblue-700 grid py-[4.25rem] pb-6":
|
||||
variant !== "page",
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="z-[1]">
|
||||
{variant === "page" && pageProps?.kategori && (
|
||||
<Detail as="div" className="mb-2">
|
||||
<NextLink
|
||||
href={`/${pageType.type.toLowerCase()}`}
|
||||
passHref
|
||||
legacyBehavior
|
||||
>
|
||||
<Link className="text-text-default">
|
||||
{capitalize(pageType.type)}
|
||||
</Link>
|
||||
</NextLink>{" "}
|
||||
/ {capitalize(pageProps.kategori)}
|
||||
</Detail>
|
||||
)}
|
||||
|
||||
<Heading
|
||||
level="1"
|
||||
size="xlarge"
|
||||
className={cl({
|
||||
"text-deepblue-800": variant === "page",
|
||||
"text-text-on-action bg-deepblue-700/80 w-fit":
|
||||
<div className="from-deepblue-700 rounded-xl bg-gradient-to-br via-blue-500 to-violet-700">
|
||||
<div
|
||||
className={cl(
|
||||
"relative mb-10 min-h-[12.5rem] overflow-hidden rounded-xl pl-6 pr-4 md:pl-10 lg:pr-10",
|
||||
{
|
||||
"bg-surface-subtle flex items-center justify-between":
|
||||
variant === "page",
|
||||
"bg-deepblue-700/80 grid py-[4.25rem] pb-6":
|
||||
variant !== "page",
|
||||
})}
|
||||
>
|
||||
{pageType.title}
|
||||
</Heading>
|
||||
<div
|
||||
className={cl({
|
||||
"bg-deepblue-700/80 w-fit": variant !== "page",
|
||||
})}
|
||||
>
|
||||
{intro && intro}
|
||||
</div>
|
||||
</div>
|
||||
{variant === "page" && pageProps.status?.bilde && (
|
||||
<div
|
||||
className={cl(
|
||||
"relative hidden aspect-square h-[12.5rem] lg:block xl:mr-40",
|
||||
{
|
||||
"hue-rotate-[65deg]": pageProps?.status?.tag === "beta",
|
||||
}
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="z-[1]">
|
||||
{variant === "page" && pageProps?.kategori && (
|
||||
<Detail as="div" className="mb-2">
|
||||
<NextLink
|
||||
href={`/${pageType.type.toLowerCase()}`}
|
||||
passHref
|
||||
legacyBehavior
|
||||
>
|
||||
<Link className="text-text-default">
|
||||
{capitalize(pageType.type)}
|
||||
</Link>
|
||||
</NextLink>{" "}
|
||||
/ {capitalize(pageProps.kategori)}
|
||||
</Detail>
|
||||
)}
|
||||
>
|
||||
<Image
|
||||
src={urlFor(pageProps.status?.bilde).auto("format").url()}
|
||||
decoding="async"
|
||||
layout="fill"
|
||||
objectFit="contain"
|
||||
aria-hidden
|
||||
priority
|
||||
/>
|
||||
|
||||
<Heading
|
||||
level="1"
|
||||
size="xlarge"
|
||||
className={cl({
|
||||
"text-deepblue-800": variant === "page",
|
||||
"text-text-on-action w-fit": variant !== "page",
|
||||
})}
|
||||
>
|
||||
{pageType.title}
|
||||
</Heading>
|
||||
<div
|
||||
className={cl({
|
||||
"w-fit": variant !== "page",
|
||||
})}
|
||||
>
|
||||
{intro}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{variant === "landingPage" && (
|
||||
<div className="pointer-events-none absolute top-0 right-0 hidden sm:block">
|
||||
<HeaderCube className="text-deepblue-300 z-0 max-h-full" />
|
||||
</div>
|
||||
)}
|
||||
{variant === "page" && pageProps.status?.bilde && (
|
||||
<div
|
||||
className={cl(
|
||||
"relative hidden aspect-square h-[12.5rem] lg:block xl:mr-40",
|
||||
{
|
||||
"hue-rotate-[65deg]":
|
||||
pageProps?.status?.tag === "beta",
|
||||
}
|
||||
)}
|
||||
>
|
||||
<Image
|
||||
src={urlFor(pageProps.status?.bilde)
|
||||
.auto("format")
|
||||
.url()}
|
||||
decoding="async"
|
||||
layout="fill"
|
||||
objectFit="contain"
|
||||
aria-hidden
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{variant === "landingPage" && (
|
||||
<div className="pointer-events-none absolute right-0 top-0 hidden sm:block">
|
||||
<HeaderCube className="text-deepblue-300 z-[-1] max-h-full" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={cl("sm:px-6 md:px-10", { flex: withToc })}>
|
||||
{withToc && (
|
||||
<TableOfContentsv2
|
||||
changedState={pageProps["content"]}
|
||||
hideToc={false}
|
||||
/>
|
||||
<TableOfContentsv2 changedState={pageProps["content"]} />
|
||||
)}
|
||||
<div className="w-full">
|
||||
{children}
|
||||
|
||||
@@ -2,8 +2,15 @@ import { BodyShort, Heading, Link } from "@navikt/ds-react";
|
||||
import { FigmaIcon, GithubIcon, SlackIcon } from "components/assets";
|
||||
import Logo from "components/assets/Logo";
|
||||
import { logNav } from "components/website-modules/utils/amplitude";
|
||||
import { EditButton } from "../..";
|
||||
import FooterForm from "./FooterForm";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
export const EditButton = dynamic(
|
||||
() => import("components/website-modules/EditButton"),
|
||||
{
|
||||
ssr: false,
|
||||
}
|
||||
);
|
||||
|
||||
const Footer = () => {
|
||||
return (
|
||||
|
||||
@@ -1,10 +1,33 @@
|
||||
import Logo from "components/assets/Logo";
|
||||
import Link from "next/link";
|
||||
import { logNav } from "../..";
|
||||
import cl from "clsx";
|
||||
import { useRouter } from "next/router";
|
||||
import Logo from "components/assets/Logo";
|
||||
import { Hamburger } from "components/layout/header/Hamburger";
|
||||
import { GlobalSearch } from "./search";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { logNav } from "../..";
|
||||
import { Button } from "@navikt/ds-react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { MagnifyingGlassIcon } from "@navikt/aksel-icons";
|
||||
|
||||
export const GlobalSearch = dynamic(() => import("./search"), {
|
||||
loading: () => (
|
||||
<Button
|
||||
variant="primary"
|
||||
className="hover:bg-deepblue-700 bg-deepblue-600 h-11"
|
||||
aria-keyshortcuts="Control+b"
|
||||
icon={
|
||||
<MagnifyingGlassIcon
|
||||
className="pointer-events-none -mt-[1px] shrink-0 text-2xl"
|
||||
aria-label="Åpne meny"
|
||||
aria-hidden
|
||||
/>
|
||||
}
|
||||
iconPosition="left"
|
||||
>
|
||||
Søk
|
||||
</Button>
|
||||
),
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
const LinkElement = ({ name, href, prefetch = undefined }) => {
|
||||
const { asPath } = useRouter();
|
||||
@@ -59,7 +82,7 @@ export const Header = ({
|
||||
"mx-auto flex h-full max-w-screen-2xl items-center pr-4 lg:pr-6"
|
||||
)}
|
||||
>
|
||||
<div className="flex h-11 items-center pr-4 pl-4 sm:pr-6 sm:pl-4">
|
||||
<div className="flex h-11 items-center pl-4 pr-4 sm:pl-4 sm:pr-6">
|
||||
<Link
|
||||
href="/"
|
||||
passHref
|
||||
@@ -88,7 +111,9 @@ export const Header = ({
|
||||
<LinkElement name="Bloggen" href="/produktbloggen" />
|
||||
</ul>
|
||||
</nav>
|
||||
<GlobalSearch />
|
||||
<div className="z-[1050] ml-auto mr-4 flex justify-center lg:ml-0 lg:mr-0">
|
||||
<GlobalSearch />
|
||||
</div>
|
||||
<div className="mr-2 h-full lg:hidden">
|
||||
<Hamburger />
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* stylelint-disable aksel/design-token-no-global-override */
|
||||
.modalOverlay {
|
||||
z-index: var(--a-z-index-modal);
|
||||
@apply bg-surface-backdrop fixed inset-0 grid place-items-center lg:hidden;
|
||||
@@ -5,7 +6,7 @@
|
||||
|
||||
.modalOverlaySearch {
|
||||
z-index: 3000;
|
||||
@apply bg-surface-backdrop fixed inset-0 grid place-items-center;
|
||||
@apply bg-deepblue-900/20 fixed inset-0 grid place-items-center;
|
||||
}
|
||||
|
||||
.akselTertiaryButton {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { searchOptions } from "@/types";
|
||||
import { Skeleton } from "@navikt/ds-react";
|
||||
import { useContext } from "react";
|
||||
import { SearchResultContext } from "./providers";
|
||||
|
||||
export const Error = () => {
|
||||
const { isValidating, error } = useContext(SearchResultContext);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<p aria-live="assertive" className="mx-auto w-fit px-6 py-24">
|
||||
Noe gikk galt! Last siden på nytt eller ta kontakt med Aksel.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isValidating) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid w-full gap-2 p-1">
|
||||
<Skeleton height="3rem" variant="rounded" />
|
||||
|
||||
<div className="flex flex-wrap gap-2 px-4 pb-4 md:px-10">
|
||||
{Object.keys(searchOptions)
|
||||
.slice(0, Object.keys(searchOptions).length - 1)
|
||||
.map((x) => (
|
||||
<Skeleton variant="rounded" height="1.75rem">
|
||||
{searchOptions[x].display}
|
||||
</Skeleton>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,110 @@
|
||||
import { searchOptions } from "@/types";
|
||||
import { XMarkIcon } from "@navikt/aksel-icons";
|
||||
import { Button, Chips, Search } from "@navikt/ds-react";
|
||||
import { KBD } from "components/website-modules/KBD";
|
||||
import { useContext, useRef } from "react";
|
||||
import { useShortcut } from "./hooks";
|
||||
import {
|
||||
SearchContext,
|
||||
SearchNavigationContext,
|
||||
SearchResultContext,
|
||||
} from "./providers";
|
||||
|
||||
export const SearchForm = () => {
|
||||
const { open, setOpen, tags, setTags, query, setQuery, os } =
|
||||
useContext(SearchContext);
|
||||
const { update, results, reset, isValidating, error } =
|
||||
useContext(SearchResultContext);
|
||||
const { close } = useContext(SearchNavigationContext);
|
||||
const inputRef = useRef(null);
|
||||
|
||||
useShortcut(open, setOpen, inputRef);
|
||||
|
||||
const handleSearchStart = (value: string) => {
|
||||
setQuery(value);
|
||||
value === "" ? reset() : update(value, tags);
|
||||
};
|
||||
|
||||
const noHits = (key: string) => {
|
||||
return !Object.hasOwn(results?.groupedHits ?? {}, key);
|
||||
};
|
||||
|
||||
const noHitsAndQuery = (key: string) => {
|
||||
return query.length > 0 && !tags.find((x) => x === key);
|
||||
};
|
||||
|
||||
if (isValidating || error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const chipsToShow = Object.entries(searchOptions)
|
||||
.filter((x) => !x[1].hidden)
|
||||
.filter(
|
||||
([key]) =>
|
||||
!(noHitsAndQuery(key) && noHits(key) && results?.hits[key] === 0)
|
||||
);
|
||||
|
||||
return (
|
||||
<form
|
||||
role="search"
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
className="bg-surface-default grid w-full gap-2"
|
||||
>
|
||||
<div className="flex flex-row-reverse items-center gap-1 p-1">
|
||||
<Button
|
||||
variant="tertiary-neutral"
|
||||
icon={<XMarkIcon title="Lukk søk" />}
|
||||
onClick={close}
|
||||
type="button"
|
||||
/>
|
||||
<Search
|
||||
label={
|
||||
<span className="flex items-center gap-2">
|
||||
<span>Søk i hele Aksel</span>
|
||||
{os === "mac" ? <KBD>CMD + B</KBD> : <KBD>CTRL + B</KBD>}
|
||||
</span>
|
||||
}
|
||||
aria-autocomplete="both"
|
||||
variant="simple"
|
||||
value={query}
|
||||
onChange={(v) => handleSearchStart(v)}
|
||||
onClear={() => {
|
||||
setQuery("");
|
||||
reset();
|
||||
}}
|
||||
ref={inputRef}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck={false}
|
||||
autoFocus
|
||||
id="aksel-search-input"
|
||||
clearButton={false}
|
||||
placeholder="Søk gjennom hele aksel"
|
||||
className="border-none pr-0 focus:shadow-none"
|
||||
/>
|
||||
</div>
|
||||
{chipsToShow.length !== 0 && (
|
||||
<Chips className="px-4 pb-4 md:px-10">
|
||||
{chipsToShow.map(([key, val]) => (
|
||||
<Chips.Toggle
|
||||
key={key}
|
||||
variant="neutral"
|
||||
selected={tags.includes(key as keyof typeof searchOptions)}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const newTags = (tags as string[]).includes(key)
|
||||
? tags.filter((y) => y !== key)
|
||||
: [...tags, key];
|
||||
setTags(newTags as Array<keyof typeof searchOptions>);
|
||||
update(query, newTags);
|
||||
}}
|
||||
>
|
||||
{val.display}
|
||||
</Chips.Toggle>
|
||||
))}
|
||||
</Chips>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -1,109 +0,0 @@
|
||||
import { Label } from "@navikt/ds-react";
|
||||
import { GroupedHitsT, SearchHitT, searchOptions } from "@/types";
|
||||
import { Hit, IconHit, IconPageHit } from "./Hit";
|
||||
import React from "react";
|
||||
|
||||
export function Group({
|
||||
groups,
|
||||
query,
|
||||
startIndex,
|
||||
logSuccess,
|
||||
}: {
|
||||
groups: GroupedHitsT;
|
||||
query: string;
|
||||
startIndex: number;
|
||||
logSuccess: (index: number, url: string) => void;
|
||||
}) {
|
||||
if (Object.keys(groups).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{Object.entries(groups)
|
||||
.sort((a, b) => searchOptions[a[0]].index - searchOptions[b[0]].index)
|
||||
.map(([key, val], index, arr) => {
|
||||
const prev = arr.slice(0, index);
|
||||
const total =
|
||||
prev.reduce((prev, cur) => prev + cur[1].length, 0) + startIndex;
|
||||
|
||||
return (
|
||||
<GroupComponent
|
||||
logSuccess={logSuccess}
|
||||
startIndex={total}
|
||||
key={key}
|
||||
heading={`${searchOptions[key].display} (${val.length})`}
|
||||
hits={val}
|
||||
query={query}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function GroupComponent({
|
||||
heading,
|
||||
hits,
|
||||
query,
|
||||
startIndex,
|
||||
logSuccess,
|
||||
}: {
|
||||
heading: React.ReactNode;
|
||||
hits: SearchHitT[];
|
||||
query: string;
|
||||
startIndex: number;
|
||||
logSuccess: (index: number, url: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="z-10 mt-4 rounded bg-teal-100 p-2">
|
||||
<Label className="text-text-default" as="h3">
|
||||
{heading}
|
||||
</Label>
|
||||
</div>
|
||||
<ul className="mt-2">
|
||||
{hits.map((x, xi) => {
|
||||
switch (x.item._type) {
|
||||
case "icon":
|
||||
return (
|
||||
<React.Fragment key={x.item.name + xi}>
|
||||
<IconHit
|
||||
key={x.item.name}
|
||||
hit={x}
|
||||
query={query}
|
||||
index={startIndex + xi}
|
||||
logSuccess={logSuccess}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
case "icon_page":
|
||||
return (
|
||||
<React.Fragment key={x.item.heading + xi}>
|
||||
<IconPageHit
|
||||
key={x.item.heading}
|
||||
hit={x}
|
||||
query={query}
|
||||
index={startIndex + xi}
|
||||
logSuccess={logSuccess}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<React.Fragment key={x.item._id}>
|
||||
<Hit
|
||||
key={x.item._id}
|
||||
hit={x}
|
||||
query={query}
|
||||
index={startIndex + xi}
|
||||
logSuccess={logSuccess}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,212 +1,145 @@
|
||||
import { isNew } from "@/utils";
|
||||
import { Tag } from "@navikt/ds-react";
|
||||
import cl from "classnames";
|
||||
import Image from "next/legacy/image";
|
||||
import { StatusTag } from "components/website-modules/StatusTag";
|
||||
import { SearchHitT } from "@/types";
|
||||
import NextLink from "next/link";
|
||||
import { urlFor } from "@/sanity/interface";
|
||||
import { SearchHitT, searchOptions } from "@/types";
|
||||
import { ChevronRightIcon } from "@navikt/aksel-icons";
|
||||
import { Chips } from "@navikt/ds-react";
|
||||
import cl from "clsx";
|
||||
import { Tag } from "components/sanity-modules/frontpage-blocks/Tag";
|
||||
import { StatusTag } from "components/website-modules/StatusTag";
|
||||
import Image from "next/legacy/image";
|
||||
import { default as Link, default as NextLink } from "next/link";
|
||||
import { forwardRef, useContext } from "react";
|
||||
import { SearchContext, SearchLoggingContext } from "./providers";
|
||||
import { highlightMatches, highlightStr } from "./utils";
|
||||
|
||||
export function Hit({
|
||||
hit,
|
||||
query,
|
||||
index,
|
||||
logSuccess,
|
||||
}: {
|
||||
hit: SearchHitT;
|
||||
query: string;
|
||||
index: number;
|
||||
logSuccess: (index: number, url: string) => void;
|
||||
}) {
|
||||
if (hit.item._type === "icon" || hit.item._type === "icon_page") {
|
||||
return;
|
||||
export const Hit = forwardRef<
|
||||
HTMLLIElement,
|
||||
{
|
||||
hit: SearchHitT | Omit<SearchHitT, "score" | "anchor">;
|
||||
index: number;
|
||||
simple?: boolean;
|
||||
tag?: Partial<keyof typeof searchOptions>;
|
||||
}
|
||||
>(({ hit, index, simple = false, tag }, ref) => {
|
||||
const { query } = useContext(SearchContext);
|
||||
const { logSuccess } = useContext(SearchLoggingContext);
|
||||
|
||||
const getHref = () => {
|
||||
if ("anchor" in hit) {
|
||||
return `/${hit.item.slug}${hit?.anchor ? `#h${hit.anchor}` : ""}`;
|
||||
}
|
||||
return `/${hit.item.slug}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<li
|
||||
ref={ref}
|
||||
className={cl(
|
||||
"focus-within:shadow-focus border-border-subtle group relative flex cursor-pointer scroll-mt-12 items-center justify-between gap-4 rounded border-b px-2 last-of-type:border-b-0 focus-within:z-10 hover:bg-gray-100"
|
||||
"border-border-subtle relative flex scroll-my-10 items-center justify-between gap-3 border-b px-2 last-of-type:border-b-0"
|
||||
)}
|
||||
>
|
||||
<div className="px-2 py-6">
|
||||
<NextLink
|
||||
href={`/${hit.item.slug}`}
|
||||
passHref
|
||||
onClick={() => logSuccess(index, `/${(hit.item as any).slug}`)}
|
||||
className="flex items-center gap-2 text-xl font-semibold after:absolute after:inset-0 focus:outline-none"
|
||||
>
|
||||
<span className="group-hover:underline">
|
||||
{highlightStr(hit.item.heading, query)}
|
||||
</span>
|
||||
<StatusTag status={hit?.item?.status?.tag} aria-hidden />
|
||||
</NextLink>
|
||||
<span className="font-regular text-text-subtle text-lg" aria-hidden>
|
||||
{hit.highlight.shouldHightlight ? (
|
||||
<div>{highlightStr(hit.highlight.description, query)}</div>
|
||||
) : (
|
||||
<div>{hit.highlight.description}</div>
|
||||
)}
|
||||
</span>
|
||||
<span className="mt-4 flex gap-2 empty:mt-0">
|
||||
{hit.item?.tema &&
|
||||
hit.item?.tema.map((x) => (
|
||||
<Tag variant="neutral-moderate" size="xsmall" key={x}>
|
||||
{x}
|
||||
</Tag>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="hidden aspect-square w-24 sm:block">
|
||||
{hit.item?.status?.bilde && (
|
||||
<Image
|
||||
src={urlFor(hit.item.status.bilde).auto("format").url()}
|
||||
decoding="sync"
|
||||
width="96"
|
||||
height="96"
|
||||
layout="fixed"
|
||||
objectFit="contain"
|
||||
alt={hit.item?.heading + " thumbnail"}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconHit({
|
||||
hit,
|
||||
query,
|
||||
index,
|
||||
logSuccess,
|
||||
}: {
|
||||
hit: SearchHitT;
|
||||
query: string;
|
||||
index: number;
|
||||
logSuccess: (index: number, url: string) => void;
|
||||
}) {
|
||||
if (hit.item._type !== "icon") {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<li
|
||||
className={cl(
|
||||
"focus-within:shadow-focus border-border-subtle group relative flex cursor-pointer scroll-mt-12 items-center justify-between gap-4 rounded border-b px-2 last-of-type:border-b-0 focus-within:z-10 hover:bg-gray-100"
|
||||
)}
|
||||
>
|
||||
<div className="px-2 py-2">
|
||||
<NextLink
|
||||
href={`/ikoner/${hit.item.name}`}
|
||||
passHref
|
||||
onClick={() => logSuccess(index, `/${(hit.item as any).name}`)}
|
||||
className="flex items-center gap-2 text-xl font-semibold after:absolute after:inset-0 focus:outline-none"
|
||||
>
|
||||
<span className="group-hover:underline">
|
||||
{highlightStr(`${hit.item.name}Icon`, query)}
|
||||
</span>
|
||||
{isNew(hit.item?.created_at ?? "") && (
|
||||
<Tag variant="info" size="small" aria-hidden>
|
||||
Ny
|
||||
</Tag>
|
||||
)}
|
||||
</NextLink>
|
||||
<span className="font-regular text-text-subtle text-lg" aria-hidden>
|
||||
<div>{highlightStr(hit.item.sub_category, query)}</div>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="hidden aspect-square w-24 place-content-center sm:grid">
|
||||
<Image
|
||||
src={`https://raw.githubusercontent.com/navikt/aksel/main/%40navikt/aksel-icons/icons/${hit.item.name}.svg`}
|
||||
decoding="sync"
|
||||
width="24"
|
||||
height="24"
|
||||
layout="fixed"
|
||||
objectFit="contain"
|
||||
alt={hit.item.name + " thumbnail"}
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconPageHit({
|
||||
hit,
|
||||
query,
|
||||
index,
|
||||
logSuccess,
|
||||
}: {
|
||||
hit: SearchHitT;
|
||||
query: string;
|
||||
index: number;
|
||||
logSuccess: (index: number, url: string) => void;
|
||||
}) {
|
||||
if (hit.item._type !== "icon_page") {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<li
|
||||
className={cl(
|
||||
"focus-within:shadow-focus border-border-subtle group relative flex cursor-pointer scroll-mt-12 items-center justify-between gap-4 rounded border-b px-2 last-of-type:border-b-0 focus-within:z-10 hover:bg-gray-100"
|
||||
)}
|
||||
>
|
||||
<div className="px-2 py-2">
|
||||
<NextLink
|
||||
href="/ikoner"
|
||||
passHref
|
||||
onClick={() => logSuccess(index, `/ikoner`)}
|
||||
className="flex items-center gap-2 text-xl font-semibold after:absolute after:inset-0 focus:outline-none"
|
||||
>
|
||||
<span className="group-hover:underline">
|
||||
{highlightStr(`Ikoner`, query)}
|
||||
</span>
|
||||
</NextLink>
|
||||
<span className="font-regular text-text-subtle text-lg" aria-hidden>
|
||||
<div>{highlightStr(hit.item.description, query)}</div>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="hidden aspect-square place-items-center sm:grid">
|
||||
<Image
|
||||
src="/images/thumbnail/ikoner/thumbnail.svg"
|
||||
decoding="sync"
|
||||
width="96"
|
||||
height="96"
|
||||
layout="fixed"
|
||||
objectFit="contain"
|
||||
alt=""
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function splitStr(str: string, query: string) {
|
||||
const regexStr = query.split(" ").join("|");
|
||||
return str.split(new RegExp(`(${regexStr})`, "gi"));
|
||||
}
|
||||
|
||||
function highlightStr(str: string, query: string) {
|
||||
return (
|
||||
<span>
|
||||
{splitStr(str, query).map((part, i) => (
|
||||
<div className="w-full truncate px-2 py-4">
|
||||
<span
|
||||
key={i}
|
||||
className={cl({
|
||||
"text-text-default bg-teal-200/80": query
|
||||
.split(" ")
|
||||
.map((x) => x.toLowerCase())
|
||||
.includes(part.toLowerCase()),
|
||||
"flex flex-col gap-1": simple,
|
||||
"flex items-center gap-2": !simple,
|
||||
})}
|
||||
>
|
||||
{part}
|
||||
<NextLink
|
||||
href={getHref()}
|
||||
onClick={() => logSuccess(index, `/${(hit.item as any).slug}`)}
|
||||
className="focus-visible:shadow-focus focus-visible:bg-border-focus focus-visible:text-text-on-action group scroll-my-32 text-xl font-semibold focus:outline-none"
|
||||
>
|
||||
<span className="group-hover:underline">
|
||||
{highlightStr(hit.item.heading, query, tag)}
|
||||
</span>
|
||||
</NextLink>
|
||||
{simple ? (
|
||||
<Tag
|
||||
type={hit.item._type}
|
||||
size="xsmall"
|
||||
text={hit.item.tema ? hit.item.tema[0] : undefined}
|
||||
inline
|
||||
aria-hidden
|
||||
/>
|
||||
) : (
|
||||
<StatusTag status={hit?.item?.status?.tag} aria-hidden />
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
|
||||
{!simple && <HeadingLinks index={index} hit={hit} />}
|
||||
</div>
|
||||
|
||||
{!simple && (
|
||||
<div className="hidden aspect-square w-24 sm:block">
|
||||
{hit.item?.status?.bilde && (
|
||||
<Image
|
||||
src={urlFor(hit.item.status.bilde).auto("format").url()}
|
||||
decoding="sync"
|
||||
width="96"
|
||||
height="96"
|
||||
layout="fixed"
|
||||
objectFit="contain"
|
||||
alt={hit.item?.heading + " thumbnail"}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
});
|
||||
|
||||
const highlightedHeadings = ["eksempler", "props", "tokens", "retningslinjer"];
|
||||
|
||||
function HeadingLinks({
|
||||
hit,
|
||||
index,
|
||||
}: {
|
||||
hit: SearchHitT | Omit<SearchHitT, "score" | "anchor">;
|
||||
index: number;
|
||||
}) {
|
||||
const { logSuccess } = useContext(SearchLoggingContext);
|
||||
|
||||
const Description = () => {
|
||||
if (hit.matches[0].key !== "heading") {
|
||||
return highlightMatches(hit.matches[0]);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="font-regular text-text-subtle max-w-full text-lg">
|
||||
{hit.description}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
if (hit.item._type === "komponent_artikkel") {
|
||||
return (
|
||||
<>
|
||||
<Description />
|
||||
<Chips className="mt-3" size="small">
|
||||
{hit.item.lvl2
|
||||
.filter(
|
||||
(x) =>
|
||||
!!x.id && highlightedHeadings.includes(x.text.toLowerCase())
|
||||
)
|
||||
.map((x) => (
|
||||
<Link
|
||||
prefetch={false}
|
||||
key={x.text}
|
||||
href={`/${hit.item.slug}${`#h${x.id}`}`}
|
||||
onClick={() =>
|
||||
logSuccess(index, `/${(hit.item as any).slug}`, x.text)
|
||||
}
|
||||
className="min-h-6 bg-surface-neutral-subtle focus-visible:shadow-focus hover:bg-surface-neutral-subtle-hover ring-border-subtle flex items-center justify-center rounded-full px-2 ring-1 ring-inset focus:outline-none"
|
||||
>
|
||||
<span>{x.text}</span>
|
||||
<ChevronRightIcon aria-hidden className="-mr-1" />
|
||||
</Link>
|
||||
))}
|
||||
</Chips>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return <Description />;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { GroupedHitsT, SearchHitT, searchOptions } from "@/types";
|
||||
import { Heading } from "@navikt/ds-react";
|
||||
import { Tag } from "components/sanity-modules/frontpage-blocks/Tag";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Hit } from "./Hit";
|
||||
import cl from "clsx";
|
||||
|
||||
export function CollectionMapper({
|
||||
groups,
|
||||
startIndex,
|
||||
}: {
|
||||
groups: GroupedHitsT;
|
||||
startIndex: number;
|
||||
}) {
|
||||
if (Object.keys(groups).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{Object.entries(groups)
|
||||
.sort((a, b) => searchOptions[a[0]].index - searchOptions[b[0]].index)
|
||||
.map(
|
||||
(
|
||||
[key, val]: [key: keyof typeof searchOptions, val: SearchHitT[]],
|
||||
index,
|
||||
arr
|
||||
) => {
|
||||
const prev = arr.slice(0, index);
|
||||
const total =
|
||||
prev.reduce((prev, cur) => prev + cur[1].length, 0) + startIndex;
|
||||
|
||||
return (
|
||||
<Collection
|
||||
startIndex={total}
|
||||
key={key}
|
||||
heading={`${searchOptions[key].display} (${val.length})`}
|
||||
tag={key}
|
||||
hits={val}
|
||||
/>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function Collection({
|
||||
heading,
|
||||
hits,
|
||||
startIndex,
|
||||
simple = false,
|
||||
tag,
|
||||
}: {
|
||||
heading?: React.ReactNode;
|
||||
hits: SearchHitT[] | Omit<SearchHitT, "score" | "anchor">[];
|
||||
simple?: boolean;
|
||||
startIndex: number;
|
||||
tag?: Partial<keyof typeof searchOptions>;
|
||||
}) {
|
||||
const [intersected, setIntersected] = useState(false);
|
||||
const item = useRef(null);
|
||||
|
||||
const split = useMemo(() => {
|
||||
if (hits.length <= (simple ? 20 : 5)) {
|
||||
return { initial: hits, lazy: null };
|
||||
}
|
||||
|
||||
return { initial: hits.slice(0, 4), lazy: hits.slice(4 + 1) };
|
||||
}, [hits, simple]);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(([entry]) => {
|
||||
entry.isIntersecting && setIntersected(entry.isIntersecting);
|
||||
});
|
||||
item.current && observer.observe(item.current);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{heading && tag && (
|
||||
<h3
|
||||
className={cl("sticky top-0 z-10 p-2 px-4 md:px-10", {
|
||||
"bg-deepblue-50": ["komponent_artikkel", "ds_artikkel"].includes(
|
||||
tag
|
||||
),
|
||||
"bg-violet-50": ["aksel_prinsipp", "aksel_standalone"].includes(
|
||||
tag
|
||||
),
|
||||
"bg-teal-50": tag === "aksel_artikkel",
|
||||
"bg-pink-50": tag === "aksel_blogg",
|
||||
})}
|
||||
>
|
||||
<Tag hTag="span" type={tag} size="small" inline count={hits.length} />
|
||||
</h3>
|
||||
)}
|
||||
{heading && !tag && (
|
||||
<Heading
|
||||
className="bg-surface-subtle sticky top-0 z-10 p-2 px-4 md:px-10"
|
||||
size="small"
|
||||
level="3"
|
||||
>
|
||||
{heading}
|
||||
</Heading>
|
||||
)}
|
||||
<ul className="mt-2 px-0 md:px-6">
|
||||
{split.initial.map((x, xi) => (
|
||||
<Hit
|
||||
key={xi}
|
||||
hit={x}
|
||||
index={startIndex + xi}
|
||||
ref={xi === split.initial.length - 1 ? item : null}
|
||||
simple={simple}
|
||||
tag={tag}
|
||||
/>
|
||||
))}
|
||||
{split.lazy &&
|
||||
intersected &&
|
||||
split.lazy.map((x, xi) => (
|
||||
<Hit
|
||||
key={xi}
|
||||
hit={x}
|
||||
index={startIndex + xi}
|
||||
simple={simple}
|
||||
tag={tag}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { searchOptions } from "@/types";
|
||||
import { ChangeLogIconOutline } from "components/assets";
|
||||
import { useContext } from "react";
|
||||
import { Collection, CollectionMapper } from "./HitCollection";
|
||||
import { SearchContext, SearchResultContext } from "./providers";
|
||||
import { Heading, Label } from "@navikt/ds-react";
|
||||
|
||||
export const Results = () => {
|
||||
const { tags, query } = useContext(SearchContext);
|
||||
const { results, isValidating, error, mostResent } =
|
||||
useContext(SearchResultContext);
|
||||
|
||||
if (isValidating || error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-y-auto">
|
||||
{!results && mostResent && (
|
||||
<div id="aksel-search-results" aria-label="Nyeste artikler">
|
||||
<Collection
|
||||
startIndex={0}
|
||||
heading={
|
||||
<span className="flex items-center gap-2">
|
||||
Nyeste artikler
|
||||
<ChangeLogIconOutline className="shrink-0" aria-hidden />
|
||||
</span>
|
||||
}
|
||||
simple
|
||||
hits={mostResent}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!results?.totalHits && query && (
|
||||
<Heading
|
||||
size="medium"
|
||||
as="p"
|
||||
className="mx-auto w-fit px-6 py-24"
|
||||
aria-live="polite"
|
||||
aria-atomic
|
||||
>
|
||||
<span className="text-text-subtle">{`Ingen treff på "`}</span>
|
||||
<span className="break-all">{query}</span>
|
||||
<span className="text-text-subtle">"</span>
|
||||
{tags.length > 0 && (
|
||||
<span className="text-text-subtle">{` i ${tags
|
||||
.map((x) => searchOptions[x].display.toLowerCase())
|
||||
.join(", ")}`}</span>
|
||||
)}
|
||||
</Heading>
|
||||
)}
|
||||
{results?.totalHits > 0 && (
|
||||
<div id="aksel-search-results" aria-label="Søkeresultater">
|
||||
<Label as="p" className="sr-only" aria-live="polite">
|
||||
{`${results?.totalHits} treff på "${results.query}"${
|
||||
tags.length > 0
|
||||
? ` i ${tags
|
||||
.map((x) => searchOptions[x].display.toLowerCase())
|
||||
.join(", ")}`
|
||||
: ""
|
||||
}`}
|
||||
</Label>
|
||||
<div className="pb-4">
|
||||
{results?.topResults.length > 0 && (
|
||||
<Collection
|
||||
startIndex={1}
|
||||
heading={
|
||||
<span className="flex items-center gap-2">
|
||||
Beste treff
|
||||
<ChangeLogIconOutline className="shrink-0" />
|
||||
</span>
|
||||
}
|
||||
hits={results?.topResults}
|
||||
/>
|
||||
)}
|
||||
<CollectionMapper
|
||||
startIndex={
|
||||
results.topResults.length > 0
|
||||
? results.topResults.length + 1
|
||||
: 1
|
||||
}
|
||||
groups={results.groupedHits}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import { MagnifyingGlassIcon } from "@navikt/aksel-icons";
|
||||
import { Button } from "@navikt/ds-react";
|
||||
import cl from "clsx";
|
||||
import { useContext, useEffect } from "react";
|
||||
import ReactModal from "react-modal";
|
||||
import styles from "../header.module.css";
|
||||
import { Error } from "./Error";
|
||||
import { SearchForm } from "./Form";
|
||||
import { Results } from "./Results";
|
||||
import { SearchContext, SearchNavigationContext } from "./providers";
|
||||
|
||||
export const Search = () => {
|
||||
const { open, setOpen } = useContext(SearchContext);
|
||||
const { close } = useContext(SearchNavigationContext);
|
||||
|
||||
useEffect(() => {
|
||||
ReactModal.setAppElement("#__next");
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="hover:bg-deepblue-700 bg-deepblue-600 h-11"
|
||||
aria-keyshortcuts="Control+b"
|
||||
icon={
|
||||
<MagnifyingGlassIcon
|
||||
className="pointer-events-none -mt-[1px] shrink-0 text-2xl"
|
||||
aria-label="Åpne meny"
|
||||
aria-hidden
|
||||
/>
|
||||
}
|
||||
iconPosition="left"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Søk
|
||||
</Button>
|
||||
<ReactModal
|
||||
isOpen={open}
|
||||
onRequestClose={close}
|
||||
aria={{ modal: true }}
|
||||
contentLabel="Søk"
|
||||
className="bg-surface-default focus-visible:shadow-focus animate-searchModal shadow-xlarge absolute inset-0 mx-auto my-auto flex h-[90%] max-h-[52rem] w-full max-w-[90%] flex-col overflow-x-hidden rounded-lg focus:outline-none md:h-[80%] lg:max-w-4xl"
|
||||
overlayClassName={cl(styles.modalOverlaySearch, "backdrop-blur-[2px]")}
|
||||
htmlOpenClassName="overflow-hidden"
|
||||
>
|
||||
<Error />
|
||||
<SearchForm />
|
||||
<Results />
|
||||
</ReactModal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
import { SearchResultsT } from "@/types";
|
||||
import { createSearchResult } from "../utils";
|
||||
|
||||
describe("createSearchResult", () => {
|
||||
let result;
|
||||
let rawResults;
|
||||
|
||||
beforeEach(() => {
|
||||
result = [
|
||||
{
|
||||
item: { _type: "komponent_artikkel" },
|
||||
score: 0.05,
|
||||
matches: [],
|
||||
},
|
||||
{
|
||||
item: { _type: "aksel_artikkel" },
|
||||
score: 0.1,
|
||||
matches: [],
|
||||
},
|
||||
{
|
||||
item: { _type: "ds_artikkel" },
|
||||
score: 0.15,
|
||||
matches: [],
|
||||
},
|
||||
{
|
||||
item: { _type: "aksel_blogg" },
|
||||
score: 0.2,
|
||||
matches: [],
|
||||
},
|
||||
{
|
||||
item: { _type: "aksel_prinsipp" },
|
||||
score: 0.25,
|
||||
matches: [],
|
||||
},
|
||||
{
|
||||
item: { _type: "aksel_standalone" },
|
||||
score: 0.3,
|
||||
matches: [],
|
||||
},
|
||||
];
|
||||
rawResults = [
|
||||
{ item: { _type: "komponent_artikkel" }, score: 0.05 },
|
||||
{ item: { _type: "aksel_artikkel" }, score: 0.1 },
|
||||
{ item: { _type: "ds_artikkel" }, score: 0.15 },
|
||||
{ item: { _type: "aksel_blogg" }, score: 0.2 },
|
||||
{ item: { _type: "aksel_prinsipp" }, score: 0.25 },
|
||||
{ item: { _type: "aksel_standalone" }, score: 0.3 },
|
||||
];
|
||||
});
|
||||
|
||||
it("should group hits by type", () => {
|
||||
const expected = {
|
||||
komponent_artikkel: [
|
||||
{ item: { _type: "komponent_artikkel" }, score: 0.05, matches: [] },
|
||||
],
|
||||
aksel_artikkel: [
|
||||
{ item: { _type: "aksel_artikkel" }, score: 0.1, matches: [] },
|
||||
],
|
||||
ds_artikkel: [
|
||||
{ item: { _type: "ds_artikkel" }, score: 0.15, matches: [] },
|
||||
],
|
||||
aksel_blogg: [
|
||||
{ item: { _type: "aksel_blogg" }, score: 0.2, matches: [] },
|
||||
],
|
||||
aksel_prinsipp: [
|
||||
{ item: { _type: "aksel_prinsipp" }, score: 0.25, matches: [] },
|
||||
],
|
||||
aksel_standalone: [
|
||||
{ item: { _type: "aksel_standalone" }, score: 0.3, matches: [] },
|
||||
],
|
||||
};
|
||||
const actual: SearchResultsT = createSearchResult(result, rawResults);
|
||||
expect(actual.groupedHits).toEqual(expected);
|
||||
});
|
||||
|
||||
it("should return top 3 results with score < 0.1", () => {
|
||||
const expected = [
|
||||
{ item: { _type: "komponent_artikkel" }, score: 0.05, matches: [] },
|
||||
];
|
||||
const actual: SearchResultsT = createSearchResult(result, rawResults);
|
||||
expect(actual.topResults).toEqual(expected);
|
||||
});
|
||||
|
||||
it("should return total number of hits", () => {
|
||||
const expected = 6;
|
||||
const actual: SearchResultsT = createSearchResult(result, rawResults);
|
||||
expect(actual.totalHits).toEqual(expected);
|
||||
});
|
||||
|
||||
it("should return number of hits by type", () => {
|
||||
const expected = {
|
||||
komponent_artikkel: 1,
|
||||
aksel_artikkel: 1,
|
||||
ds_artikkel: 1,
|
||||
aksel_blogg: 1,
|
||||
aksel_prinsipp: 1,
|
||||
aksel_standalone: 1,
|
||||
};
|
||||
const actual: SearchResultsT = createSearchResult(result, rawResults);
|
||||
expect(actual.hits).toEqual(expected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./useSearch";
|
||||
export * from "./useShortcut";
|
||||
@@ -0,0 +1,56 @@
|
||||
import { allArticleDocuments } from "@/sanity/config";
|
||||
import { SearchResultsT } from "@/types";
|
||||
import { useMemo, useState } from "react";
|
||||
import useSWRImmutable from "swr/immutable";
|
||||
import { createSearchResult, formatResults, fuseSearch } from "../utils";
|
||||
import { debounce } from "@navikt/ds-react";
|
||||
import { logSearch } from "@/utils";
|
||||
|
||||
export const useSearch = () => {
|
||||
const [fuseResults, setFuseResults] = useState<SearchResultsT>(null);
|
||||
|
||||
const { data, error, isValidating } = useSWRImmutable(
|
||||
`/searchindex.json`,
|
||||
(query) => fetch(query).then((res) => res.json())
|
||||
);
|
||||
|
||||
const updateResults = useMemo(
|
||||
() =>
|
||||
debounce((value: string, tags: string[]) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rawResults = fuseSearch(data, value);
|
||||
|
||||
const formatedResults = formatResults(
|
||||
rawResults.filter((x) =>
|
||||
(tags.length > 0 ? tags : allArticleDocuments).includes(
|
||||
x.item._type
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
setFuseResults({
|
||||
...createSearchResult(formatedResults, rawResults),
|
||||
query: value,
|
||||
});
|
||||
logSearch({
|
||||
type: "standard",
|
||||
searchedFromUrl: window.location.pathname,
|
||||
query: value,
|
||||
filter: tags,
|
||||
});
|
||||
}),
|
||||
[data]
|
||||
);
|
||||
|
||||
return {
|
||||
results: fuseResults,
|
||||
update: updateResults,
|
||||
error,
|
||||
isValidating,
|
||||
reset: () => setFuseResults(null),
|
||||
rawData: data,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import { MutableRefObject, useEffect } from "react";
|
||||
|
||||
export const useShortcut = (
|
||||
open: boolean,
|
||||
setOpen: (value: boolean) => void,
|
||||
inputRef: MutableRefObject<HTMLInputElement>
|
||||
) => {
|
||||
useEffect(() => {
|
||||
const listener = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key === "b" &&
|
||||
(event.metaKey || event.ctrlKey) &&
|
||||
!event.shiftKey
|
||||
) {
|
||||
event.preventDefault();
|
||||
if (open) {
|
||||
inputRef.current?.focus();
|
||||
} else {
|
||||
setOpen(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", listener);
|
||||
|
||||
return () => document.removeEventListener("keydown", listener);
|
||||
}, [inputRef, open, setOpen]);
|
||||
};
|
||||
@@ -1,370 +1,21 @@
|
||||
import { SearchLogT, SearchResultsT, searchOptions } from "@/types";
|
||||
import { logSearch, useDebounce } from "@/utils";
|
||||
import { MagnifyingGlassIcon } from "@navikt/aksel-icons";
|
||||
import { Search } from "./Search";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
CheckboxGroup,
|
||||
Loader,
|
||||
Search,
|
||||
} from "@navikt/ds-react";
|
||||
import { ChangeLogIconOutline } from "components/assets";
|
||||
import { KBD } from "components/website-modules/KBD";
|
||||
import { useRouter } from "next/router";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import ReactModal from "react-modal";
|
||||
import styles from "../header.module.css";
|
||||
import { Group, GroupComponent } from "./Group";
|
||||
SearchProvider,
|
||||
SearchResultProvider,
|
||||
SearchLoggingProvider,
|
||||
SearchNavigationProvider,
|
||||
} from "./providers";
|
||||
|
||||
/**
|
||||
* https://www.figma.com/file/71Sm1h6VV23lbBbQ3CJJ9t/Aksel-v2?node-id=1861%3A186079&t=ARKgZcA6B7ysmG3V-0
|
||||
* TODO:
|
||||
* - Oppdatere url-query basert på query + filter: ?search=abcd&filter=god_praksis
|
||||
* - Oppdatere søkefelt og filter basert på url.
|
||||
const GlobalSearch = () => (
|
||||
<SearchProvider>
|
||||
<SearchLoggingProvider>
|
||||
<SearchResultProvider>
|
||||
<SearchNavigationProvider>
|
||||
<Search />
|
||||
</SearchNavigationProvider>
|
||||
</SearchResultProvider>
|
||||
</SearchLoggingProvider>
|
||||
</SearchProvider>
|
||||
);
|
||||
|
||||
*/
|
||||
export const GlobalSearch = () => {
|
||||
const [results, setResults] = useState<SearchResultsT>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeTags, setTags] = useState<Array<keyof typeof searchOptions>>([]);
|
||||
const [os, setOs] = useState<"mac" | "windows">("windows");
|
||||
const inputRef = useRef(null);
|
||||
|
||||
const session = useRef<{ retires: number; queries: string[] }>({
|
||||
retires: 0,
|
||||
queries: [],
|
||||
});
|
||||
const firstSearch = useRef<boolean>(true);
|
||||
const prevSearch = useRef<string>("");
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
const debouncedSearchTerm = useDebounce(query);
|
||||
|
||||
useEffect(() => {
|
||||
ReactModal.setAppElement("#__next");
|
||||
navigator.userAgent?.indexOf("Mac OS X") !== -1
|
||||
? setOs("mac")
|
||||
: setOs("windows");
|
||||
}, []);
|
||||
|
||||
const logSearchAttempt = useCallback(
|
||||
(hits: number, type: SearchLogT["type"]) => {
|
||||
const data: SearchLogT = {
|
||||
type,
|
||||
searchedFromUrl: router.asPath,
|
||||
hits: hits ?? 0,
|
||||
retries: session.current.retires,
|
||||
retriedQueries: session.current.queries,
|
||||
query: debouncedSearchTerm,
|
||||
filter: activeTags,
|
||||
};
|
||||
|
||||
logSearch(data);
|
||||
},
|
||||
[router.asPath, debouncedSearchTerm, activeTags]
|
||||
);
|
||||
|
||||
const logSuccessSearchAttempt = useCallback(
|
||||
(index: number, url: string) => {
|
||||
const data: SearchLogT = {
|
||||
type: "suksess",
|
||||
searchedFromUrl: router.asPath,
|
||||
hits: results?.totalHits ?? 0,
|
||||
retries: session.current.retires,
|
||||
retriedQueries: session.current.queries,
|
||||
query: debouncedSearchTerm,
|
||||
filter: activeTags,
|
||||
|
||||
index,
|
||||
url,
|
||||
accuracy: (100 - index / results?.totalHits).toFixed(0),
|
||||
topResult: index <= results?.topResults?.length,
|
||||
};
|
||||
logSearch(data);
|
||||
},
|
||||
[
|
||||
router.asPath,
|
||||
results?.totalHits,
|
||||
results?.topResults,
|
||||
debouncedSearchTerm,
|
||||
activeTags,
|
||||
]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedSearchTerm && open) {
|
||||
setLoading(true);
|
||||
fetch(
|
||||
`/api/search/v1?q=${encodeURIComponent(debouncedSearchTerm)}${
|
||||
activeTags.length > 0 ? `&doc=${activeTags.join(",")}` : ""
|
||||
}`
|
||||
)
|
||||
.then((x) => x.json())
|
||||
.then((res) => {
|
||||
setResults(res);
|
||||
logSearchAttempt(res?.hits?.totalHits ?? 0, "standard");
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
setResults(null);
|
||||
setLoading(false);
|
||||
});
|
||||
window.scrollTo(0, 0);
|
||||
} else {
|
||||
setLoading(false);
|
||||
setResults(null);
|
||||
}
|
||||
}, [debouncedSearchTerm, activeTags, open, logSearchAttempt]);
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedSearchTerm && open && !firstSearch.current) {
|
||||
session.current = {
|
||||
queries: [...session.current.queries, prevSearch.current],
|
||||
retires: session.current.retires + 1,
|
||||
};
|
||||
prevSearch.current = debouncedSearchTerm;
|
||||
} else if (debouncedSearchTerm && open) {
|
||||
firstSearch.current = false;
|
||||
prevSearch.current = debouncedSearchTerm;
|
||||
}
|
||||
}, [debouncedSearchTerm, activeTags, open]);
|
||||
|
||||
useEffect(() => {
|
||||
const listener = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key === "b" &&
|
||||
(event.metaKey || event.ctrlKey) &&
|
||||
!event.shiftKey
|
||||
) {
|
||||
event.preventDefault();
|
||||
if (open) {
|
||||
inputRef.current?.focus();
|
||||
} else {
|
||||
setOpen(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", listener);
|
||||
|
||||
return () => document.removeEventListener("keydown", listener);
|
||||
}, [open]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (session.current.retires > 0 || prevSearch.current !== "") {
|
||||
logSearchAttempt(results?.totalHits, "feilet");
|
||||
}
|
||||
|
||||
setOpen(false);
|
||||
setQuery("");
|
||||
setTags([]);
|
||||
session.current = { queries: [], retires: 0 };
|
||||
firstSearch.current = true;
|
||||
}, [logSearchAttempt, results?.totalHits]);
|
||||
|
||||
useEffect(() => {
|
||||
router.events.on("beforeHistoryChange", handleClose);
|
||||
router.events.on("hashChangeComplete", handleClose);
|
||||
|
||||
return () => {
|
||||
router.events.off("beforeHistoryChange", handleClose);
|
||||
router.events.off("hashChangeComplete", handleClose);
|
||||
};
|
||||
}, [handleClose, router.events]);
|
||||
|
||||
const handleQueryChange = (v: string) => {
|
||||
setQuery(v);
|
||||
v !== debouncedSearchTerm && setLoading(!!v);
|
||||
};
|
||||
|
||||
const noHits = (key: string) => {
|
||||
return !Object.hasOwn(results?.groupedHits ?? {}, key);
|
||||
};
|
||||
|
||||
const noHitsAndQuery = (key: string) => {
|
||||
return debouncedSearchTerm.length > 0 && !activeTags.find((x) => x === key);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="z-[1050] ml-auto mr-4 flex justify-center lg:ml-0 lg:mr-0">
|
||||
<Button
|
||||
variant="primary"
|
||||
className="hover:bg-deepblue-700 bg-deepblue-600 focus-visible:shadow-focus-gap h-11 focus:shadow-none"
|
||||
aria-keyshortcuts="Control+b"
|
||||
icon={
|
||||
<MagnifyingGlassIcon
|
||||
className="pointer-events-none -mt-[1px] shrink-0 text-2xl"
|
||||
aria-label="Åpne meny"
|
||||
aria-hidden
|
||||
/>
|
||||
}
|
||||
iconPosition="left"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Søk
|
||||
</Button>
|
||||
<ReactModal
|
||||
isOpen={open}
|
||||
onRequestClose={() => handleClose()}
|
||||
aria={{ modal: true }}
|
||||
contentLabel="Søk"
|
||||
className="bg-surface-default absolute inset-0 block w-screen overflow-x-auto px-4 md:px-6"
|
||||
overlayClassName={styles.modalOverlaySearch}
|
||||
>
|
||||
<div className="search-grid-wrapper relative mx-auto max-w-4xl gap-4 gap-x-8 py-24">
|
||||
{/* <button
|
||||
className="focus-visible:shadow-focus hover:bg-surface-neutral-subtle-hover absolute right-4 top-8 flex items-center justify-center gap-2 rounded px-2 py-3 text-lg focus:outline-none"
|
||||
|
||||
>
|
||||
<span>Lukk søk</span>
|
||||
</button> */}
|
||||
<Button
|
||||
className="absolute right-4 top-8"
|
||||
variant="tertiary-neutral"
|
||||
icon={<KBD>ESC</KBD>}
|
||||
onClick={() => handleClose()}
|
||||
iconPosition="right"
|
||||
>
|
||||
Lukk søk
|
||||
</Button>
|
||||
<div className="search-grid-filter mt-8">
|
||||
<CheckboxGroup legend="Filter" onChange={setTags}>
|
||||
{Object.entries(searchOptions)
|
||||
.filter((x) => !x[1].hidden)
|
||||
.map(([key, val]) => (
|
||||
<Checkbox
|
||||
disabled={
|
||||
noHitsAndQuery(key) &&
|
||||
noHits(key) &&
|
||||
results?.hits[key] === 0
|
||||
}
|
||||
key={key}
|
||||
value={key}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{`${val.display} ${
|
||||
results?.hits[key] > 0 ? `(${results?.hits[key]})` : ""
|
||||
}`}
|
||||
</Checkbox>
|
||||
))}
|
||||
</CheckboxGroup>
|
||||
</div>
|
||||
<div className="search-grid-input w-full">
|
||||
<form role="search" onSubmit={(e) => e.preventDefault()}>
|
||||
<Search
|
||||
label={
|
||||
<span className="flex items-center gap-2">
|
||||
<span>Søk i hele Aksel</span>
|
||||
{os === "mac" ? <KBD>CMD + B</KBD> : <KBD>CTRL + B</KBD>}
|
||||
</span>
|
||||
}
|
||||
variant="simple"
|
||||
value={query}
|
||||
hideLabel={false}
|
||||
onChange={(v) => handleQueryChange(v)}
|
||||
onClear={() => setQuery("")}
|
||||
ref={inputRef}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck={false}
|
||||
autoFocus
|
||||
id="aksel-search-input"
|
||||
clearButton={false}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
<div
|
||||
className="search-grid-results mt-8 w-full max-w-3xl"
|
||||
aria-busy={loading}
|
||||
role="status"
|
||||
>
|
||||
{loading && (
|
||||
<div className="flex w-full justify-center p-4">
|
||||
<Loader size="xlarge" variant="neutral" />
|
||||
</div>
|
||||
)}
|
||||
{results && query && !loading && (
|
||||
<div id="aksel-search-results" aria-label="Søkeresultater">
|
||||
<p className="text-xl font-semibold">
|
||||
{`${results?.totalHits} treff på "${query}"${
|
||||
activeTags.length > 0
|
||||
? ` i ${activeTags
|
||||
.map((x) => searchOptions[x].display.toLowerCase())
|
||||
.join(", ")}`
|
||||
: ""
|
||||
}`}
|
||||
</p>
|
||||
<div className="mt-4 pb-16 md:block">
|
||||
{results?.topResults.length > 0 && (
|
||||
<GroupComponent
|
||||
startIndex={1}
|
||||
heading={
|
||||
<span className="flex items-center gap-2">
|
||||
Beste treff
|
||||
<ChangeLogIconOutline className="shrink-0" />
|
||||
</span>
|
||||
}
|
||||
logSuccess={logSuccessSearchAttempt}
|
||||
hits={results?.topResults}
|
||||
query={query}
|
||||
/>
|
||||
)}
|
||||
<Group
|
||||
startIndex={
|
||||
results?.topResults.length > 0
|
||||
? results?.topResults.length + 1
|
||||
: 1
|
||||
}
|
||||
logSuccess={logSuccessSearchAttempt}
|
||||
groups={results?.groupedHits}
|
||||
query={debouncedSearchTerm}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<style jsx>{`
|
||||
.search-grid-wrapper {
|
||||
display: grid;
|
||||
grid-template-columns: 12rem auto;
|
||||
grid-template-areas:
|
||||
"input input"
|
||||
"results results";
|
||||
}
|
||||
|
||||
.search-grid-wrapper .search-grid-filter {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.search-grid-wrapper .search-grid-input {
|
||||
grid-area: input;
|
||||
}
|
||||
|
||||
.search-grid-wrapper .search-grid-results {
|
||||
grid-area: results;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.search-grid-wrapper {
|
||||
display: grid;
|
||||
grid-template-columns: 12rem auto;
|
||||
grid-template-areas:
|
||||
"empty input"
|
||||
"filter results";
|
||||
}
|
||||
|
||||
.search-grid-wrapper .search-grid-filter {
|
||||
display: block;
|
||||
grid-area: filter;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</ReactModal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default GlobalSearch;
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
import { logSearch } from "@/utils";
|
||||
import { createContext, useCallback, useContext } from "react";
|
||||
import { useSearch } from "../hooks";
|
||||
import { SearchContext } from "./SearchProvider";
|
||||
|
||||
type SearchLoggingContextType = {
|
||||
logSuccess: (index: number, url: string, tag?: string) => void;
|
||||
};
|
||||
|
||||
export const SearchLoggingContext = createContext<SearchLoggingContextType>({
|
||||
logSuccess: () => null,
|
||||
});
|
||||
|
||||
export const SearchLoggingProvider = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const context = useSearch();
|
||||
const { query, tags } = useContext(SearchContext);
|
||||
|
||||
const logSuccess = useCallback(
|
||||
(index: number, url: string, tag?: string) => {
|
||||
const data = {
|
||||
type: "suksess",
|
||||
searchedFromUrl: window.location.pathname,
|
||||
query,
|
||||
filter: tags,
|
||||
tag,
|
||||
index,
|
||||
url,
|
||||
accuracy: (100 - index / context?.results?.totalHits).toFixed(0),
|
||||
topResult: index <= context?.results?.topResults?.length,
|
||||
};
|
||||
logSearch(data);
|
||||
},
|
||||
[
|
||||
query,
|
||||
tags,
|
||||
context?.results?.totalHits,
|
||||
context?.results?.topResults?.length,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<SearchLoggingContext.Provider value={{ logSuccess }}>
|
||||
{children}
|
||||
</SearchLoggingContext.Provider>
|
||||
);
|
||||
};
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
import { createContext, useCallback, useContext, useEffect } from "react";
|
||||
import { SearchContext } from "./SearchProvider";
|
||||
import { SearchResultContext } from "./SearchResultProvider";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
type SearchNavigationProviderT = { close: () => void };
|
||||
|
||||
export const SearchNavigationContext = createContext<SearchNavigationProviderT>(
|
||||
{
|
||||
close: () => null,
|
||||
}
|
||||
);
|
||||
|
||||
export const SearchNavigationProvider = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
const { setOpen, setQuery, setTags } = useContext(SearchContext);
|
||||
const { reset } = useContext(SearchResultContext);
|
||||
|
||||
const close = useCallback(() => {
|
||||
setOpen(false);
|
||||
reset();
|
||||
setQuery("");
|
||||
setTags([]);
|
||||
}, [reset, setOpen, setQuery, setTags]);
|
||||
|
||||
/* Add a small delay to get a precieved smoother navigation */
|
||||
useEffect(() => {
|
||||
let timeout: NodeJS.Timeout;
|
||||
const handler = () => {
|
||||
timeout && clearTimeout(timeout);
|
||||
|
||||
setTimeout(() => close(), 100);
|
||||
};
|
||||
router.events.on("beforeHistoryChange", handler);
|
||||
router.events.on("hashChangeComplete", handler);
|
||||
|
||||
return () => {
|
||||
router.events.off("beforeHistoryChange", handler);
|
||||
router.events.off("hashChangeComplete", handler);
|
||||
};
|
||||
}, [close, router.events]);
|
||||
|
||||
return (
|
||||
<SearchNavigationContext.Provider value={{ close }}>
|
||||
{children}
|
||||
</SearchNavigationContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import { searchOptions } from "@/types";
|
||||
import {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
createContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useShortcut } from "../hooks";
|
||||
|
||||
type SearchContextType = {
|
||||
os: "mac" | "windows";
|
||||
open: boolean;
|
||||
setOpen: (v) => void;
|
||||
tags: Array<keyof typeof searchOptions>;
|
||||
setTags: Dispatch<SetStateAction<Array<keyof typeof searchOptions>>>;
|
||||
query: string;
|
||||
setQuery: (v: string) => void;
|
||||
};
|
||||
|
||||
export const SearchContext = createContext<SearchContextType>({
|
||||
os: "windows",
|
||||
open: false,
|
||||
setOpen: () => null,
|
||||
tags: [],
|
||||
setTags: () => null,
|
||||
query: "",
|
||||
setQuery: () => null,
|
||||
});
|
||||
|
||||
export const SearchProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [tags, setTags] = useState<SearchContextType["tags"]>([]);
|
||||
const [os, setOs] = useState<SearchContextType["os"]>("windows");
|
||||
const inputRef = useRef(null);
|
||||
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
useShortcut(open, setOpen, inputRef);
|
||||
|
||||
useEffect(() => {
|
||||
navigator.userAgent?.indexOf("Mac OS X") !== -1
|
||||
? setOs("mac")
|
||||
: setOs("windows");
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SearchContext.Provider
|
||||
value={{
|
||||
os,
|
||||
open,
|
||||
setOpen,
|
||||
tags,
|
||||
setTags,
|
||||
query,
|
||||
setQuery,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</SearchContext.Provider>
|
||||
);
|
||||
};
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
import { allArticleDocuments } from "@/sanity/config";
|
||||
import { SearchHitT, SearchResultsT } from "@/types";
|
||||
import { createContext, useContext, useMemo } from "react";
|
||||
import { useSearch } from "../hooks";
|
||||
import { formatRawResults } from "../utils";
|
||||
import { SearchContext } from "./SearchProvider";
|
||||
|
||||
type SearchResultContextType = {
|
||||
results: SearchResultsT;
|
||||
update: (value: string, tags: string[]) => void;
|
||||
error: any;
|
||||
isValidating: boolean;
|
||||
reset: () => void;
|
||||
mostResent: Omit<SearchHitT, "score" | "anchor" | "matches">[];
|
||||
};
|
||||
|
||||
export const SearchResultContext = createContext<SearchResultContextType>({
|
||||
results: null,
|
||||
update: () => null,
|
||||
error: false,
|
||||
isValidating: false,
|
||||
reset: () => null,
|
||||
mostResent: [],
|
||||
});
|
||||
|
||||
export const SearchResultProvider = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const context = useSearch();
|
||||
const { tags } = useContext(SearchContext);
|
||||
|
||||
const mostResent: Omit<SearchHitT, "score" | "anchor">[] = useMemo(() => {
|
||||
if (!context.rawData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return formatRawResults(
|
||||
context.rawData
|
||||
.filter((x) =>
|
||||
(tags.length > 0 ? tags : allArticleDocuments).includes(x._type)
|
||||
)
|
||||
.slice(0, 20)
|
||||
);
|
||||
}, [context.rawData, tags]);
|
||||
|
||||
return (
|
||||
<SearchResultContext.Provider value={{ ...context, mostResent }}>
|
||||
{children}
|
||||
</SearchResultContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./SearchProvider";
|
||||
export * from "./SearchResultProvider";
|
||||
export * from "./SearchLoggingProvider";
|
||||
export * from "./SearchNavigationProvider";
|
||||
@@ -0,0 +1,36 @@
|
||||
import { FuseItemT, SearchHitT } from "@/types";
|
||||
import { omit } from "@navikt/ds-react";
|
||||
import Fuse from "fuse.js";
|
||||
|
||||
export function formatResults(res: Fuse.FuseResult<FuseItemT>[]): SearchHitT[] {
|
||||
return res.map((x) => {
|
||||
return {
|
||||
...x,
|
||||
item: omit(x.item, ["intro", "ingress"]) as Omit<
|
||||
FuseItemT,
|
||||
"ingress" | "intro"
|
||||
>,
|
||||
anchor: resolveAnchor(x.matches[0], x.item),
|
||||
description: x?.item.intro || x.item.ingress || "",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function formatRawResults(
|
||||
res: FuseItemT[]
|
||||
): Omit<SearchHitT, "score" | "anchor">[] {
|
||||
return res.map((x) => ({
|
||||
item: omit(x, ["intro", "ingress"]) as Omit<FuseItemT, "ingress" | "intro">,
|
||||
description: x?.intro || x.ingress || "",
|
||||
}));
|
||||
}
|
||||
|
||||
function resolveAnchor(match: Fuse.FuseResultMatch, item: FuseItemT) {
|
||||
if (match.key.includes("lvl")) {
|
||||
return item[match.key.split(".")[0]][match.refIndex].id;
|
||||
}
|
||||
if (match.key === "content.text") {
|
||||
return item[match.key.split(".")[0]][match.refIndex].id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { FuseItemT } from "@/types";
|
||||
import Fuse from "fuse.js";
|
||||
|
||||
export function fuseSearch(results: any[], query: string) {
|
||||
/* https://fusejs.io/api/options.html */
|
||||
const fuse = new Fuse<FuseItemT>(results, {
|
||||
keys: [
|
||||
{ name: "heading", weight: 100 },
|
||||
{ name: "lvl2.text", weight: 50 },
|
||||
{ name: "lvl3.text", weight: 40 },
|
||||
{ name: "lvl4.text", weight: 30 },
|
||||
{ name: "ingress", weight: 30 },
|
||||
{ name: "intro", weight: 30 },
|
||||
{ name: "tema", weight: 70 },
|
||||
{ name: "content.text", weight: 20 },
|
||||
],
|
||||
includeScore: true,
|
||||
shouldSort: true,
|
||||
minMatchCharLength: 3,
|
||||
includeMatches: true,
|
||||
threshold: 0.3,
|
||||
location: 120,
|
||||
});
|
||||
return fuse.search(query).filter((x) => x.score < 0.3);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { FuseItemT, GroupedHitsT, SearchHitT, SearchResultsT } from "@/types";
|
||||
import Fuse from "fuse.js";
|
||||
|
||||
export function createSearchResult(
|
||||
result: SearchHitT[],
|
||||
rawResults: Fuse.FuseResult<FuseItemT>[]
|
||||
) {
|
||||
const groupedHits: GroupedHitsT = result?.reduce((prev, cur) => {
|
||||
if (cur.item._type in prev) {
|
||||
return { ...prev, [cur.item._type]: [...prev[cur.item._type], cur] };
|
||||
} else {
|
||||
return { ...prev, [cur.item._type]: [cur] };
|
||||
}
|
||||
}, {});
|
||||
|
||||
const response: SearchResultsT = {
|
||||
groupedHits,
|
||||
topResults:
|
||||
result?.length > 4 ? result.slice(0, 4).filter((x) => x.score < 0.1) : [],
|
||||
totalHits: result?.length ?? 0,
|
||||
hits: {
|
||||
komponent_artikkel: rawResults.filter(
|
||||
(x: any) => x.item._type === "komponent_artikkel"
|
||||
).length,
|
||||
aksel_artikkel: rawResults.filter(
|
||||
(x: any) => x.item._type === "aksel_artikkel"
|
||||
).length,
|
||||
ds_artikkel: rawResults.filter((x: any) => x.item._type === "ds_artikkel")
|
||||
.length,
|
||||
aksel_blogg: rawResults.filter((x: any) => x.item._type === "aksel_blogg")
|
||||
.length,
|
||||
aksel_prinsipp: rawResults.filter(
|
||||
(x: any) => x.item._type === "aksel_prinsipp"
|
||||
).length,
|
||||
aksel_standalone: rawResults.filter(
|
||||
(x: any) => x.item._type === "aksel_prinsipp"
|
||||
).length,
|
||||
},
|
||||
};
|
||||
|
||||
return response;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import React from "react";
|
||||
import cl from "clsx";
|
||||
import Fuse from "fuse.js";
|
||||
import { searchOptions } from "@/types";
|
||||
|
||||
function splitStr(str: string, query: string) {
|
||||
const regexStr = query.toLowerCase().split(" ").join("|");
|
||||
return str.split(new RegExp(`(${regexStr})`, "gi"));
|
||||
}
|
||||
|
||||
export function highlightStr(
|
||||
str: string,
|
||||
query: string,
|
||||
tag: keyof typeof searchOptions
|
||||
) {
|
||||
if (!query) {
|
||||
return str;
|
||||
}
|
||||
|
||||
const getClass = (part: string) => {
|
||||
if (
|
||||
!query
|
||||
.split(" ")
|
||||
.map((x) => x.toLowerCase())
|
||||
.includes(part.toLowerCase())
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return cl(
|
||||
"text-text-default group-focus-visible:text-text-on-action group-focus-visible:bg-transparent",
|
||||
{
|
||||
"bg-gray-200": !tag,
|
||||
"bg-teal-100": tag === "aksel_artikkel",
|
||||
"bg-deepblue-100": ["komponent_artikkel", "ds_artikkel"].includes(tag),
|
||||
"bg-violet-100": ["aksel_prinsipp", "aksel_standalone"].includes(tag),
|
||||
"bg-pink-100": tag === "aksel_blogg",
|
||||
}
|
||||
);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
{splitStr(str, query)
|
||||
.filter((x) => !!x)
|
||||
.map((part, i) => (
|
||||
<span key={i} className={getClass(part)}>
|
||||
{part}
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function highlightMatches(hitMatch: Fuse.FuseResultMatch) {
|
||||
const text = hitMatch.value;
|
||||
const result = [];
|
||||
const matches = [...hitMatch.indices];
|
||||
|
||||
let index = 0;
|
||||
matches.forEach((match, i) => {
|
||||
const str = text.substring(match[0], match[1] - 1);
|
||||
let preText = text.substring(index, match[0]);
|
||||
|
||||
if (i === 0) {
|
||||
const preLength = preText.length;
|
||||
preText = preText.substring(preText.length - 23);
|
||||
if (preText.length < preLength) {
|
||||
preText = `...${preText}`;
|
||||
}
|
||||
}
|
||||
|
||||
result.push({ text: preText, highlight: false });
|
||||
result.push({ text: str, highlight: true });
|
||||
index = match[1] - 1;
|
||||
if (i === matches.length - 1) {
|
||||
result.push({ text: text.substring(index), highlight: false });
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<span>
|
||||
{result.map((part, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={cl({
|
||||
"text-text-default font-semibold": part.highlight,
|
||||
})}
|
||||
>
|
||||
{part.text}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./format-result";
|
||||
export * from "./fuse-search";
|
||||
export * from "./group-results";
|
||||
export * from "./highlighter";
|
||||
@@ -6,7 +6,7 @@ import cl from "clsx";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { useSection } from "./use-sections";
|
||||
import { useSection } from "./useSection";
|
||||
|
||||
const NavItem = ({
|
||||
link,
|
||||
|
||||
File renamed without changes.
@@ -74,7 +74,7 @@ const Element = ({ block }: { block: DoDontT["blokker"][number] }) => {
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
<span className="absolute left-[10px] z-10 translate-y-2">
|
||||
<span className="absolute right-[10px] z-10 translate-y-2">
|
||||
{GetIcon(block.variant)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
File renamed without changes.
@@ -3,7 +3,7 @@ import { TokenTableT } from "@/types";
|
||||
import { ChevronDownCircleIcon } from "@navikt/aksel-icons";
|
||||
import core from "@navikt/ds-css/tokens.json";
|
||||
import { BodyLong, CopyButton, Label, Link } from "@navikt/ds-react";
|
||||
import cl from "classnames";
|
||||
import cl from "clsx";
|
||||
import { AkselTable, AkselTableRow } from "components/website-modules/Table";
|
||||
import NextLink from "next/link";
|
||||
import { useState } from "react";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { FeedbackT } from "@/types";
|
||||
import { IdContext } from "@/utils";
|
||||
import { Button, Heading, Textarea } from "@navikt/ds-react";
|
||||
import cl from "classnames";
|
||||
import cl from "clsx";
|
||||
import { useRouter } from "next/router";
|
||||
import { useContext, useState } from "react";
|
||||
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { withErrorBoundary } from "@/error-boundary";
|
||||
|
||||
import {
|
||||
ResolveContributorsSingleT,
|
||||
ResolveTemaT,
|
||||
ResolveSlugT,
|
||||
AkselGodPraksisDocT,
|
||||
ResolveContributorsSingleT,
|
||||
ResolveSlugT,
|
||||
ResolveTemaT,
|
||||
} from "@/types";
|
||||
import { BodyShort, Detail, Heading } from "@navikt/ds-react";
|
||||
import { useFormatedDate } from "components/website-modules/utils/getDate";
|
||||
import NextLink from "next/link";
|
||||
import { abbrName, dateStr, logNav } from "../..";
|
||||
import { abbrName, logNav } from "../..";
|
||||
|
||||
const ArtikkelCard = ({
|
||||
slug,
|
||||
@@ -25,11 +26,9 @@ const ArtikkelCard = ({
|
||||
}: ResolveContributorsSingleT<
|
||||
ResolveTemaT<ResolveSlugT<AkselGodPraksisDocT>>
|
||||
> & { source: string; variant: string; level?: "2" | "3" }) => {
|
||||
const date = (rest as any)?.updateInfo?.lastVerified
|
||||
? (rest as any)?.updateInfo?.lastVerified
|
||||
: publishedAt
|
||||
? publishedAt
|
||||
: _updatedAt;
|
||||
const date = useFormatedDate(
|
||||
(rest as any)?.updateInfo?.lastVerified ?? publishedAt ?? _updatedAt
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="hover:shadow-small focus-within:ring-border-focus bg-surface-default ring-border-subtle group relative rounded-lg p-3 pb-16 ring-1 focus-within:ring-[3px] sm:p-5 sm:pb-16">
|
||||
@@ -70,7 +69,7 @@ const ArtikkelCard = ({
|
||||
</Detail>
|
||||
)}
|
||||
<Detail as="span" className="text-text-subtle">
|
||||
{dateStr(date)}
|
||||
{date}
|
||||
</Detail>
|
||||
</span>
|
||||
) : (
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { withErrorBoundary } from "@/error-boundary";
|
||||
import { AkselBloggDocT, ResolveContributorsT, ResolveSlugT } from "@/types";
|
||||
import { BodyLong, BodyShort, Heading, Link } from "@navikt/ds-react";
|
||||
import { getAuthors } from "components/website-modules/LatestBloggs";
|
||||
import { useFormatedDate } from "components/website-modules/utils/getDate";
|
||||
import NextLink from "next/link";
|
||||
import { dateStr, logNav } from "../..";
|
||||
import { getAuthors, logNav } from "@/utils";
|
||||
|
||||
const BloggCard = ({
|
||||
blog,
|
||||
}: {
|
||||
blog: ResolveContributorsT<ResolveSlugT<AkselBloggDocT>>;
|
||||
}) => {
|
||||
const date = useFormatedDate(blog?.publishedAt ?? blog._createdAt);
|
||||
return (
|
||||
<li
|
||||
key={blog._id}
|
||||
@@ -38,7 +39,7 @@ const BloggCard = ({
|
||||
className="text-text-subtle mt-auto flex gap-2 justify-self-end"
|
||||
>
|
||||
<span className="font-semibold">{getAuthors(blog)[0]}</span>
|
||||
<span>{dateStr(blog?.publishedAt ?? blog._createdAt)}</span>
|
||||
<span>{date}</span>
|
||||
</BodyShort>
|
||||
)}
|
||||
</li>
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { abbrName, dateStr } from "@/utils";
|
||||
import { BodyShort, Detail, Heading } from "@navikt/ds-react";
|
||||
import { logNav } from "components/website-modules/utils/amplitude";
|
||||
import NextLink from "next/link";
|
||||
import NextImage from "next/legacy/image";
|
||||
import { Tag } from "./Tag";
|
||||
import cl from "clsx";
|
||||
import { getImage } from "components/website-modules/utils/get-image";
|
||||
import { urlFor } from "@/sanity/interface";
|
||||
import { withErrorBoundary } from "@/error-boundary";
|
||||
import { urlFor } from "@/sanity/interface";
|
||||
import { abbrName } from "@/utils";
|
||||
import { BodyShort, Detail, Heading } from "@navikt/ds-react";
|
||||
import cl from "clsx";
|
||||
import { logNav } from "components/website-modules/utils/amplitude";
|
||||
import { getImage } from "components/website-modules/utils/get-image";
|
||||
import { useFormatedDate } from "components/website-modules/utils/getDate";
|
||||
import NextImage from "next/legacy/image";
|
||||
import NextLink from "next/link";
|
||||
import { Tag } from "./Tag";
|
||||
|
||||
export type ArticleT = {
|
||||
_key: string;
|
||||
@@ -37,7 +38,7 @@ const Card = ({
|
||||
visible: boolean;
|
||||
index: number;
|
||||
}) => {
|
||||
const date = article.publishedAt ? article.publishedAt : article._updatedAt;
|
||||
const date = useFormatedDate(article.publishedAt ?? article._updatedAt);
|
||||
|
||||
const showFooter = ["aksel_artikkel", "aksel_blogg"].includes(article._type);
|
||||
const showImage = [
|
||||
@@ -126,7 +127,7 @@ const Card = ({
|
||||
<Tag
|
||||
type={article._type}
|
||||
text={article.tema ? article.tema[0] : undefined}
|
||||
size="small"
|
||||
size="xsmall"
|
||||
beta={article.status?.tag === "beta"}
|
||||
/>
|
||||
</div>
|
||||
@@ -142,7 +143,7 @@ const Card = ({
|
||||
{abbrName(article?.contributors[0]?.title)}
|
||||
</Detail>
|
||||
)}
|
||||
<Detail as="span">{dateStr(date)}</Detail>
|
||||
<Detail as="span">{date}</Detail>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { urlFor } from "@/sanity/interface";
|
||||
import { getAuthors, logNav } from "@/utils";
|
||||
import { BodyLong, BodyShort, Heading, Link } from "@navikt/ds-react";
|
||||
import cl from "clsx";
|
||||
import { getImage } from "components/website-modules/utils/get-image";
|
||||
import { useFormatedDate } from "components/website-modules/utils/getDate";
|
||||
import Image from "next/legacy/image";
|
||||
import NextLink from "next/link";
|
||||
import { ArticleT } from "./Card";
|
||||
import { Tag } from "./Tag";
|
||||
|
||||
export const Highlight = ({
|
||||
article,
|
||||
compact,
|
||||
}: {
|
||||
article: ArticleT;
|
||||
compact: boolean;
|
||||
}) => {
|
||||
const showFooter = ["aksel_artikkel", "aksel_blogg"].includes(article._type);
|
||||
const useStatusImage =
|
||||
["ds_artikkel", "komponent_artikkel"].includes(article._type) &&
|
||||
article.status?.bilde;
|
||||
|
||||
const date = useFormatedDate(article?.publishedAt ?? article._createdAt);
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label={`Fremhevet artikkel: ${article?.heading}`}
|
||||
className={cl({
|
||||
"grid content-start gap-8 md:grid-cols-2": compact,
|
||||
"grid content-start gap-6": !compact,
|
||||
})}
|
||||
>
|
||||
<div className="relative block aspect-video">
|
||||
{useStatusImage ? (
|
||||
<Image
|
||||
src={urlFor(article.status.bilde).quality(100).auto("format").url()}
|
||||
quality={100}
|
||||
layout="fill"
|
||||
aria-hidden
|
||||
className={cl(
|
||||
"bg-deepblue-200 rounded-lg object-cover sm:object-contain",
|
||||
{
|
||||
"hue-rotate-[65deg]": article?.status?.tag === "beta",
|
||||
}
|
||||
)}
|
||||
decoding="auto"
|
||||
/>
|
||||
) : article?.seo?.image ? (
|
||||
<Image
|
||||
src={urlFor(article.seo.image).quality(100).auto("format").url()}
|
||||
quality={100}
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
aria-hidden
|
||||
className={cl("rounded-lg", {
|
||||
"hue-rotate-[65deg]": article?.status?.tag === "beta",
|
||||
})}
|
||||
decoding="auto"
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src={getImage(article?.heading ?? "", "thumbnail")}
|
||||
layout="fill"
|
||||
objectFit="contain"
|
||||
aria-hidden
|
||||
className="rounded-lg"
|
||||
decoding="auto"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Tag
|
||||
type={article._type}
|
||||
text={article.tema ? article.tema[0] : undefined}
|
||||
beta={article?.status?.tag === "beta"}
|
||||
/>
|
||||
<NextLink href={`/${article.slug.current}`} passHref legacyBehavior>
|
||||
<Link
|
||||
onClick={(e) =>
|
||||
logNav(
|
||||
"artikkel-kort",
|
||||
window.location.pathname,
|
||||
e.currentTarget.getAttribute("href")
|
||||
)
|
||||
}
|
||||
className="text-text-default mb-5 mt-2 no-underline hover:underline"
|
||||
>
|
||||
<Heading size="large" level="3">
|
||||
{article?.heading}
|
||||
</Heading>
|
||||
</Link>
|
||||
</NextLink>
|
||||
<BodyLong className="mb-4" size="small">
|
||||
{article?.ingress ?? article.seo?.meta}
|
||||
</BodyLong>
|
||||
{showFooter && getAuthors(article as any).length > 0 && (
|
||||
<BodyShort size="small" className="text-text-subtle flex gap-2">
|
||||
<span className="font-semibold">
|
||||
{getAuthors(article as any)[0]}
|
||||
</span>
|
||||
<span>{date}</span>
|
||||
</BodyShort>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -1,16 +1,10 @@
|
||||
import { BodyLong, BodyShort, Heading, Link } from "@navikt/ds-react";
|
||||
import Masonry, { ResponsiveMasonry } from "react-responsive-masonry";
|
||||
import { withErrorBoundary } from "@/error-boundary";
|
||||
import Image from "next/legacy/image";
|
||||
import NextLink from "next/link";
|
||||
import { getImage } from "components/website-modules/utils/get-image";
|
||||
import { getAuthors } from "components/website-modules/LatestBloggs";
|
||||
import { dateStr, logNav } from "@/utils";
|
||||
import { Tag } from "./Tag";
|
||||
import Card, { ArticleT } from "./Card";
|
||||
import { Heading } from "@navikt/ds-react";
|
||||
import cl from "clsx";
|
||||
import { urlFor } from "@/sanity/interface";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Masonry, { ResponsiveMasonry } from "react-responsive-masonry";
|
||||
import Card, { ArticleT } from "./Card";
|
||||
import { Highlight } from "./Highlight";
|
||||
|
||||
export type LatestT = {
|
||||
_type: "nytt_fra_aksel";
|
||||
@@ -29,9 +23,12 @@ const Latest = ({ block }: { block: LatestT }) => {
|
||||
const section = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(([entry]) => {
|
||||
entry.isIntersecting && setIntersected(entry.isIntersecting);
|
||||
});
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
entry.isIntersecting && setIntersected(entry.isIntersecting);
|
||||
},
|
||||
{ rootMargin: "0px 0px 100px 0px" }
|
||||
);
|
||||
observer.observe(section.current);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
@@ -74,99 +71,9 @@ function Highlights({ highlights }: { highlights: ArticleT[] }) {
|
||||
<div
|
||||
className={cl({ "grid gap-8 md:grid-cols-2": highlights?.length === 2 })}
|
||||
>
|
||||
{highlights.map((x, idx) => {
|
||||
const showFooter = ["aksel_artikkel", "aksel_blogg"].includes(x._type);
|
||||
const useStatusImage =
|
||||
["ds_artikkel", "komponent_artikkel"].includes(x._type) &&
|
||||
x.status?.bilde;
|
||||
return (
|
||||
<section
|
||||
aria-label={`Fremhevet artikkel: ${x?.heading}`}
|
||||
key={idx}
|
||||
className={cl({
|
||||
"grid content-start gap-8 md:grid-cols-2":
|
||||
highlights?.length === 1,
|
||||
"grid content-start gap-6": highlights?.length === 2,
|
||||
})}
|
||||
>
|
||||
<div className="relative block aspect-video">
|
||||
{useStatusImage ? (
|
||||
<Image
|
||||
src={urlFor(x.status.bilde).quality(100).auto("format").url()}
|
||||
quality={100}
|
||||
layout="fill"
|
||||
aria-hidden
|
||||
priority
|
||||
className={cl(
|
||||
"bg-deepblue-200 rounded-lg object-cover sm:object-contain",
|
||||
{
|
||||
"hue-rotate-[65deg]": x?.status?.tag === "beta",
|
||||
}
|
||||
)}
|
||||
decoding="sync"
|
||||
/>
|
||||
) : x?.seo?.image ? (
|
||||
<Image
|
||||
src={urlFor(x.seo.image).quality(100).auto("format").url()}
|
||||
quality={100}
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
aria-hidden
|
||||
priority
|
||||
className={cl("rounded-lg", {
|
||||
"hue-rotate-[65deg]": x?.status?.tag === "beta",
|
||||
})}
|
||||
decoding="sync"
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src={getImage(x?.heading ?? "", "thumbnail")}
|
||||
layout="fill"
|
||||
objectFit="contain"
|
||||
aria-hidden
|
||||
priority
|
||||
className="rounded-lg"
|
||||
decoding="sync"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Tag
|
||||
type={x._type}
|
||||
text={x.tema ? x.tema[0] : undefined}
|
||||
beta={x?.status?.tag === "beta"}
|
||||
/>
|
||||
<NextLink href={`/${x.slug.current}`} passHref legacyBehavior>
|
||||
<Link
|
||||
onClick={(e) =>
|
||||
logNav(
|
||||
"artikkel-kort",
|
||||
window.location.pathname,
|
||||
e.currentTarget.getAttribute("href")
|
||||
)
|
||||
}
|
||||
className="text-text-default mb-5 mt-2 no-underline hover:underline"
|
||||
>
|
||||
<Heading size="large" level="3">
|
||||
{x?.heading}
|
||||
</Heading>
|
||||
</Link>
|
||||
</NextLink>
|
||||
<BodyLong className="mb-4" size="small">
|
||||
{x?.ingress ?? x.seo?.meta}
|
||||
</BodyLong>
|
||||
{showFooter && getAuthors(x as any).length > 0 && (
|
||||
<BodyShort size="small" className="text-text-subtle flex gap-2">
|
||||
<span className="font-semibold">
|
||||
{getAuthors(x as any)[0]}
|
||||
</span>
|
||||
<span>{dateStr(x?.publishedAt ?? x._createdAt)}</span>
|
||||
</BodyShort>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
{highlights.map((x, idx) => (
|
||||
<Highlight article={x} key={idx} compact={highlights.length === 1} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,22 +6,36 @@ export const Tag = ({
|
||||
text,
|
||||
size = "medium",
|
||||
beta = false,
|
||||
inline = false,
|
||||
hTag,
|
||||
count,
|
||||
...rest
|
||||
}: {
|
||||
type: string;
|
||||
text?: string;
|
||||
size?: "medium" | "small";
|
||||
size?: "medium" | "small" | "xsmall";
|
||||
beta?: boolean;
|
||||
inline?: boolean;
|
||||
hTag?: string;
|
||||
count?: number;
|
||||
}) => {
|
||||
const Comp = ({ ...props }: any) =>
|
||||
size === "medium" ? (
|
||||
<Heading {...props} as="div" size="medium" />
|
||||
size === "medium" || size === "small" ? (
|
||||
<Heading {...rest} {...props} as={hTag ?? "div"} size={size}>
|
||||
{props.children}
|
||||
{`${count ? ` (${count})` : ""}`}
|
||||
</Heading>
|
||||
) : (
|
||||
<Label
|
||||
{...rest}
|
||||
{...props}
|
||||
as="div"
|
||||
as={hTag ?? "div"}
|
||||
size="small"
|
||||
className={cl(props?.className, "mb-2")}
|
||||
/>
|
||||
className={cl(props?.className, !inline && "mb-2")}
|
||||
>
|
||||
{props.children}
|
||||
{`${count ? ` (${count})` : ""}`}
|
||||
</Label>
|
||||
);
|
||||
|
||||
switch (type) {
|
||||
@@ -30,13 +44,17 @@ export const Tag = ({
|
||||
<Comp className="text-teal-700">{text ? text : "God praksis"}</Comp>
|
||||
);
|
||||
case "aksel_blogg":
|
||||
return <Comp className="text-pink-600">Blogg</Comp>;
|
||||
return <Comp className="text-pink-700">Blogg</Comp>;
|
||||
case "komponent_artikkel":
|
||||
return (
|
||||
<Comp className="text-deepblue-600 flex items-center gap-3">
|
||||
Komponent
|
||||
{beta && (
|
||||
<DsTag variant="alt1" size="small">
|
||||
<DsTag
|
||||
variant="alt1"
|
||||
size="small"
|
||||
className="border-violet-300 bg-violet-50"
|
||||
>
|
||||
Beta
|
||||
</DsTag>
|
||||
)}
|
||||
@@ -53,6 +71,18 @@ export const Tag = ({
|
||||
)}
|
||||
</Comp>
|
||||
);
|
||||
case "aksel_prinsipp":
|
||||
return (
|
||||
<Comp className="flex items-center gap-3 text-violet-800">
|
||||
Prinsipper
|
||||
</Comp>
|
||||
);
|
||||
case "aksel_standalone":
|
||||
return (
|
||||
<Comp className="flex items-center gap-3 text-violet-800">
|
||||
Unike sider
|
||||
</Comp>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
|
||||
@@ -9,7 +9,7 @@ export const Grid = ({
|
||||
stacked?: boolean;
|
||||
}) => (
|
||||
<div
|
||||
className={cl("mt-8 grid", {
|
||||
className={cl("mt-4 grid", {
|
||||
"grid-cols-[repeat(auto-fit,_minmax(16rem,_1fr))] gap-6 xl:grid-cols-3":
|
||||
!stacked,
|
||||
"gap-8": stacked,
|
||||
|
||||
@@ -169,7 +169,7 @@ export const SemanticView = ({ cat }: { cat: string }) => {
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className="rounded-t-small h-2/3 w-3"
|
||||
className="rounded-t-small h-2/4 w-3"
|
||||
style={{
|
||||
border: `1px solid var(${x.name})`,
|
||||
backgroundColor: `var(${x.name.replace(
|
||||
@@ -179,7 +179,7 @@ export const SemanticView = ({ cat }: { cat: string }) => {
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className="rounded-t-small h-1/3 w-3"
|
||||
className="rounded-t-small h-2/3 w-3"
|
||||
style={{
|
||||
border: `1px solid var(${x.name})`,
|
||||
backgroundColor: `var(${x.name.replace(
|
||||
@@ -233,11 +233,11 @@ export const SemanticView = ({ cat }: { cat: string }) => {
|
||||
style={{ backgroundColor: `var(${x.name})` }}
|
||||
/>
|
||||
<span
|
||||
className="rounded-t-small h-2/3 w-3"
|
||||
className="rounded-t-small h-2/4 w-3"
|
||||
style={{ backgroundColor: `var(${x.name})` }}
|
||||
/>
|
||||
<span
|
||||
className="rounded-t-small h-1/3 w-3"
|
||||
className="rounded-t-small h-2/3 w-3"
|
||||
style={{ backgroundColor: `var(${x.name})` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Tooltip } from "@navikt/ds-react";
|
||||
import { useContext } from "react";
|
||||
import { IdContext, useCheckAuth } from "./utils";
|
||||
import { IdContext } from "./utils";
|
||||
import { PencilIcon } from "@navikt/aksel-icons";
|
||||
import { useCheckAuth } from "components/website-modules/utils/useCheckAuth";
|
||||
|
||||
function EditButton() {
|
||||
const idCtx = useContext(IdContext);
|
||||
@@ -9,26 +10,24 @@ function EditButton() {
|
||||
const validUser = useCheckAuth();
|
||||
|
||||
return validUser && idCtx?.id ? (
|
||||
<>
|
||||
<div className="overflow-hidden">
|
||||
<Tooltip
|
||||
content="Vises bare for innloggede redaktører"
|
||||
placement="left"
|
||||
delay={500}
|
||||
<div className="overflow-hidden">
|
||||
<Tooltip
|
||||
content="Vises bare for innloggede redaktører"
|
||||
placement="left"
|
||||
delay={500}
|
||||
>
|
||||
<a
|
||||
href={`https://aksel.nav.no/admin/prod/intent/edit/id=${idCtx?.id}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="editbutton bg-deepblue-800 hover:bg-deepblue-700 absolute right-0 top-0 flex -translate-y-[99%] items-center gap-2 overflow-hidden rounded-tl px-2 py-1 text-white transition-transform hover:translate-x-0"
|
||||
aria-hidden
|
||||
tabIndex={-1}
|
||||
>
|
||||
<a
|
||||
href={`https://aksel.nav.no/admin/prod/intent/edit/id=${idCtx?.id}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="editbutton bg-deepblue-800 hover:bg-deepblue-700 absolute right-0 top-0 flex -translate-y-[99%] items-center gap-2 overflow-hidden rounded-tl px-2 py-1 text-white transition-transform hover:translate-x-0"
|
||||
aria-hidden
|
||||
tabIndex={-1}
|
||||
>
|
||||
<PencilIcon aria-hidden className="shrink-0 text-xl" /> Rediger side
|
||||
</a>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</>
|
||||
<PencilIcon aria-hidden className="shrink-0 text-xl" /> Rediger side
|
||||
</a>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
export const InlineCode = (props: React.HTMLAttributes<HTMLElement>) => (
|
||||
<code className="text-deepblue-800 font-mono text-sm font-semibold leading-normal before:contents">
|
||||
<span aria-hidden>`</span>
|
||||
<span aria-hidden className="inline-code-amp">
|
||||
`
|
||||
</span>
|
||||
<span>{props.children}</span>
|
||||
<span aria-hidden>`</span>
|
||||
<span aria-hidden className="inline-code-amp">
|
||||
`
|
||||
</span>
|
||||
</code>
|
||||
);
|
||||
@@ -29,6 +29,7 @@ export const IntroCards = ({
|
||||
"shadow-small max-w-md": variant === "forside",
|
||||
}
|
||||
)}
|
||||
prefetch={false}
|
||||
onClick={(e) =>
|
||||
logNav(
|
||||
"intro-kort",
|
||||
|
||||
@@ -3,8 +3,9 @@ import cl from "clsx";
|
||||
export const KBD = (props: React.HTMLAttributes<HTMLElement>) => (
|
||||
<kbd
|
||||
className={cl(
|
||||
"mx-05 ring-border-subtle my-0 inline-block min-w-[2rem] rounded-md px-2 py-[1px] text-center font-mono text-sm text-gray-900 ring-1 ring-inset",
|
||||
"from-surface-default via-surface-default to-surface-neutral-subtle bg-gradient-to-b"
|
||||
"mx-05 ring-border-subtle my-0 inline-block min-w-[2rem] rounded-md px-2 py-[1px] text-center font-mono text-sm text-gray-900 ring-1 ring-inset",
|
||||
"from-surface-default via-surface-default to-surface-neutral-subtle bg-gradient-to-b",
|
||||
"group-active:text-text-on-inverted group-active:ring-grayalpha-400 group-active:from-gray-700 group-active:via-gray-700 group-active:to-gray-900"
|
||||
)}
|
||||
>
|
||||
<span className="kbd">{props.children}</span>
|
||||
|
||||
@@ -1,270 +0,0 @@
|
||||
import { SanityBlockContent } from "@/sanity-block";
|
||||
import { urlFor } from "@/sanity/interface";
|
||||
import { AkselBloggDocT, ResolveContributorsT, ResolveSlugT } from "@/types";
|
||||
import { dateStr } from "@/utils";
|
||||
import { BodyLong, BodyShort, Heading, Link } from "@navikt/ds-react";
|
||||
import { getImage } from "components/website-modules/utils/get-image";
|
||||
import Image from "next/legacy/image";
|
||||
import NextLink from "next/link";
|
||||
|
||||
export const getAuthors = (
|
||||
blog: ResolveContributorsT<ResolveSlugT<AkselBloggDocT>>
|
||||
) => (blog?.contributors as any)?.map((x) => x?.title) ?? [];
|
||||
|
||||
export const LatestBloggposts = ({
|
||||
bloggs,
|
||||
title,
|
||||
level = "1",
|
||||
intro,
|
||||
}: {
|
||||
bloggs: ResolveContributorsT<ResolveSlugT<AkselBloggDocT>>[];
|
||||
title: string;
|
||||
level?: "1" | "2";
|
||||
intro?: any[];
|
||||
}) => {
|
||||
if (!bloggs || bloggs.length < 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-20">
|
||||
<Heading
|
||||
level={level}
|
||||
size="xlarge"
|
||||
className="text-deepblue-700 mx-auto w-full text-5xl md:mx-0 md:max-w-none"
|
||||
>
|
||||
{title}
|
||||
</Heading>
|
||||
{intro && (
|
||||
<SanityBlockContent
|
||||
blocks={intro}
|
||||
isIngress
|
||||
noLastMargin
|
||||
className="mt-4"
|
||||
/>
|
||||
)}
|
||||
{/* Desktop-view */}
|
||||
<div className="my-12 hidden grid-cols-2 gap-12 md:grid">
|
||||
<div className="col-span-1">
|
||||
<div className="relative mb-10 block aspect-video">
|
||||
{bloggs[0]?.seo?.image ? (
|
||||
<Image
|
||||
src={urlFor(bloggs[0].seo.image)
|
||||
.quality(100)
|
||||
.auto("format")
|
||||
.url()}
|
||||
quality={100}
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
aria-hidden
|
||||
priority
|
||||
className="rounded-lg"
|
||||
decoding="sync"
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src={getImage(bloggs[0]?.heading ?? "", "thumbnail")}
|
||||
layout="fill"
|
||||
objectFit="contain"
|
||||
aria-hidden
|
||||
priority
|
||||
className="rounded-lg"
|
||||
decoding="sync"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<NextLink href={`/${bloggs[0].slug}`} passHref legacyBehavior>
|
||||
<Link className="text-deepblue-500 no-underline hover:underline">
|
||||
<Heading
|
||||
size="large"
|
||||
level={(Number(level) + 1).toString() as any}
|
||||
>
|
||||
{bloggs[0].heading}
|
||||
</Heading>
|
||||
</Link>
|
||||
</NextLink>
|
||||
<BodyLong className="mt-4" size="small">
|
||||
{bloggs[0]?.ingress}
|
||||
</BodyLong>
|
||||
{getAuthors(bloggs[0]).length > 0 && (
|
||||
<BodyShort
|
||||
size="small"
|
||||
className="text-text-subtle mt-4 flex gap-2"
|
||||
>
|
||||
<span className="font-semibold">{getAuthors(bloggs[0])[0]}</span>
|
||||
<span>
|
||||
{dateStr(bloggs[0]?.publishedAt ?? bloggs[0]._createdAt)}
|
||||
</span>
|
||||
</BodyShort>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-span-1 grid place-content-start gap-12">
|
||||
{bloggs.slice(1, 3).map((blog) => (
|
||||
<div key={blog._id} className="flex gap-6">
|
||||
<div className="relative hidden aspect-square h-[11.75rem] lg:block">
|
||||
{blog?.seo?.image ? (
|
||||
<Image
|
||||
src={urlFor(blog.seo.image).auto("format").url()}
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
aria-hidden
|
||||
priority
|
||||
className="rounded-lg"
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src={getImage(blog?.heading ?? "", "thumbnail")}
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
aria-hidden
|
||||
priority
|
||||
className="rounded-lg"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<NextLink href={`/${blog.slug}`} passHref legacyBehavior>
|
||||
<Link className="text-deepblue-500 no-underline hover:underline">
|
||||
<Heading
|
||||
size="small"
|
||||
level={(Number(level) + 1).toString() as any}
|
||||
>
|
||||
{blog.heading}
|
||||
</Heading>
|
||||
</Link>
|
||||
</NextLink>
|
||||
<BodyLong className="mt-4" size="small">
|
||||
{blog?.ingress}
|
||||
</BodyLong>
|
||||
{getAuthors(blog).length > 0 ? (
|
||||
<BodyShort
|
||||
size="small"
|
||||
className="text-text-subtle mt-4 flex gap-2"
|
||||
>
|
||||
<span className="font-semibold">{getAuthors(blog)[0]}</span>
|
||||
<span>{dateStr(blog?.publishedAt ?? blog._createdAt)}</span>
|
||||
</BodyShort>
|
||||
) : (
|
||||
<BodyShort size="small" className="text-text-subtle mt-4">
|
||||
<span>{dateStr(blog?.publishedAt ?? blog._createdAt)}</span>
|
||||
</BodyShort>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile view */}
|
||||
<div className="my-10 mx-auto grid gap-12 md:hidden">
|
||||
<div className="w-full">
|
||||
<div className="relative mb-10 block aspect-video">
|
||||
{bloggs[0]?.seo?.image ? (
|
||||
<Image
|
||||
src={urlFor(bloggs[0].seo.image)
|
||||
.quality(100)
|
||||
.auto("format")
|
||||
.url()}
|
||||
quality={100}
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
aria-hidden
|
||||
priority
|
||||
className="rounded-lg"
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src={getImage(bloggs[0]?.heading ?? "", "thumbnail")}
|
||||
layout="fill"
|
||||
objectFit="contain"
|
||||
aria-hidden
|
||||
priority
|
||||
className="rounded-lg"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<NextLink href={`/${bloggs[0].slug}`} passHref legacyBehavior>
|
||||
<Link className="text-deepblue-500 no-underline hover:underline">
|
||||
<Heading
|
||||
size="large"
|
||||
level={(Number(level) + 1).toString() as any}
|
||||
>
|
||||
{bloggs[0].heading}
|
||||
</Heading>
|
||||
</Link>
|
||||
</NextLink>
|
||||
<BodyLong className="mt-4" size="small">
|
||||
{bloggs[0]?.ingress}
|
||||
</BodyLong>
|
||||
{getAuthors(bloggs[0]).length > 0 && (
|
||||
<BodyShort
|
||||
size="small"
|
||||
className="text-text-subtle mt-4 flex gap-2"
|
||||
>
|
||||
<span className="font-semibold">{getAuthors(bloggs[0])[0]}</span>
|
||||
<span>
|
||||
{dateStr(bloggs[0]?.publishedAt ?? bloggs[0]._createdAt)}
|
||||
</span>
|
||||
</BodyShort>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid w-full gap-12">
|
||||
{bloggs.slice(1, 3).map((blog) => (
|
||||
<div key={blog._id}>
|
||||
<div className="relative mb-6 block aspect-video">
|
||||
{blog?.seo?.image ? (
|
||||
<Image
|
||||
src={urlFor(blog.seo.image).auto("format").url()}
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
aria-hidden
|
||||
priority
|
||||
className="rounded-lg"
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src={getImage(blog?.heading ?? "", "thumbnail")}
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
aria-hidden
|
||||
priority
|
||||
className="rounded-lg"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<NextLink href={`/${blog.slug}`} passHref legacyBehavior>
|
||||
<Link className="text-deepblue-500 no-underline hover:underline">
|
||||
<Heading
|
||||
size="small"
|
||||
level={(Number(level) + 1).toString() as any}
|
||||
>
|
||||
{blog.heading}
|
||||
</Heading>
|
||||
</Link>
|
||||
</NextLink>
|
||||
<BodyLong className="mt-4" size="small">
|
||||
{blog?.ingress}
|
||||
</BodyLong>
|
||||
{getAuthors(blog).length > 0 ? (
|
||||
<BodyShort
|
||||
size="small"
|
||||
className="text-text-subtle mt-4 flex gap-2"
|
||||
>
|
||||
<span className="font-semibold">{getAuthors(blog)[0]}</span>
|
||||
<span>{dateStr(blog?.publishedAt ?? blog._createdAt)}</span>
|
||||
</BodyShort>
|
||||
) : (
|
||||
<BodyShort size="small" className="text-text-subtle mt-4">
|
||||
<span>{dateStr(blog?.publishedAt ?? blog._createdAt)}</span>
|
||||
</BodyShort>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* https://github.com/navikt/detsombetyrnoe/blob/main/src/components/PreviewBanner.tsx#L17
|
||||
*/
|
||||
import { useCheckAuth } from "./utils";
|
||||
import { BodyShort, Button } from "@navikt/ds-react";
|
||||
import { useCheckAuth } from "components/website-modules/utils/useCheckAuth";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
@@ -53,7 +53,7 @@ function PreviewBanner() {
|
||||
return (
|
||||
<a
|
||||
href={`/api/exit-preview?slug=${asPath}`}
|
||||
className="text-text-on-inverted focus-visible:shadow-focus-inverted fixed top-2 left-0 z-[9999] w-80 -translate-x-24 translate-y-6 -rotate-45 bg-gray-900 p-4 text-center font-semibold no-underline hover:bg-gray-700 focus:outline-none"
|
||||
className="text-text-on-inverted focus-visible:shadow-focus-inverted fixed left-0 top-2 z-[9999] w-80 -translate-x-24 translate-y-6 -rotate-45 bg-gray-900 p-4 text-center font-semibold no-underline hover:bg-gray-700 focus:outline-none"
|
||||
>
|
||||
<div>EXIT PREVIEW</div>
|
||||
</a>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { removeEmojies } from "@/utils";
|
||||
import { BodyShort, Link } from "@navikt/ds-react";
|
||||
import { BodyShort, Link, useClientLayoutEffect } from "@navikt/ds-react";
|
||||
import cl from "clsx";
|
||||
import throttle from "lodash/throttle";
|
||||
import * as React from "react";
|
||||
@@ -7,11 +7,11 @@ import { useEffect, useState } from "react";
|
||||
|
||||
export function TableOfContentsv2({
|
||||
changedState,
|
||||
hideToc = true,
|
||||
|
||||
aksel = false,
|
||||
}: {
|
||||
changedState: any;
|
||||
hideToc?: boolean;
|
||||
|
||||
aksel?: boolean;
|
||||
}) {
|
||||
const [toc, setToc] = useState<
|
||||
@@ -21,7 +21,7 @@ export function TableOfContentsv2({
|
||||
const [activeId, setActiveId] = useState(null);
|
||||
const [activeSubId, setActiveSubId] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
useClientLayoutEffect(() => {
|
||||
const time = setTimeout(() => {
|
||||
const main = document.getElementsByTagName("main")?.[0];
|
||||
const tags = main?.getElementsByTagName("h2");
|
||||
@@ -126,24 +126,27 @@ export function TableOfContentsv2({
|
||||
element && element?.scrollIntoView();
|
||||
};
|
||||
|
||||
const renderToc = !(toc.length < 2) && !hideToc;
|
||||
const renderToc = toc.length !== 0;
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cl(
|
||||
"toc-ignore sticky top-20 z-[1] order-1 my-0 ml-6 mb-16 mr-auto h-full w-full max-w-[160px] flex-col items-start sm:-mr-6 md:-mr-10",
|
||||
"toc-ignore sticky top-20 z-[1] order-1 my-0 mb-16 ml-6 mr-auto h-full w-full max-w-[160px] flex-col items-start sm:-mr-6 md:-mr-10",
|
||||
"invisible hidden xl:flex",
|
||||
{
|
||||
hidden: !renderToc,
|
||||
"hidden xl:flex": renderToc,
|
||||
invisible: !renderToc,
|
||||
"xl:visible": renderToc,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<BodyShort
|
||||
id="toc-heading"
|
||||
className="text-deepblue-800 mb-2 font-semibold"
|
||||
>
|
||||
Innhold på siden
|
||||
</BodyShort>
|
||||
{toc.length > 0 && (
|
||||
<BodyShort
|
||||
id="toc-heading"
|
||||
className="text-deepblue-800 animate-fadeIn mb-2 font-semibold "
|
||||
>
|
||||
Innhold på siden
|
||||
</BodyShort>
|
||||
)}
|
||||
|
||||
<nav
|
||||
aria-labelledby="toc-heading"
|
||||
@@ -157,9 +160,8 @@ export function TableOfContentsv2({
|
||||
<BodyShort
|
||||
as="li"
|
||||
size="small"
|
||||
className={cl("relative py-1", {
|
||||
className={cl("animate-fadeIn relative py-1 ", {
|
||||
"font-semibold": link.id === activeId,
|
||||
"": link.id !== activeId,
|
||||
})}
|
||||
>
|
||||
<Link
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { Heading } from "@navikt/ds-react";
|
||||
|
||||
export const ScrollTop = () => {
|
||||
return (
|
||||
<button
|
||||
onClick={() => {
|
||||
window.scrollTo(0, 0);
|
||||
document.getElementById("hovedinnhold")?.focus?.();
|
||||
}}
|
||||
className="text-xlarge text-text-on-inverted focus-visible:shadow-focus-inverted group -mt-[10px] flex h-[48px] w-fit items-center justify-center gap-2 rounded p-2 font-semibold hover:underline focus:outline-none"
|
||||
>
|
||||
<svg
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-[19px] w-[19px] translate-y-1 rotate-90 transition-all group-hover:translate-y-0"
|
||||
aria-hidden
|
||||
>
|
||||
<path
|
||||
d="M8 17.5L2.12132 11.6213C0.949744 10.4497 0.949746 8.55025 2.12132 7.37868L8 1.5"
|
||||
stroke="white"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M16 9.72266L8 9.72266"
|
||||
stroke="white"
|
||||
strokeWidth="1.5"
|
||||
className="hidden group-hover:block"
|
||||
/>
|
||||
</svg>
|
||||
<Heading as="span" size="small">
|
||||
Til toppen
|
||||
</Heading>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScrollTop;
|
||||
@@ -1,100 +0,0 @@
|
||||
import { Link } from "@navikt/ds-react";
|
||||
import NextLink from "next/link";
|
||||
import { logNav } from "../";
|
||||
|
||||
const tools = [
|
||||
/* {
|
||||
title: "Identitet",
|
||||
description: "Åpen for alle",
|
||||
href: "https://identitet.nav.no/",
|
||||
icon: (
|
||||
<svg
|
||||
className="h-7 w-7"
|
||||
width="48"
|
||||
height="48"
|
||||
viewBox="0 0 48 48"
|
||||
fill="none"
|
||||
focusable="false"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M19.25 27.4 21.05 21.65 16.3 17.95H22.1L24 12L25.85 17.95H31.7L26.95 21.65L28.7 27.4L24 23.85ZM12.2 46V30.8Q9.95 28.45 8.975 25.65Q8 22.85 8 20Q8 13.2 12.6 8.6Q17.2 4 24 4Q30.8 4 35.4 8.6Q40 13.2 40 20Q40 22.85 39.025 25.65Q38.05 28.45 35.8 30.8V46L24 42.05ZM24 33Q29.45 33 33.225 29.225Q37 25.45 37 20Q37 14.55 33.225 10.775Q29.45 7 24 7Q18.55 7 14.775 10.775Q11 14.55 11 20Q11 25.45 14.775 29.225Q18.55 33 24 33ZM15.2 41.8 24 39.05 32.8 41.8V33.25Q30.8 34.7 28.5 35.35Q26.2 36 24 36Q21.8 36 19.5 35.35Q17.2 34.7 15.2 33.25ZM24 37.5Q24 37.5 24 37.5Q24 37.5 24 37.5Q24 37.5 24 37.5Q24 37.5 24 37.5Z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
}, */
|
||||
{
|
||||
title: "Security Playbook",
|
||||
description: "Åpen for alle",
|
||||
href: "https://sikkerhet.nav.no/",
|
||||
icon: (
|
||||
<svg
|
||||
className="h-7 w-7"
|
||||
width="48"
|
||||
height="48"
|
||||
viewBox="0 0 48 48"
|
||||
fill="none"
|
||||
focusable="false"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M24 44Q17 42.25 12.5 35.875Q8 29.5 8 21.9V10L24 4L40 10V21.9Q40 29.5 35.5 35.875Q31 42.25 24 44ZM24 40.9Q29.3 39.15 32.775 34.475Q36.25 29.8 36.85 24H24V7.25L11 12.1V21.9Q11 22.5 11.025 22.925Q11.05 23.35 11.15 24H24Z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Etterlevelse",
|
||||
description: "Kun for ansatte",
|
||||
href: "https://etterlevelse.intern.nav.no/",
|
||||
icon: (
|
||||
<svg
|
||||
className="h-7 w-7"
|
||||
width="48"
|
||||
height="48"
|
||||
viewBox="0 0 48 48"
|
||||
fill="none"
|
||||
focusable="false"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M17.3 45 13.5 38.5 5.95 36.95 6.8 29.6 2 24 6.8 18.45 5.95 11.1 13.5 9.55 17.3 3 24 6.1 30.7 3 34.55 9.55 42.05 11.1 41.2 18.45 46 24 41.2 29.6 42.05 36.95 34.55 38.5 30.7 45 24 41.9ZM24 24ZM21.85 30.65 33.2 19.4 30.95 17.35 21.85 26.35 17.1 21.4 14.8 23.65ZM18.65 41.05 24 38.8 29.5 41.05 32.85 36.05 38.7 34.55 38.1 28.6 42.15 24 38.1 19.3 38.7 13.35 32.85 11.95 29.4 6.95 24 9.2 18.5 6.95 15.15 11.95 9.3 13.35 9.9 19.3 5.85 24 9.9 28.6 9.3 34.65 15.15 36.05Z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export const ToolCard = () => {
|
||||
return (
|
||||
<ul className="mx-auto flex w-full max-w-screen-lg flex-col items-center justify-center gap-16 rounded-2xl bg-[#99F6E4] px-2 py-12 md:flex-row md:justify-evenly md:gap-4">
|
||||
{tools.map((t) => (
|
||||
<li key={t.title} className="flex w-56 items-center gap-4 md:w-fit">
|
||||
<div className="rotate-45 rounded-lg bg-[#5EEAD4] p-3">
|
||||
<div className="-rotate-45">{t.icon}</div>
|
||||
</div>
|
||||
<div>
|
||||
<NextLink href={t.href} passHref legacyBehavior>
|
||||
<Link
|
||||
className="text-text-default mb-1 font-semibold"
|
||||
onClick={(e) =>
|
||||
logNav(
|
||||
"verktøy",
|
||||
window.location.pathname,
|
||||
e.currentTarget.getAttribute("href")
|
||||
)
|
||||
}
|
||||
>
|
||||
{t.title}
|
||||
</Link>
|
||||
</NextLink>
|
||||
<div>{t.description}</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
import { urlFor } from "@/sanity/interface";
|
||||
import { AkselBloggDocT, ResolveContributorsT, ResolveSlugT } from "@/types";
|
||||
import { getAuthors } from "@/utils";
|
||||
import { BodyLong, BodyShort, Heading, Link } from "@navikt/ds-react";
|
||||
import { getImage } from "components/website-modules/utils/get-image";
|
||||
import { useFormatedDate } from "components/website-modules/utils/getDate";
|
||||
import Image from "next/legacy/image";
|
||||
import NextLink from "next/link";
|
||||
|
||||
export const BloggList = ({
|
||||
blogg,
|
||||
}: {
|
||||
blogg: ResolveContributorsT<ResolveSlugT<AkselBloggDocT>>;
|
||||
}) => {
|
||||
const date = useFormatedDate(blogg?.publishedAt ?? blogg._createdAt);
|
||||
return (
|
||||
<div>
|
||||
<div className="hidden gap-6 md:flex">
|
||||
<div className="relative hidden aspect-square h-[11.75rem] lg:block">
|
||||
{blogg?.seo?.image ? (
|
||||
<Image
|
||||
src={urlFor(blogg.seo.image).auto("format").url()}
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
aria-hidden
|
||||
priority
|
||||
className="rounded-lg"
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src={getImage(blogg?.heading ?? "", "thumbnail")}
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
aria-hidden
|
||||
priority
|
||||
className="rounded-lg"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<NextLink href={`/${blogg.slug}`} passHref legacyBehavior>
|
||||
<Link className="text-deepblue-500 no-underline hover:underline">
|
||||
<Heading size="small" level="2">
|
||||
{blogg.heading}
|
||||
</Heading>
|
||||
</Link>
|
||||
</NextLink>
|
||||
<BodyLong className="mt-4" size="small">
|
||||
{blogg?.ingress}
|
||||
</BodyLong>
|
||||
{getAuthors(blogg).length > 0 ? (
|
||||
<BodyShort
|
||||
size="small"
|
||||
className="text-text-subtle mt-4 flex gap-2"
|
||||
>
|
||||
<span className="font-semibold">{getAuthors(blogg)[0]}</span>
|
||||
<span className="animate-fadeIn">{date}</span>
|
||||
</BodyShort>
|
||||
) : (
|
||||
<BodyShort size="small" className="text-text-subtle mt-4">
|
||||
<span className="animate-fadeIn">{date}</span>
|
||||
</BodyShort>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="md:hidden">
|
||||
<div className="relative mb-6 block aspect-video">
|
||||
{blogg?.seo?.image ? (
|
||||
<Image
|
||||
src={urlFor(blogg.seo.image).auto("format").url()}
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
aria-hidden
|
||||
priority
|
||||
className="rounded-lg"
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src={getImage(blogg?.heading ?? "", "thumbnail")}
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
aria-hidden
|
||||
priority
|
||||
className="rounded-lg"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="md:hidden">
|
||||
<NextLink href={`/${blogg.slug}`} passHref legacyBehavior>
|
||||
<Link className="text-deepblue-500 no-underline hover:underline">
|
||||
<Heading size="small" level="2">
|
||||
{blogg.heading}
|
||||
</Heading>
|
||||
</Link>
|
||||
</NextLink>
|
||||
<BodyLong className="mt-4" size="small">
|
||||
{blogg?.ingress}
|
||||
</BodyLong>
|
||||
{getAuthors(blogg).length > 0 ? (
|
||||
<BodyShort
|
||||
size="small"
|
||||
className="text-text-subtle mt-4 flex gap-2"
|
||||
>
|
||||
<span className="font-semibold">{getAuthors(blogg)[0]}</span>
|
||||
<span>{date}</span>
|
||||
</BodyShort>
|
||||
) : (
|
||||
<BodyShort size="small" className="text-text-subtle mt-4">
|
||||
<span>{date}</span>
|
||||
</BodyShort>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
import { urlFor } from "@/sanity/interface";
|
||||
import { AkselBloggDocT, ResolveContributorsT, ResolveSlugT } from "@/types";
|
||||
import { getAuthors } from "@/utils";
|
||||
import { BodyLong, BodyShort, Heading, Link } from "@navikt/ds-react";
|
||||
import { getImage } from "components/website-modules/utils/get-image";
|
||||
import { useFormatedDate } from "components/website-modules/utils/getDate";
|
||||
import Image from "next/legacy/image";
|
||||
import NextLink from "next/link";
|
||||
|
||||
export const HighlightedBlogg = ({
|
||||
blogg,
|
||||
}: {
|
||||
blogg: ResolveContributorsT<ResolveSlugT<AkselBloggDocT>>;
|
||||
}) => {
|
||||
const date = useFormatedDate(blogg?.publishedAt ?? blogg._createdAt);
|
||||
return (
|
||||
<div>
|
||||
<div className="col-span-1 hidden md:block">
|
||||
<div className="relative mb-10 block aspect-video">
|
||||
{blogg?.seo?.image ? (
|
||||
<Image
|
||||
src={urlFor(blogg.seo.image).quality(100).auto("format").url()}
|
||||
quality={100}
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
aria-hidden
|
||||
priority
|
||||
className="rounded-lg"
|
||||
decoding="sync"
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src={getImage(blogg?.heading ?? "", "thumbnail")}
|
||||
layout="fill"
|
||||
objectFit="contain"
|
||||
aria-hidden
|
||||
priority
|
||||
className="rounded-lg"
|
||||
decoding="sync"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<NextLink href={`/${blogg.slug}`} passHref legacyBehavior>
|
||||
<Link className="text-deepblue-500 no-underline hover:underline">
|
||||
<Heading size="large" level="2">
|
||||
{blogg.heading}
|
||||
</Heading>
|
||||
</Link>
|
||||
</NextLink>
|
||||
<BodyLong className="mt-4" size="small">
|
||||
{blogg?.ingress}
|
||||
</BodyLong>
|
||||
{getAuthors(blogg).length > 0 && (
|
||||
<BodyShort size="small" className="text-text-subtle mt-4 flex gap-2">
|
||||
<span className="font-semibold">{getAuthors(blogg)[0]}</span>
|
||||
<span>{date}</span>
|
||||
</BodyShort>
|
||||
)}
|
||||
</div>
|
||||
{/* Mobile view */}
|
||||
<div className="w-full md:hidden">
|
||||
<div className="relative mb-10 block aspect-video">
|
||||
{blogg?.seo?.image ? (
|
||||
<Image
|
||||
src={urlFor(blogg.seo.image).quality(100).auto("format").url()}
|
||||
quality={100}
|
||||
layout="fill"
|
||||
objectFit="cover"
|
||||
aria-hidden
|
||||
priority
|
||||
className="rounded-lg"
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src={getImage(blogg?.heading ?? "", "thumbnail")}
|
||||
layout="fill"
|
||||
objectFit="contain"
|
||||
aria-hidden
|
||||
priority
|
||||
className="rounded-lg"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<NextLink href={`/${blogg.slug}`} passHref legacyBehavior>
|
||||
<Link className="text-deepblue-500 no-underline hover:underline">
|
||||
<Heading size="large" level="2">
|
||||
{blogg.heading}
|
||||
</Heading>
|
||||
</Link>
|
||||
</NextLink>
|
||||
<BodyLong className="mt-4" size="small">
|
||||
{blogg?.ingress}
|
||||
</BodyLong>
|
||||
{getAuthors(blogg).length > 0 && (
|
||||
<BodyShort size="small" className="text-text-subtle mt-4 flex gap-2">
|
||||
<span className="font-semibold">{getAuthors(blogg)[0]}</span>
|
||||
<span>{date}</span>
|
||||
</BodyShort>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import { SanityBlockContent } from "@/sanity-block";
|
||||
import { AkselBloggDocT, ResolveContributorsT, ResolveSlugT } from "@/types";
|
||||
import { Heading } from "@navikt/ds-react";
|
||||
import { BloggList } from "./BloggList";
|
||||
import { HighlightedBlogg } from "./HighlightedBlogg";
|
||||
|
||||
export const LatestBloggposts = ({
|
||||
bloggs,
|
||||
title,
|
||||
intro,
|
||||
}: {
|
||||
bloggs: ResolveContributorsT<ResolveSlugT<AkselBloggDocT>>[];
|
||||
title: string;
|
||||
intro?: any[];
|
||||
}) => {
|
||||
if (!bloggs || bloggs.length < 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-20">
|
||||
<Heading
|
||||
level="1"
|
||||
size="xlarge"
|
||||
className="text-deepblue-700 mx-auto w-full text-5xl md:mx-0 md:max-w-none"
|
||||
>
|
||||
{title}
|
||||
</Heading>
|
||||
{intro && (
|
||||
<SanityBlockContent
|
||||
blocks={intro}
|
||||
isIngress
|
||||
noLastMargin
|
||||
className="mt-4"
|
||||
/>
|
||||
)}
|
||||
{/* Desktop-view */}
|
||||
<div className="mx-auto my-10 grid gap-12 md:my-12 md:grid-cols-2">
|
||||
<HighlightedBlogg blogg={bloggs[0]} />
|
||||
<div className="col-span-1 grid place-content-start gap-12">
|
||||
{bloggs.slice(1, 3).map((blogg) => (
|
||||
<BloggList blogg={blogg} key={blogg._id} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -7,12 +7,13 @@ export const withDsExample = (
|
||||
/**
|
||||
* Static: Used for dynamic-height examples like ExpansionCard
|
||||
*/
|
||||
variant?: "full" | "inverted" | "static"
|
||||
variant?: "full" | "inverted" | "static" | "subtle"
|
||||
) => {
|
||||
const DsHOC = (props: any) => (
|
||||
<div
|
||||
className={cl({
|
||||
"bg-gray-900": variant === "inverted",
|
||||
"bg-bg-subtle": variant === "subtle",
|
||||
[styles.containerStatic]: variant === "static",
|
||||
[styles.container]: variant !== "static",
|
||||
})}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMedia } from "@/utils";
|
||||
import * as Icons from "@navikt/aksel-icons";
|
||||
import meta from "@navikt/aksel-icons/metadata";
|
||||
import { Heading, Modal, Search, ToggleGroup } from "@navikt/ds-react";
|
||||
import cl from "classnames";
|
||||
import cl from "clsx";
|
||||
import Footer from "components/layout/footer/Footer";
|
||||
import { Header } from "components/layout/header/Header";
|
||||
import { SuggestionBlock } from "components/website-modules/suggestionblock";
|
||||
@@ -126,7 +126,7 @@ export const IconPage = ({ name }: { name: string }) => {
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
className="flex h-fit w-full flex-wrap-reverse items-center gap-4 px-4 py-2"
|
||||
>
|
||||
<div className="min-w-40">
|
||||
<div className="min-w-[10.25rem]">
|
||||
<ToggleGroup
|
||||
value={toggle}
|
||||
onChange={(v) => setToggle(v as any)}
|
||||
@@ -137,7 +137,7 @@ export const IconPage = ({ name }: { name: string }) => {
|
||||
<ToggleGroup.Item value="fill">Fill</ToggleGroup.Item>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
<div className=" min-w-48 flex w-full items-center gap-2 md:w-fit">
|
||||
<div className="min-w-48 md:min-w-96 flex w-full items-center gap-2 md:w-fit">
|
||||
<Search
|
||||
variant="simple"
|
||||
label="Ikonsøk"
|
||||
@@ -209,9 +209,9 @@ export const IconPage = ({ name }: { name: string }) => {
|
||||
}
|
||||
}}
|
||||
className={cl(
|
||||
"hover:bg-surface-hover bg-surface-default active:bg-surface-neutral-subtle-hover group relative grid aspect-square w-11 shrink-0 place-items-center rounded focus:outline-none focus:ring-2 focus:ring-blue-800",
|
||||
"hover:bg-surface-hover bg-surface-default active:bg-surface-neutral-subtle-hover group relative grid aspect-square w-11 shrink-0 place-items-center rounded focus:outline-none focus:ring-blue-800 focus-visible:ring-2",
|
||||
{
|
||||
"bg-surface-selected ring-border-alt-3 z-10 ring-1":
|
||||
"from-surface-selected bg-surface-selected ring-border-alt-3 z-10 bg-gradient-to-br to-teal-50 ring-1":
|
||||
i.id === name,
|
||||
}
|
||||
)}
|
||||
|
||||
@@ -184,7 +184,7 @@ export const IconSidebar = ({
|
||||
theme={undefined}
|
||||
>
|
||||
{({ tokens, getLineProps, getTokenProps }) => (
|
||||
<pre className="relative m-0 max-w-[80vw] overflow-x-auto overflow-y-auto rounded-b-lg p-3 font-mono lg:max-w-[20rem]">
|
||||
<pre className="relative m-0 max-w-[80vw] overflow-x-auto overflow-y-auto rounded-b-lg p-3 font-mono lg:max-w-sm">
|
||||
{tokens.map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
@@ -217,7 +217,7 @@ export const IconSidebar = ({
|
||||
theme={undefined}
|
||||
>
|
||||
{({ tokens, getLineProps, getTokenProps }) => (
|
||||
<pre className="w-text relative m-0 max-w-[80vw] overflow-x-auto overflow-y-auto rounded-b-lg p-3 font-mono lg:w-auto lg:max-w-[20rem]">
|
||||
<pre className="w-text relative m-0 max-w-[80vw] overflow-x-auto overflow-y-auto rounded-b-lg p-3 font-mono lg:w-auto lg:max-w-sm">
|
||||
{tokens.map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { LightBulbIcon } from "@navikt/aksel-icons";
|
||||
import { BodyLong, BodyShort, Button, Heading } from "@navikt/ds-react";
|
||||
import cl from "classnames";
|
||||
import cl from "clsx";
|
||||
import { ChangeLogIconOutline } from "components/assets";
|
||||
|
||||
const SuggestionBlockComponent = ({
|
||||
|
||||
@@ -21,12 +21,12 @@ describe("Utils test", () => {
|
||||
expect(capitalize(str4)).toEqual("Teststr");
|
||||
});
|
||||
|
||||
test("dateStr", () => {
|
||||
test("dateStr", async () => {
|
||||
const date = "2022-06-09T11:05:48Z";
|
||||
const date2 = "2021-03-02T12:05:48Z";
|
||||
|
||||
expect(dateStr(date)).toEqual("9. juni 2022");
|
||||
expect(dateStr(date2)).toEqual("2. mars 2021");
|
||||
expect(await dateStr(date)).toEqual("9. juni 2022");
|
||||
expect(await dateStr(date2)).toEqual("2. mars 2021");
|
||||
});
|
||||
|
||||
test("abbrName", () => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { SearchLogT } from "@/types";
|
||||
import amplitude from "amplitude-js";
|
||||
import getConfig from "next/config";
|
||||
import { Router } from "next/router";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { track, init } from "@amplitude/analytics-browser";
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
|
||||
export enum AmplitudeEvents {
|
||||
@@ -20,13 +20,14 @@ export enum AmplitudeEvents {
|
||||
}
|
||||
|
||||
export const initAmplitude = () => {
|
||||
if (amplitude && !(typeof window === "undefined")) {
|
||||
amplitude.getInstance().init("1a9a84a5e557ac9635a250bc27d75030", "", {
|
||||
apiEndpoint: "amplitude.nav.no/collect",
|
||||
saveEvents: false,
|
||||
includeUtm: true,
|
||||
includeReferrer: true,
|
||||
platform: window.location.toString(),
|
||||
if (!(typeof window === "undefined")) {
|
||||
init("1a9a84a5e557ac9635a250bc27d75030", undefined, {
|
||||
useBatch: true,
|
||||
serverUrl: "https://amplitude.nav.no/collect",
|
||||
defaultTracking: {
|
||||
attribution: false,
|
||||
},
|
||||
ingestionMetadata: { sourceName: window.location.toString() },
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -66,8 +67,8 @@ const isProduction = () => {
|
||||
export function logAmplitudeEvent(eventName: string, data?: any): Promise<any> {
|
||||
return new Promise(function (resolve: any) {
|
||||
const eventData = data ? { ...data } : {};
|
||||
if (amplitude && isProduction()) {
|
||||
amplitude.getInstance().logEvent(eventName, eventData, resolve);
|
||||
if (isProduction()) {
|
||||
track(eventName, eventData, resolve);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Loaded 100 of 166 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user