mirror of
https://github.com/navikt/aksel.git
synced 2026-10-28 08:24:05 +00:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
085cc532ee | ||
|
|
deae4ecba5 | ||
|
|
cd2adcb655 | ||
|
|
0d4385a545 | ||
|
|
6f1b9751f3 | ||
|
|
7fb97808f0 | ||
|
|
d8ac958de2 | ||
|
|
b417c74374 | ||
|
|
ff1d3534a6 | ||
|
|
57d015209d | ||
|
|
5969859024 |
No files matched your search
@@ -1,5 +1,7 @@
|
||||
# @navikt/aksel-icons
|
||||
|
||||
## 6.5.0
|
||||
|
||||
## 6.4.1
|
||||
|
||||
## 6.4.0
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"postcss": "^8.4.31",
|
||||
"postcss-url": "^10.1.3",
|
||||
"typescript": "^5.1.6",
|
||||
"vite": "^5.0.12",
|
||||
"vite": "^5.1.7",
|
||||
"vite-plugin-singlefile": "^1.0.0",
|
||||
"vite-plugin-svgr": "^4.2.0"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/aksel-icons",
|
||||
"version": "6.4.1",
|
||||
"version": "6.5.0",
|
||||
"author": "Aksel | NAV",
|
||||
"homepage": "https://aksel.nav.no/ikoner",
|
||||
"repository": {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# @navikt/aksel-stylelint
|
||||
|
||||
## 6.5.0
|
||||
|
||||
## 6.4.1
|
||||
|
||||
## 6.4.0
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/aksel-stylelint",
|
||||
"version": "6.4.1",
|
||||
"version": "6.5.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": "^6.4.1",
|
||||
"@navikt/ds-tokens": "^6.4.1",
|
||||
"@navikt/ds-css": "^6.5.0",
|
||||
"@navikt/ds-tokens": "^6.5.0",
|
||||
"concurrently": "7.2.1",
|
||||
"postcss-selector-parser": "^6.0.13",
|
||||
"postcss-value-parser": "^4.2.0",
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# @navikt/aksel
|
||||
|
||||
## 6.5.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies []:
|
||||
- @navikt/ds-css@6.5.0
|
||||
|
||||
## 6.4.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/aksel",
|
||||
"version": "6.4.1",
|
||||
"version": "6.5.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": "6.4.1",
|
||||
"@navikt/ds-css": "6.5.0",
|
||||
"axios": "1.6.0",
|
||||
"chalk": "4.1.0",
|
||||
"clipboardy": "^2.3.0",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# @navikt/ds-css
|
||||
|
||||
## 6.5.0
|
||||
|
||||
## 6.4.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/ds-css",
|
||||
"version": "6.4.1",
|
||||
"version": "6.5.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": "^6.4.1",
|
||||
"@navikt/ds-tokens": "^6.5.0",
|
||||
"cssnano": "6.0.0",
|
||||
"fast-glob": "3.2.11",
|
||||
"lodash": "4.17.21",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
.navds-tabs__tablist {
|
||||
display: flex;
|
||||
max-width: 100%;
|
||||
width: auto;
|
||||
width: 100%;
|
||||
scroll-behavior: smooth;
|
||||
-ms-overflow-style: none; /* for Internet Explorer, Edge */
|
||||
scrollbar-width: none; /* for Firefox */
|
||||
@@ -83,6 +83,10 @@
|
||||
gap: var(--a-spacing-1);
|
||||
}
|
||||
|
||||
.navds-tabs__tab-inner > * {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.navds-tabs__tab-inner svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -117,6 +121,10 @@
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.navds-tabs__tab--fill {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
.navds-tabs__tabpanel:focus-visible {
|
||||
outline: 2px solid transparent;
|
||||
box-shadow: inset 0 0 0 2px var(--a-border-focus);
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
gap: var(--a-spacing-2);
|
||||
}
|
||||
|
||||
.navds-toggle-group__wrapper--fill {
|
||||
justify-items: initial;
|
||||
}
|
||||
|
||||
.navds-toggle-group {
|
||||
border-radius: var(--a-border-radius-medium);
|
||||
background-color: var(--ac-toggle-group-bg, var(--a-surface-transparent));
|
||||
|
||||
@@ -1,5 +1,29 @@
|
||||
# @navikt/ds-react
|
||||
|
||||
## 6.5.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Tabs: Ny prop `lazy` som rendrer innhold i TabPanel selv når panel er skjult (har fortsatt display:none) ([#2621](https://github.com/navikt/aksel/pull/2621))
|
||||
|
||||
- ToggleGroup: Erstattet bruk av dependency `@radix-ui/react-toggle-group` med egen implementasjon. ([#2620](https://github.com/navikt/aksel/pull/2620))
|
||||
|
||||
- Tabs: La til ny prop `fill` som lar Tabs.Tab-elementer strekke seg over tilgjengelig bredde. ([#2621](https://github.com/navikt/aksel/pull/2621))
|
||||
|
||||
- Tabs: Erstattet bruk av dependency `@radix-ui/react-tabs` med egen implementasjon. ([#2621](https://github.com/navikt/aksel/pull/2621))
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- DatePicker/MonthPicker: Valgte datoer får nå `aria-pressed` for å bedre indikere valg for skjermleser. ([#2838](https://github.com/navikt/aksel/pull/2838))
|
||||
|
||||
- DatePicker/MonthPicker: `required`-prop stoppet ikke de-select av allerede valgt dato. ([#2838](https://github.com/navikt/aksel/pull/2838))
|
||||
|
||||
- ToggleGroup: La til ny prop `fill` som strekker ToggleGroup til å ta opp all tilgjengelig bredde. ([#2620](https://github.com/navikt/aksel/pull/2620))
|
||||
|
||||
- Updated dependencies []:
|
||||
- @navikt/ds-tokens@6.5.0
|
||||
- @navikt/aksel-icons@6.5.0
|
||||
|
||||
## 6.4.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/ds-react",
|
||||
"version": "6.4.1",
|
||||
"version": "6.5.0",
|
||||
"description": "React components from the Norwegian Labour and Welfare Administration.",
|
||||
"author": "Aksel, a team part of the Norwegian Labour and Welfare Administration.",
|
||||
"license": "MIT",
|
||||
@@ -562,10 +562,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@floating-ui/react": "0.25.4",
|
||||
"@navikt/aksel-icons": "^6.4.1",
|
||||
"@navikt/ds-tokens": "^6.4.1",
|
||||
"@radix-ui/react-tabs": "1.0.0",
|
||||
"@radix-ui/react-toggle-group": "1.0.0",
|
||||
"@navikt/aksel-icons": "^6.5.0",
|
||||
"@navikt/ds-tokens": "^6.5.0",
|
||||
"clsx": "^2.1.0",
|
||||
"date-fns": "^3.0.0",
|
||||
"react-day-picker": "8.10.0"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Meta, StoryObj } from "@storybook/react";
|
||||
import { expect, userEvent, within } from "@storybook/test";
|
||||
import { isSameDay } from "date-fns";
|
||||
import React, { useId, useState } from "react";
|
||||
import { act } from "react-dom/test-utils";
|
||||
import { Button } from "../../button";
|
||||
import { HGrid } from "../../layout/grid";
|
||||
import { VStack } from "../../layout/stack";
|
||||
@@ -440,6 +442,43 @@ export const WeekDayClick = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export const Required = {
|
||||
render: () => {
|
||||
const { datepickerProps } = useDatepicker({
|
||||
defaultSelected: new Date("Apr 10 2024"),
|
||||
required: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ height: "20rem" }}>
|
||||
<DatePicker.Standalone {...datepickerProps} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const button10 = canvas.getByRole("button", { pressed: true });
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.click(button10);
|
||||
});
|
||||
|
||||
expect(button10.ariaPressed).toBe("true");
|
||||
|
||||
const button17 = canvas.getByText("17").closest("button");
|
||||
|
||||
expect(button17?.ariaPressed).toBe("false");
|
||||
|
||||
await act(async () => {
|
||||
button17 && (await userEvent.click(button17));
|
||||
});
|
||||
|
||||
expect(button17?.ariaPressed).toBe("true");
|
||||
expect(button10.ariaPressed).toBe("false");
|
||||
},
|
||||
};
|
||||
|
||||
export const ModalDemo = () => {
|
||||
const { datepickerProps, inputProps } = useDatepicker({
|
||||
fromDate: new Date("Aug 23 2019"),
|
||||
|
||||
@@ -23,6 +23,8 @@ const DayButton = (props: DayProps) => {
|
||||
role={undefined}
|
||||
aria-label={dateTime}
|
||||
aria-hidden={dayRender.activeModifiers.outside}
|
||||
aria-selected={undefined}
|
||||
aria-pressed={!!dayRender.activeModifiers.selected}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -229,12 +229,16 @@ export const useDatepicker = (
|
||||
|
||||
/* Only allow de-selecting if not required */
|
||||
const handleDayClick: DayClickEventHandler = (day, { selected }) => {
|
||||
if (selected && required) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (day && !selected) {
|
||||
handleOpen(false);
|
||||
anchorRef?.focus();
|
||||
}
|
||||
|
||||
if (!required && selected) {
|
||||
if (selected) {
|
||||
updateDate(undefined);
|
||||
setInputValue("");
|
||||
updateValidation({ isValidDate: false, isEmpty: true });
|
||||
|
||||
@@ -225,13 +225,17 @@ export const useMonthpicker = (
|
||||
|
||||
/* Only allow de-selecting if not required */
|
||||
const handleMonthClick = (month?: Date) => {
|
||||
if (!month && required) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (month) {
|
||||
handleOpen(false);
|
||||
setYear(month);
|
||||
anchorRef?.focus();
|
||||
}
|
||||
|
||||
if (!required && !month) {
|
||||
if (!month) {
|
||||
updateMonth(undefined);
|
||||
updateValidation({ isValidMonth: false, isEmpty: true });
|
||||
setInputValue("");
|
||||
|
||||
@@ -66,6 +66,7 @@ export const MonthButton = ({
|
||||
type="button"
|
||||
onClick={() => onSelect(isSelected ? undefined : month)}
|
||||
disabled={isDisabled}
|
||||
aria-pressed={!!isSelected}
|
||||
className={cl("navds-date__month-button", {
|
||||
"rdp-day_today": dateIsInCurrentMonth(month, year),
|
||||
"rdp-day_selected": isSelected,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Meta, StoryFn, StoryObj } from "@storybook/react";
|
||||
import { expect, userEvent, within } from "@storybook/test";
|
||||
import { setYear } from "date-fns";
|
||||
import React, { useId, useState } from "react";
|
||||
import { act } from "react-dom/test-utils";
|
||||
import { Button } from "../../button";
|
||||
import { useMonthpicker } from "../hooks";
|
||||
import { DateInputProps } from "../parts/DateInput";
|
||||
@@ -121,25 +123,41 @@ export const UseMonthpickerFormat = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export const Required = () => {
|
||||
const { inputProps, monthpickerProps } = useMonthpicker({
|
||||
locale: "nb",
|
||||
defaultSelected: new Date(),
|
||||
disabled: [new Date("Apr 1 2022")],
|
||||
required: true,
|
||||
});
|
||||
export const Required = {
|
||||
render: () => {
|
||||
const { monthpickerProps } = useMonthpicker({
|
||||
defaultSelected: new Date("Apr 10 2024"),
|
||||
required: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ height: "20rem" }}>
|
||||
<MonthPicker {...monthpickerProps}>
|
||||
<MonthPicker.Input
|
||||
{...inputProps}
|
||||
label="Velg måned"
|
||||
variant="monthpicker"
|
||||
/>
|
||||
</MonthPicker>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div style={{ height: "20rem" }}>
|
||||
<MonthPicker.Standalone {...monthpickerProps} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
const buttonApr = canvas.getByRole("button", { pressed: true });
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.click(buttonApr);
|
||||
});
|
||||
|
||||
expect(buttonApr.ariaPressed).toBe("true");
|
||||
|
||||
const buttonSep = canvas.getByText("september").closest("button");
|
||||
|
||||
expect(buttonSep?.ariaPressed).toBe("false");
|
||||
|
||||
await act(async () => {
|
||||
buttonSep && (await userEvent.click(buttonSep));
|
||||
});
|
||||
|
||||
expect(buttonSep?.ariaPressed).toBe("true");
|
||||
expect(buttonApr.ariaPressed).toBe("false");
|
||||
},
|
||||
};
|
||||
|
||||
export const UserControlled = () => {
|
||||
@@ -193,7 +211,6 @@ export const Chromatic: Story = {
|
||||
<DisabledMonths />
|
||||
<UseMonthpicker />
|
||||
<UseMonthpickerFormat />
|
||||
<Required />
|
||||
<UserControlled />
|
||||
<FollowYear />
|
||||
</div>
|
||||
|
||||
@@ -146,6 +146,7 @@ export const WithUseState: StoryFn = () => {
|
||||
);
|
||||
};
|
||||
WithUseState.storyName = "With useState";
|
||||
WithUseState.parameters = { chromatic: { disable: true } };
|
||||
|
||||
export const EmptyHeader: StoryFn = () => (
|
||||
<Modal open onClose={() => null} aria-label="Modal with empty header">
|
||||
@@ -225,6 +226,7 @@ export const WithTooltip: StoryFn = () => {
|
||||
</div>
|
||||
);
|
||||
};
|
||||
WithTooltip.parameters = { chromatic: { disable: true } }; // The tooltip behaves unpredictably in Chromatic
|
||||
|
||||
export const WithSrOnlyElement: StoryFn = () => (
|
||||
<Modal
|
||||
@@ -330,12 +332,6 @@ ChromaticViewportTesting.parameters = {
|
||||
height: 400,
|
||||
},
|
||||
},
|
||||
desktop: {
|
||||
viewport: {
|
||||
width: 1280,
|
||||
height: 960,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import * as RadixTabs from "@radix-ui/react-tabs";
|
||||
import cl from "clsx";
|
||||
import React, { forwardRef, useContext } from "react";
|
||||
import { BodyShort } from "../typography";
|
||||
import { OverridableComponent } from "../util/types";
|
||||
import { TabsContext } from "./context";
|
||||
|
||||
export interface TabProps
|
||||
extends Omit<React.HTMLAttributes<HTMLButtonElement>, "children"> {
|
||||
/**
|
||||
* Tab label
|
||||
*/
|
||||
label?: React.ReactNode;
|
||||
/**
|
||||
* Tab Icon
|
||||
*/
|
||||
icon?: React.ReactNode;
|
||||
/**
|
||||
* Value for state-handling
|
||||
*/
|
||||
value: string;
|
||||
}
|
||||
|
||||
export const Tab: OverridableComponent<TabProps, HTMLButtonElement> =
|
||||
forwardRef(
|
||||
(
|
||||
{ className, as: Component = "button", label, icon, value, ...rest },
|
||||
ref,
|
||||
) => {
|
||||
const context = useContext(TabsContext);
|
||||
|
||||
if (!label && !icon) {
|
||||
console.error("<Tabs.Tab/> needs label/icon");
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<RadixTabs.Trigger value={value} asChild>
|
||||
<Component
|
||||
ref={ref}
|
||||
className={cl(
|
||||
"navds-tabs__tab",
|
||||
`navds-tabs__tab--${context?.size ?? "medium"}`,
|
||||
`navds-tabs__tab-icon--${context?.iconPosition}`,
|
||||
className,
|
||||
{
|
||||
"navds-tabs__tab--icon-only": icon && !label,
|
||||
},
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
<BodyShort
|
||||
as="span"
|
||||
className="navds-tabs__tab-inner"
|
||||
size={context?.size}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</BodyShort>
|
||||
</Component>
|
||||
</RadixTabs.Trigger>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default Tab;
|
||||
@@ -1,128 +0,0 @@
|
||||
import * as RadixTabs from "@radix-ui/react-tabs";
|
||||
import cl from "clsx";
|
||||
import React, {
|
||||
forwardRef,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "@navikt/aksel-icons";
|
||||
import { debounce } from "../util";
|
||||
import { useMergeRefs } from "../util/hooks/useMergeRefs";
|
||||
import { TabsContext } from "./context";
|
||||
|
||||
export interface TabListProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
* `<Tabs.Tab />` elements
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const TabList = forwardRef<HTMLDivElement, TabListProps>(
|
||||
({ className, ...rest }, ref) => {
|
||||
const context = useContext(TabsContext);
|
||||
const listRef = useRef<HTMLDivElement | null>(null);
|
||||
const mergedRef = useMergeRefs(listRef, ref);
|
||||
|
||||
const [displayScroll, setDisplayScroll] = useState({
|
||||
start: false,
|
||||
end: false,
|
||||
});
|
||||
|
||||
const updateScrollButtonState = useMemo(
|
||||
() =>
|
||||
debounce(() => {
|
||||
if (!listRef?.current) return;
|
||||
const { scrollWidth, clientWidth } = listRef.current;
|
||||
const scrollLeft = listRef.current.scrollLeft;
|
||||
// use 1 for the potential rounding error with browser zooms.
|
||||
const showStartScroll = scrollLeft > 1;
|
||||
const showEndScroll = scrollLeft < scrollWidth - clientWidth - 1;
|
||||
|
||||
setDisplayScroll((oldDisplayScroll) =>
|
||||
showStartScroll === oldDisplayScroll.start &&
|
||||
showEndScroll === oldDisplayScroll.end
|
||||
? oldDisplayScroll
|
||||
: { start: showStartScroll, end: showEndScroll },
|
||||
);
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => updateScrollButtonState();
|
||||
const win = listRef.current?.ownerDocument ?? document ?? window;
|
||||
win.addEventListener("resize", handleResize);
|
||||
|
||||
let resizeObserver;
|
||||
|
||||
if (typeof ResizeObserver !== "undefined") {
|
||||
resizeObserver = new ResizeObserver(handleResize);
|
||||
resizeObserver.observe(listRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
win.removeEventListener("resize", handleResize);
|
||||
if (resizeObserver) {
|
||||
resizeObserver.disconnect();
|
||||
}
|
||||
};
|
||||
}, [updateScrollButtonState]);
|
||||
|
||||
useEffect(() => {
|
||||
updateScrollButtonState();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
updateScrollButtonState.clear();
|
||||
};
|
||||
}, [updateScrollButtonState]);
|
||||
|
||||
const ScrollButton = ({
|
||||
dir,
|
||||
hidden,
|
||||
}: {
|
||||
dir: 1 | -1;
|
||||
hidden: boolean;
|
||||
}) => (
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||
<div
|
||||
className={cl("navds-tabs__scroll-button", {
|
||||
"navds-tabs__scroll-button--hidden": hidden,
|
||||
})}
|
||||
onClick={() => {
|
||||
if (!listRef.current) return;
|
||||
listRef.current.scrollLeft += dir * 100;
|
||||
}}
|
||||
>
|
||||
{dir === -1 ? (
|
||||
<ChevronLeftIcon title="scroll tilbake" />
|
||||
) : (
|
||||
<ChevronRightIcon title="scroll neste" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const showSteppers = displayScroll.end || displayScroll.start;
|
||||
return (
|
||||
<div className="navds-tabs__tablist-wrapper">
|
||||
{showSteppers && (
|
||||
<ScrollButton dir={-1} hidden={!displayScroll.start} />
|
||||
)}
|
||||
<RadixTabs.TabsList
|
||||
{...rest}
|
||||
ref={mergedRef}
|
||||
onScroll={updateScrollButtonState}
|
||||
loop={context?.loop}
|
||||
className={cl("navds-tabs__tablist", className)}
|
||||
/>
|
||||
{showSteppers && <ScrollButton dir={1} hidden={!displayScroll.end} />}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default TabList;
|
||||
@@ -1,26 +0,0 @@
|
||||
import * as RadixTabs from "@radix-ui/react-tabs";
|
||||
import cl from "clsx";
|
||||
import React, { forwardRef } from "react";
|
||||
|
||||
export interface TabPanelProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
* Tab panel content
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* Value for state-handling
|
||||
*/
|
||||
value: string;
|
||||
}
|
||||
|
||||
const TabPanel = forwardRef<HTMLDivElement, TabPanelProps>(
|
||||
({ className, ...rest }, ref) => (
|
||||
<RadixTabs.TabsContent
|
||||
{...rest}
|
||||
ref={ref}
|
||||
className={cl("navds-tabs__tabpanel", className)}
|
||||
/>
|
||||
),
|
||||
);
|
||||
|
||||
export default TabPanel;
|
||||
@@ -0,0 +1,24 @@
|
||||
import { createContext } from "../util/create-context";
|
||||
import { createDescendantContext } from "../util/hooks/descendants/useDescendant";
|
||||
import { TabsProps } from "./Tabs.types";
|
||||
import { useTabs } from "./useTabs";
|
||||
|
||||
export const [
|
||||
TabsDescendantsProvider,
|
||||
useTabsDescendantsContext,
|
||||
useTabsDescendants,
|
||||
useTabsDescendant,
|
||||
] = createDescendantContext<HTMLButtonElement, { value: string }>();
|
||||
|
||||
type TabsProviderProps = ReturnType<typeof useTabs> &
|
||||
Pick<
|
||||
TabsProps,
|
||||
"selectionFollowsFocus" | "loop" | "size" | "iconPosition" | "fill"
|
||||
>;
|
||||
|
||||
/* State context */
|
||||
export const [TabsProvider, useTabsContext] = createContext<TabsProviderProps>({
|
||||
name: "TabsContext",
|
||||
hookName: "useTabsContext",
|
||||
providerName: "TabsProvider",
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Meta } from "@storybook/react";
|
||||
import { Meta, StoryObj } from "@storybook/react";
|
||||
import React, { useState } from "react";
|
||||
import { DishwasherIcon, FreezerIcon, MugIcon } from "@navikt/aksel-icons";
|
||||
import { Tabs } from ".";
|
||||
import { VStack } from "../layout/stack";
|
||||
|
||||
export default {
|
||||
title: "ds-react/Tabs",
|
||||
@@ -20,41 +21,44 @@ export default {
|
||||
},
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
chromatic: { disable: true },
|
||||
},
|
||||
} satisfies Meta<typeof Tabs>;
|
||||
|
||||
const Panel = () => {
|
||||
return (
|
||||
<>
|
||||
<Tabs.Panel
|
||||
value="test1"
|
||||
className="panel"
|
||||
style={{ background: "var(--a-gray-50)", height: 100 }}
|
||||
>
|
||||
Innholdspanel for Skap-tab
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel
|
||||
value="test2"
|
||||
className="panel"
|
||||
style={{
|
||||
background: "var(--a-green-50)",
|
||||
height: 100,
|
||||
}}
|
||||
>
|
||||
Innholdspanel for Oppvaskmaskin-tab
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel
|
||||
value="test3"
|
||||
className="panel"
|
||||
style={{ background: "var(--a-red-50)", height: 100 }}
|
||||
>
|
||||
Innholdspanel for Fryser-tab
|
||||
</Tabs.Panel>
|
||||
<style>{`.panel[data-state="active"] { display:grid; place-content: center;}`}</style>
|
||||
</>
|
||||
);
|
||||
};
|
||||
type Story = StoryObj<typeof Tabs>;
|
||||
|
||||
export const Default = {
|
||||
const Panel = () => (
|
||||
<>
|
||||
<Tabs.Panel
|
||||
value="test1"
|
||||
className="panel"
|
||||
style={{ background: "var(--a-gray-50)", height: 100 }}
|
||||
>
|
||||
Innholdspanel for Skap-tab
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel
|
||||
value="test2"
|
||||
className="panel"
|
||||
style={{
|
||||
background: "var(--a-green-50)",
|
||||
height: 100,
|
||||
}}
|
||||
>
|
||||
Innholdspanel for Oppvaskmaskin-tab
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel
|
||||
value="test3"
|
||||
className="panel"
|
||||
style={{ background: "var(--a-red-50)", height: 100 }}
|
||||
>
|
||||
Innholdspanel for Fryser-tab
|
||||
</Tabs.Panel>
|
||||
<style>{`.panel[data-state="active"] { display:grid; place-content: center;}`}</style>
|
||||
</>
|
||||
);
|
||||
|
||||
export const Default: Story = {
|
||||
render: (props) => {
|
||||
return (
|
||||
<Tabs
|
||||
@@ -65,13 +69,17 @@ export const Default = {
|
||||
iconPosition={props?.iconPosition}
|
||||
>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="test1" icon={<MugIcon />} label="Skap" />
|
||||
<Tabs.Tab value="test1" icon={<MugIcon aria-hidden />} label="Skap" />
|
||||
<Tabs.Tab
|
||||
value="test2"
|
||||
label="Oppvaskmaskin"
|
||||
icon={<DishwasherIcon />}
|
||||
icon={<DishwasherIcon aria-hidden />}
|
||||
/>
|
||||
<Tabs.Tab
|
||||
value="test3"
|
||||
icon={<FreezerIcon aria-hidden />}
|
||||
label="Fryser"
|
||||
/>
|
||||
<Tabs.Tab value="test3" icon={<FreezerIcon />} label="Fryser" />
|
||||
</Tabs.List>
|
||||
<Panel />
|
||||
</Tabs>
|
||||
@@ -84,108 +92,220 @@ export const Default = {
|
||||
},
|
||||
};
|
||||
|
||||
export const Small = () => {
|
||||
return (
|
||||
<Tabs defaultValue="test2" size="small">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="test1" icon={<MugIcon />} label="Skap" />
|
||||
<Tabs.Tab
|
||||
value="test2"
|
||||
label="Oppvaskmaskin"
|
||||
icon={<DishwasherIcon />}
|
||||
/>
|
||||
<Tabs.Tab value="test3" icon={<FreezerIcon />} label="Fryser" />
|
||||
</Tabs.List>
|
||||
<Panel />
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
export const Small = () => (
|
||||
<Tabs defaultValue="test2" size="small">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="test1" icon={<MugIcon aria-hidden />} label="Skap" />
|
||||
<Tabs.Tab
|
||||
value="test2"
|
||||
label="Oppvaskmaskin"
|
||||
icon={<DishwasherIcon aria-hidden />}
|
||||
/>
|
||||
<Tabs.Tab
|
||||
value="test3"
|
||||
icon={<FreezerIcon aria-hidden />}
|
||||
label="Fryser"
|
||||
/>
|
||||
</Tabs.List>
|
||||
<Panel />
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
export const Controlled = () => {
|
||||
const [activeValue, setActiveValue] = useState("test1");
|
||||
return (
|
||||
<Tabs value={activeValue} onChange={setActiveValue}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="test1" icon={<MugIcon />} label="Skap" />
|
||||
<Tabs.Tab value="test1" icon={<MugIcon aria-hidden />} label="Skap" />
|
||||
<Tabs.Tab
|
||||
value="test2"
|
||||
label="Oppvaskmaskin"
|
||||
icon={<DishwasherIcon />}
|
||||
icon={<DishwasherIcon aria-hidden />}
|
||||
/>
|
||||
<Tabs.Tab
|
||||
value="test3"
|
||||
icon={<FreezerIcon aria-hidden />}
|
||||
label="Fryser"
|
||||
/>
|
||||
<Tabs.Tab value="test3" icon={<FreezerIcon />} label="Fryser" />
|
||||
</Tabs.List>
|
||||
<Panel />
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
|
||||
export const IconPosition = () => {
|
||||
return (
|
||||
<div className="colgap">
|
||||
<Tabs defaultValue="test2" size="small">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="test1" icon={<MugIcon />} label="Skap" />
|
||||
<Tabs.Tab
|
||||
value="test2"
|
||||
label="Oppvaskmaskin"
|
||||
icon={<DishwasherIcon />}
|
||||
/>
|
||||
<Tabs.Tab value="test3" icon={<FreezerIcon />} label="Fryser" />
|
||||
</Tabs.List>
|
||||
<Panel />
|
||||
</Tabs>
|
||||
<Tabs defaultValue="test2" size="small" iconPosition="top">
|
||||
<Tabs.List style={{ margin: "0 auto" }}>
|
||||
<Tabs.Tab value="test1" icon={<MugIcon />} label="Skap" />
|
||||
<Tabs.Tab
|
||||
value="test2"
|
||||
label="Oppvaskmaskin"
|
||||
icon={<DishwasherIcon />}
|
||||
/>
|
||||
<Tabs.Tab value="test3" icon={<FreezerIcon />} label="Fryser" />
|
||||
</Tabs.List>
|
||||
<Panel />
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Icon = () => {
|
||||
return (
|
||||
<div className="colgap">
|
||||
<Tabs defaultValue="test2">
|
||||
<Tabs.List style={{ margin: "0 auto" }}>
|
||||
<Tabs.Tab value="test1" icon={<MugIcon />} />
|
||||
<Tabs.Tab value="test2" icon={<DishwasherIcon />} />
|
||||
<Tabs.Tab value="test3" icon={<FreezerIcon />} />
|
||||
</Tabs.List>
|
||||
<Panel />
|
||||
</Tabs>
|
||||
<Tabs defaultValue="test2" size="small" iconPosition="top">
|
||||
<Tabs.List style={{ margin: "0 auto" }}>
|
||||
<Tabs.Tab value="test1" icon={<MugIcon />} />
|
||||
<Tabs.Tab value="test2" icon={<DishwasherIcon />} />
|
||||
<Tabs.Tab value="test3" icon={<FreezerIcon />} />
|
||||
</Tabs.List>
|
||||
<Panel />
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Overflow = () => {
|
||||
return (
|
||||
<Tabs defaultValue="test2" style={{ maxWidth: 300 }}>
|
||||
export const IconPosition = () => (
|
||||
<VStack gap="4">
|
||||
<Tabs defaultValue="test2" size="small">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="test1" icon={<MugIcon />} label="Skap" />
|
||||
<Tabs.Tab value="test1" icon={<MugIcon aria-hidden />} label="Skap" />
|
||||
<Tabs.Tab
|
||||
value="test2"
|
||||
label="Oppvaskmaskin"
|
||||
icon={<DishwasherIcon />}
|
||||
icon={<DishwasherIcon aria-hidden />}
|
||||
/>
|
||||
<Tabs.Tab
|
||||
value="test3"
|
||||
icon={<FreezerIcon aria-hidden />}
|
||||
label="Fryser"
|
||||
/>
|
||||
<Tabs.Tab value="test3" icon={<FreezerIcon />} label="Fryser" />
|
||||
</Tabs.List>
|
||||
<Panel />
|
||||
</Tabs>
|
||||
);
|
||||
<Tabs defaultValue="test2" size="small" iconPosition="top">
|
||||
<Tabs.List style={{ margin: "0 auto" }}>
|
||||
<Tabs.Tab value="test1" icon={<MugIcon aria-hidden />} label="Skap" />
|
||||
<Tabs.Tab
|
||||
value="test2"
|
||||
label="Oppvaskmaskin"
|
||||
icon={<DishwasherIcon aria-hidden />}
|
||||
/>
|
||||
<Tabs.Tab
|
||||
value="test3"
|
||||
icon={<FreezerIcon aria-hidden />}
|
||||
label="Fryser"
|
||||
/>
|
||||
</Tabs.List>
|
||||
<Panel />
|
||||
</Tabs>
|
||||
</VStack>
|
||||
);
|
||||
|
||||
export const Icon = () => (
|
||||
<VStack gap="4">
|
||||
<Tabs defaultValue="test2">
|
||||
<Tabs.List style={{ margin: "0 auto" }}>
|
||||
<Tabs.Tab value="test1" icon={<MugIcon title="Skap" />} />
|
||||
<Tabs.Tab
|
||||
value="test2"
|
||||
icon={<DishwasherIcon title="Oppvaskmaskin" />}
|
||||
/>
|
||||
<Tabs.Tab value="test3" icon={<FreezerIcon title="Fryser" />} />
|
||||
</Tabs.List>
|
||||
<Panel />
|
||||
</Tabs>
|
||||
<Tabs defaultValue="test2" size="small" iconPosition="top">
|
||||
<Tabs.List style={{ margin: "0 auto" }}>
|
||||
<Tabs.Tab value="test1" icon={<MugIcon title="Skap" />} />
|
||||
<Tabs.Tab
|
||||
value="test2"
|
||||
icon={<DishwasherIcon title="Oppvaskmaskin" />}
|
||||
/>
|
||||
<Tabs.Tab value="test3" icon={<FreezerIcon title="Fryser" />} />
|
||||
</Tabs.List>
|
||||
<Panel />
|
||||
</Tabs>
|
||||
</VStack>
|
||||
);
|
||||
|
||||
export const Overflow = () => (
|
||||
<Tabs defaultValue="test2" style={{ maxWidth: 300 }}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="test1" icon={<MugIcon aria-hidden />} label="Skap" />
|
||||
<Tabs.Tab
|
||||
value="test2"
|
||||
label="Oppvaskmaskin"
|
||||
icon={<DishwasherIcon aria-hidden />}
|
||||
/>
|
||||
<Tabs.Tab
|
||||
value="test3"
|
||||
icon={<FreezerIcon aria-hidden />}
|
||||
label="Fryser"
|
||||
/>
|
||||
</Tabs.List>
|
||||
<Panel />
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
export const Fill = () => (
|
||||
<Tabs defaultValue="test2" fill>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="test1" icon={<MugIcon aria-hidden />} label="Skap" />
|
||||
<Tabs.Tab
|
||||
value="test2"
|
||||
icon={<DishwasherIcon aria-hidden />}
|
||||
label="Oppvaskmaskin"
|
||||
/>
|
||||
<Tabs.Tab
|
||||
value="test3"
|
||||
icon={<FreezerIcon aria-hidden />}
|
||||
label="Fryser"
|
||||
/>
|
||||
</Tabs.List>
|
||||
<Panel />
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
Fill.parameters = {
|
||||
layout: "fullscreen",
|
||||
};
|
||||
|
||||
export const CustomIds = () => (
|
||||
<Tabs defaultValue="test2">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab
|
||||
value="test1"
|
||||
label="Skap"
|
||||
id="custom-tabid-1"
|
||||
aria-controls="custom-tabpanelid-1"
|
||||
/>
|
||||
<Tabs.Tab
|
||||
value="test2"
|
||||
label="Oppvaskmaskin"
|
||||
id="custom-tabid-2"
|
||||
aria-controls="custom-tabpanelid-2"
|
||||
/>
|
||||
</Tabs.List>
|
||||
<Tabs.Panel
|
||||
value="test1"
|
||||
className="panel"
|
||||
id="custom-tabpanelid-1"
|
||||
aria-labelledby="custom-tabid-1"
|
||||
>
|
||||
Innholdspanel for Skap-tab
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel
|
||||
value="test2"
|
||||
className="panel"
|
||||
id="custom-tabpanelid-2"
|
||||
aria-labelledby="custom-tabid-2"
|
||||
>
|
||||
Innholdspanel for Oppvaskmaskin-tab
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
export const Chromatic = {
|
||||
render: () => (
|
||||
<VStack gap="6" align="center">
|
||||
<div>
|
||||
<h2>Small</h2>
|
||||
<Small />
|
||||
</div>
|
||||
<div>
|
||||
<h2>Controlled</h2>
|
||||
<Controlled />
|
||||
</div>
|
||||
<div>
|
||||
<h2>IconPosition</h2>
|
||||
<IconPosition />
|
||||
</div>
|
||||
<div>
|
||||
<h2>Icon</h2>
|
||||
<Icon />
|
||||
</div>
|
||||
<div>
|
||||
<h2>Overflow</h2>
|
||||
<Overflow />
|
||||
</div>
|
||||
<div>
|
||||
<h2>Fill</h2>
|
||||
<div style={{ minWidth: 600 }}>
|
||||
<Fill />
|
||||
</div>
|
||||
</div>
|
||||
</VStack>
|
||||
),
|
||||
parameters: {
|
||||
chromatic: { disable: false, delay: 300 },
|
||||
},
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import React from "react";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { Tabs } from "./Tabs";
|
||||
@@ -39,72 +40,133 @@ const TestTabs = ({
|
||||
describe("Tabs", () => {
|
||||
test("sets default value correctly", () => {
|
||||
render(<TestTabs defaultValue="tab2" />);
|
||||
const tab = screen.getByTestId("tab2");
|
||||
const panel = screen.getByTestId("tabpanel2");
|
||||
const tab2 = screen.getByTestId("tab2");
|
||||
const panel2 = screen.getByTestId("tabpanel2");
|
||||
|
||||
expect(tab).toHaveAttribute("aria-selected", "true");
|
||||
expect(panel).toHaveTextContent("Tabpanel 2");
|
||||
expect(tab2).toHaveAttribute("aria-selected", "true");
|
||||
expect(panel2).toHaveTextContent("Tabpanel 2");
|
||||
});
|
||||
|
||||
test("label-connection between tab and tabpanel is correct", async () => {
|
||||
render(<TestTabs defaultValue="tab2" />);
|
||||
const tab = screen.getByTestId("tab2");
|
||||
const panel = screen.getByTestId("tabpanel2");
|
||||
const tab2 = screen.getByTestId("tab2");
|
||||
const panel2 = screen.getByTestId("tabpanel2");
|
||||
|
||||
const controlsId = tab.getAttribute("aria-controls");
|
||||
const panelLabelledBy = panel.getAttribute("aria-labelledby");
|
||||
const controlsId = tab2.getAttribute("aria-controls");
|
||||
const panelLabelledBy = panel2.getAttribute("aria-labelledby");
|
||||
|
||||
expect(controlsId).toEqual(panel.id);
|
||||
expect(tab.id).toEqual(panelLabelledBy);
|
||||
expect(controlsId).toEqual(panel2.id);
|
||||
expect(tab2.id).toEqual(panelLabelledBy);
|
||||
});
|
||||
|
||||
test("sets correct attributes on active tab", () => {
|
||||
render(<TestTabs defaultValue="tab2" />);
|
||||
const tab = screen.getByTestId("tab2");
|
||||
const tab2 = screen.getByTestId("tab2");
|
||||
|
||||
expect(tab).toHaveAttribute("aria-selected", "true");
|
||||
expect(tab).toHaveAttribute("role", "tab");
|
||||
expect(tab).toHaveAttribute("aria-controls");
|
||||
expect(tab).toHaveAttribute("tabindex", "-1");
|
||||
expect(tab2).toHaveAttribute("aria-selected", "true");
|
||||
expect(tab2).toHaveAttribute("role", "tab");
|
||||
expect(tab2).toHaveAttribute("aria-controls");
|
||||
expect(tab2).toHaveAttribute("tabindex", "0");
|
||||
});
|
||||
|
||||
test("sets correct attributes on idle tab", () => {
|
||||
render(<TestTabs defaultValue="tab2" />);
|
||||
const tab = screen.getByTestId("tab1");
|
||||
const tab1 = screen.getByTestId("tab1");
|
||||
|
||||
expect(tab).toHaveAttribute("aria-selected", "false");
|
||||
expect(tab).toHaveAttribute("role", "tab");
|
||||
expect(tab).toHaveAttribute("aria-controls");
|
||||
expect(tab).toHaveAttribute("tabindex", "-1");
|
||||
expect(tab1).toHaveAttribute("aria-selected", "false");
|
||||
expect(tab1).toHaveAttribute("role", "tab");
|
||||
expect(tab1).toHaveAttribute("aria-controls");
|
||||
expect(tab1).toHaveAttribute("tabindex", "-1");
|
||||
});
|
||||
|
||||
test("sets correct attributes on active tabpanel", () => {
|
||||
render(<TestTabs defaultValue="tab2" />);
|
||||
const panel = screen.getByTestId("tabpanel2");
|
||||
const panel2 = screen.getByTestId("tabpanel2");
|
||||
|
||||
expect(panel).toHaveAttribute("aria-labelledby");
|
||||
expect(panel).toHaveAttribute("role", "tabpanel");
|
||||
expect(panel).toHaveAttribute("tabindex", "0");
|
||||
expect(panel).toHaveTextContent("Tabpanel 2");
|
||||
expect(panel).toHaveStyle({ display: "block" });
|
||||
expect(panel2).toHaveAttribute("aria-labelledby");
|
||||
expect(panel2).toHaveAttribute("role", "tabpanel");
|
||||
expect(panel2).toHaveAttribute("tabindex", "0");
|
||||
expect(panel2).toHaveTextContent("Tabpanel 2");
|
||||
expect(panel2).toHaveStyle({ display: "block" });
|
||||
});
|
||||
|
||||
test("sets correct attributes on idle tabpanel", () => {
|
||||
render(<TestTabs defaultValue="tab1" />);
|
||||
const panel = screen.getByTestId("tabpanel2");
|
||||
const panel2 = screen.getByTestId("tabpanel2");
|
||||
|
||||
expect(panel).toHaveAttribute("aria-labelledby");
|
||||
expect(panel).toHaveAttribute("role", "tabpanel");
|
||||
expect(panel).toHaveAttribute("tabindex", "0");
|
||||
expect(panel).toBeEmptyDOMElement();
|
||||
expect(panel).toHaveStyle({ display: "none" });
|
||||
expect(panel2).toHaveAttribute("aria-labelledby");
|
||||
expect(panel2).toHaveAttribute("role", "tabpanel");
|
||||
expect(panel2).toHaveAttribute("tabindex", "0");
|
||||
expect(panel2).toBeEmptyDOMElement();
|
||||
expect(panel2).toHaveStyle({ display: "none" });
|
||||
});
|
||||
|
||||
test("sets tabindex to 0 when focused", () => {
|
||||
render(<TestTabs defaultValue="tab2" />);
|
||||
const tab = screen.getByTestId("tab2");
|
||||
render(<TestTabs defaultValue="tab1" />);
|
||||
const tab2 = screen.getByTestId("tab2");
|
||||
|
||||
fireEvent.focus(tab);
|
||||
expect(tab).toHaveAttribute("tabindex", "0");
|
||||
fireEvent.focus(tab2);
|
||||
expect(tab2).toHaveAttribute("tabindex", "0");
|
||||
});
|
||||
|
||||
test("rowing tabindex keydown moves focus", () => {
|
||||
render(<TestTabs defaultValue="tab1" />);
|
||||
const tab1 = screen.getByTestId("tab1");
|
||||
|
||||
expect(tab1).toHaveAttribute("tabindex", "0");
|
||||
fireEvent.keyDown(tab1, { key: "ArrowRight" });
|
||||
|
||||
expect(tab1).toHaveAttribute("tabindex", "-1");
|
||||
expect(screen.getByTestId("tab2")).toHaveAttribute("tabindex", "0");
|
||||
expect(screen.getByTestId("tab2")).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"false",
|
||||
);
|
||||
});
|
||||
|
||||
test("selectionFollowsFocus moves active tabs", () => {
|
||||
render(<TestTabs defaultValue="tab1" selectionFollowsFocus />);
|
||||
const tab1 = screen.getByTestId("tab1");
|
||||
|
||||
expect(tab1).toHaveAttribute("tabindex", "0");
|
||||
fireEvent.keyDown(tab1, { key: "ArrowRight" });
|
||||
|
||||
expect(screen.getByTestId("tab2")).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.getByTestId("tab2")).toHaveAttribute("tabindex", "0");
|
||||
});
|
||||
|
||||
test("tabbing from tabs moves focus to tabPanel", async () => {
|
||||
render(<TestTabs defaultValue="tab1" />);
|
||||
const tab1 = screen.getByTestId("tab1");
|
||||
|
||||
tab1.focus();
|
||||
await userEvent.tab();
|
||||
|
||||
expect(screen.getByTestId("tabpanel1")).toHaveFocus();
|
||||
});
|
||||
|
||||
test("shift+tab back to tablist should focus selected tab", async () => {
|
||||
render(<TestTabs defaultValue="tab1" />);
|
||||
const tab1 = screen.getByTestId("tab1");
|
||||
|
||||
/* Move focus to tab2 */
|
||||
fireEvent.keyDown(tab1, { key: "ArrowRight" });
|
||||
expect(screen.getByTestId("tab2")).toHaveFocus();
|
||||
|
||||
/* Move focus to tabPanel */
|
||||
// eslint-disable-next-line testing-library/no-unnecessary-act
|
||||
await act(async () => {
|
||||
/* Tablist handles tabbing with setTimeout, so we need to use act */
|
||||
await userEvent.tab();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("tabpanel1")).toHaveFocus();
|
||||
/* Move focus back to tablist, now tab1 should have focus */
|
||||
|
||||
// eslint-disable-next-line testing-library/no-unnecessary-act
|
||||
await act(async () => {
|
||||
await userEvent.tab({ shift: true });
|
||||
});
|
||||
expect(screen.getByTestId("tab1")).toHaveFocus();
|
||||
});
|
||||
});
|
||||
@@ -1,48 +1,15 @@
|
||||
import * as RadixTabs from "@radix-ui/react-tabs";
|
||||
import cl from "clsx";
|
||||
import React, { HTMLAttributes, forwardRef } from "react";
|
||||
import { OverridableComponent } from "../util/types";
|
||||
import Tab, { TabProps } from "./Tab";
|
||||
import TabList, { TabListProps } from "./TabList";
|
||||
import TabPanel, { TabPanelProps } from "./TabPanel";
|
||||
import { TabsContext } from "./context";
|
||||
|
||||
export interface TabsProps
|
||||
extends Omit<HTMLAttributes<HTMLDivElement>, "onChange" | "dir"> {
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* Changes padding and font-size.
|
||||
* @default "medium"
|
||||
*/
|
||||
size?: "medium" | "small";
|
||||
/**
|
||||
* onChange callback for selected Tab.
|
||||
*/
|
||||
onChange?: (value: string) => void;
|
||||
/**
|
||||
* Controlled selected value.
|
||||
*/
|
||||
value?: string;
|
||||
/**
|
||||
* If not controlled, a default-value needs to be set.
|
||||
*/
|
||||
defaultValue?: string;
|
||||
/**
|
||||
* Automatically activates tab on focus/navigation.
|
||||
* @default false
|
||||
*/
|
||||
selectionFollowsFocus?: boolean;
|
||||
/**
|
||||
* Loops back to start when navigating past last item.
|
||||
* @default false
|
||||
*/
|
||||
loop?: boolean;
|
||||
/**
|
||||
* Icon position in Tab.
|
||||
* @default "left"
|
||||
*/
|
||||
iconPosition?: "left" | "top";
|
||||
}
|
||||
import React, { forwardRef } from "react";
|
||||
import {
|
||||
TabsDescendantsProvider,
|
||||
TabsProvider,
|
||||
useTabsDescendants,
|
||||
} from "./Tabs.context";
|
||||
import { TabsProps } from "./Tabs.types";
|
||||
import Tab from "./parts/tab/Tab";
|
||||
import TabList from "./parts/tablist/TabList";
|
||||
import TabPanel from "./parts/tabpanel/TabPanel";
|
||||
import { useTabs } from "./useTabs";
|
||||
|
||||
interface TabsComponent
|
||||
extends React.ForwardRefExoticComponent<
|
||||
@@ -52,19 +19,15 @@ interface TabsComponent
|
||||
* @see 🏷️ {@link TabProps}
|
||||
* @see [🤖 OverridableComponent](https://aksel.nav.no/grunnleggende/kode/overridablecomponent) support
|
||||
*/
|
||||
Tab: OverridableComponent<TabProps, HTMLButtonElement>;
|
||||
Tab: typeof Tab;
|
||||
/**
|
||||
* @see 🏷️ {@link TabListProps}
|
||||
*/
|
||||
List: React.ForwardRefExoticComponent<
|
||||
TabListProps & React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
List: typeof TabList;
|
||||
/**
|
||||
* @see 🏷️ {@link TabPanelProps}
|
||||
*/
|
||||
Panel: React.ForwardRefExoticComponent<
|
||||
TabPanelProps & React.RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
Panel: typeof TabPanel;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -98,33 +61,48 @@ export const Tabs = forwardRef<HTMLDivElement, TabsProps>(
|
||||
{
|
||||
className,
|
||||
children,
|
||||
onChange,
|
||||
size = "medium",
|
||||
defaultValue = "",
|
||||
value,
|
||||
onChange,
|
||||
id,
|
||||
selectionFollowsFocus = false,
|
||||
loop = false,
|
||||
loop = true,
|
||||
iconPosition = "left",
|
||||
fill = false,
|
||||
...rest
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const descendants = useTabsDescendants();
|
||||
|
||||
const tabsContext = useTabs({ defaultValue, value, onChange, id });
|
||||
|
||||
/**
|
||||
* TabsProvider handles memoization of context values, so we can safely skip it here.
|
||||
*/
|
||||
const context = {
|
||||
...tabsContext,
|
||||
selectionFollowsFocus,
|
||||
loop,
|
||||
size,
|
||||
iconPosition,
|
||||
fill,
|
||||
};
|
||||
|
||||
return (
|
||||
<RadixTabs.Root
|
||||
{...rest}
|
||||
ref={ref}
|
||||
className={cl("navds-tabs", className, `navds-tabs--${size}`)}
|
||||
activationMode={selectionFollowsFocus ? "automatic" : "manual"}
|
||||
onValueChange={onChange}
|
||||
>
|
||||
<TabsContext.Provider
|
||||
value={{
|
||||
size,
|
||||
loop,
|
||||
iconPosition,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</TabsContext.Provider>
|
||||
</RadixTabs.Root>
|
||||
<TabsDescendantsProvider value={descendants}>
|
||||
<TabsProvider {...context}>
|
||||
<div
|
||||
ref={ref}
|
||||
{...rest}
|
||||
id={id}
|
||||
className={cl("navds-tabs", className, `navds-tabs--${size}`)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</TabsProvider>
|
||||
</TabsDescendantsProvider>
|
||||
);
|
||||
},
|
||||
) as TabsComponent;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { HTMLAttributes } from "react";
|
||||
|
||||
export interface TabsProps
|
||||
extends Omit<HTMLAttributes<HTMLDivElement>, "onChange" | "dir"> {
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* Changes padding and font-size.
|
||||
* @default "medium"
|
||||
*/
|
||||
size?: "medium" | "small";
|
||||
/**
|
||||
* onChange callback for selected Tab.
|
||||
*/
|
||||
onChange?: (value: string) => void;
|
||||
/**
|
||||
* Controlled selected value.
|
||||
*/
|
||||
value?: string;
|
||||
/**
|
||||
* If not controlled, a default-value needs to be set.
|
||||
*/
|
||||
defaultValue?: string;
|
||||
/**
|
||||
* Automatically activates tab on focus/navigation.
|
||||
* @default false
|
||||
*/
|
||||
selectionFollowsFocus?: boolean;
|
||||
/**
|
||||
* Loops back to start when navigating past last item.
|
||||
* @default true
|
||||
*/
|
||||
loop?: boolean;
|
||||
/**
|
||||
* Icon position in Tab.
|
||||
* @default "left"
|
||||
*/
|
||||
iconPosition?: "left" | "top";
|
||||
/**
|
||||
* Stretches each tab to fill avaliable space in container.
|
||||
* @default false
|
||||
*/
|
||||
fill?: boolean;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { createContext } from "react";
|
||||
|
||||
interface TabsContextProps {
|
||||
size: "medium" | "small";
|
||||
loop: boolean;
|
||||
iconPosition: "left" | "top";
|
||||
}
|
||||
|
||||
export const TabsContext = createContext<TabsContextProps | null>(null);
|
||||
@@ -1,5 +1,12 @@
|
||||
"use client";
|
||||
export { default as TabsTab, type TabProps } from "./Tab";
|
||||
export { default as TabsList, type TabListProps } from "./TabList";
|
||||
export { default as TabsPanel, type TabPanelProps } from "./TabPanel";
|
||||
export { default as Tabs, type TabsProps } from "./Tabs";
|
||||
export { default as Tabs } from "./Tabs";
|
||||
export { type TabsProps } from "./Tabs.types";
|
||||
export { default as TabsTab, type TabProps } from "./parts/tab/Tab";
|
||||
export {
|
||||
default as TabsList,
|
||||
type TabListProps,
|
||||
} from "./parts/tablist/TabList";
|
||||
export {
|
||||
default as TabsPanel,
|
||||
type TabPanelProps,
|
||||
} from "./parts/tabpanel/TabPanel";
|
||||
@@ -0,0 +1,93 @@
|
||||
import cl from "clsx";
|
||||
import React, { forwardRef } from "react";
|
||||
import { BodyShort } from "../../../typography";
|
||||
import { OverridableComponent } from "../../../util";
|
||||
import { useTabsContext } from "../../Tabs.context";
|
||||
import { useTab } from "./useTab";
|
||||
|
||||
export interface TabProps
|
||||
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "children"> {
|
||||
/**
|
||||
* Tab label.
|
||||
*/
|
||||
label?: React.ReactNode;
|
||||
/**
|
||||
* Tab Icon.
|
||||
*/
|
||||
icon?: React.ReactNode;
|
||||
/**
|
||||
* Value for state-handling.
|
||||
*/
|
||||
value: string;
|
||||
/**
|
||||
* Overrides auto-generated id.
|
||||
*
|
||||
* **Warning**: Tab generates an id if not provided. If you need to override it,
|
||||
* make sure to also include the correct `aria-controls` id for the TabPanel it controls.
|
||||
*/
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export const Tab: OverridableComponent<TabProps, HTMLButtonElement> =
|
||||
forwardRef(
|
||||
(
|
||||
{
|
||||
className,
|
||||
as: Component = "button",
|
||||
label,
|
||||
icon,
|
||||
value,
|
||||
onClick,
|
||||
onFocus,
|
||||
disabled,
|
||||
id,
|
||||
...rest
|
||||
},
|
||||
ref: React.ForwardedRef<HTMLButtonElement>,
|
||||
) => {
|
||||
const tabCtx = useTab({ value, onClick, onFocus, disabled }, ref);
|
||||
const ctx = useTabsContext();
|
||||
|
||||
if (!label && !icon) {
|
||||
console.error("<Tabs.Tab/> needs label and/or icon");
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Component
|
||||
ref={tabCtx.ref}
|
||||
{...rest}
|
||||
className={cl(
|
||||
"navds-tabs__tab",
|
||||
`navds-tabs__tab--${ctx?.size ?? "medium"}`,
|
||||
`navds-tabs__tab-icon--${ctx?.iconPosition}`,
|
||||
className,
|
||||
{
|
||||
"navds-tabs__tab--icon-only": icon && !label,
|
||||
"navds-tabs__tab--fill": ctx.fill,
|
||||
},
|
||||
)}
|
||||
role="tab"
|
||||
type="button"
|
||||
aria-selected={tabCtx.isSelected}
|
||||
data-state={tabCtx.isSelected ? "active" : "inactive"}
|
||||
tabIndex={tabCtx.isFocused ? 0 : -1}
|
||||
aria-controls={rest["aria-controls"] ?? tabCtx.controlsId}
|
||||
id={id ?? tabCtx.id}
|
||||
onFocus={tabCtx.onFocus}
|
||||
onClick={tabCtx.onClick}
|
||||
>
|
||||
<BodyShort
|
||||
as="span"
|
||||
className="navds-tabs__tab-inner"
|
||||
size={ctx?.size}
|
||||
>
|
||||
<span aria-hidden={!!label}>{icon}</span>
|
||||
<span>{label}</span>
|
||||
</BodyShort>
|
||||
</Component>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default Tab;
|
||||
@@ -0,0 +1,52 @@
|
||||
import { composeEventHandlers } from "../../../util/composeEventHandlers";
|
||||
import { mergeRefs } from "../../../util/hooks/useMergeRefs";
|
||||
import { useTabsContext, useTabsDescendant } from "../../Tabs.context";
|
||||
|
||||
export interface UseTabProps {
|
||||
/**
|
||||
* If `true`, the `Tab` won't be toggleable
|
||||
* @default false
|
||||
*/
|
||||
disabled?: boolean;
|
||||
onClick?: React.MouseEventHandler;
|
||||
onFocus?: React.FocusEventHandler;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export function useTab<P extends UseTabProps>(
|
||||
{ value, disabled = false, onFocus: _onFocus, onClick }: P,
|
||||
ref: React.ForwardedRef<HTMLButtonElement>,
|
||||
) {
|
||||
const {
|
||||
id,
|
||||
setSelectedValue,
|
||||
selectionFollowsFocus,
|
||||
focusedValue,
|
||||
setFocusedValue,
|
||||
selectedValue,
|
||||
makeTabId,
|
||||
makeTabPanelId,
|
||||
} = useTabsContext();
|
||||
|
||||
const { register } = useTabsDescendant({
|
||||
disabled,
|
||||
value,
|
||||
});
|
||||
|
||||
const isSelected = value === selectedValue;
|
||||
|
||||
const onFocus = () => {
|
||||
setFocusedValue(value);
|
||||
selectionFollowsFocus && setSelectedValue(value);
|
||||
};
|
||||
|
||||
return {
|
||||
ref: mergeRefs([register, ref]),
|
||||
isSelected,
|
||||
isFocused: focusedValue === value,
|
||||
id: makeTabId(id, value),
|
||||
controlsId: makeTabPanelId(id, value),
|
||||
onClick: composeEventHandlers(onClick, () => setSelectedValue(value)),
|
||||
onFocus: disabled ? undefined : composeEventHandlers(_onFocus, onFocus),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import cl from "clsx";
|
||||
import React from "react";
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "@navikt/aksel-icons";
|
||||
|
||||
interface ScrollButtonProps {
|
||||
hidden: boolean;
|
||||
onClick: () => void;
|
||||
dir: "left" | "right";
|
||||
}
|
||||
|
||||
function ScrollButton({ hidden, onClick, dir }: ScrollButtonProps) {
|
||||
return (
|
||||
<div
|
||||
className={cl("navds-tabs__scroll-button", {
|
||||
"navds-tabs__scroll-button--hidden": hidden,
|
||||
})}
|
||||
onClick={onClick}
|
||||
aria-hidden
|
||||
>
|
||||
{dir === "left" ? (
|
||||
<ChevronLeftIcon title="scroll tilbake" />
|
||||
) : (
|
||||
<ChevronRightIcon title="scroll neste" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ScrollButton;
|
||||
@@ -0,0 +1,56 @@
|
||||
/* eslint-disable jsx-a11y/interactive-supports-focus */
|
||||
import cl from "clsx";
|
||||
import React, { forwardRef, useRef } from "react";
|
||||
import { composeEventHandlers } from "../../../util/composeEventHandlers";
|
||||
import { useMergeRefs } from "../../../util/hooks/useMergeRefs";
|
||||
import ScrollButton from "./ScrollButtons";
|
||||
import { useScrollButtons } from "./useScrollButtons";
|
||||
import { useTabList } from "./useTabList";
|
||||
|
||||
export interface TabListProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
* <Tabs.Tab /> elements.
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const TabList = forwardRef<HTMLDivElement, TabListProps>(
|
||||
({ className, onKeyDown, ...rest }, ref) => {
|
||||
const { onKeyDown: _onKeyDown } = useTabList();
|
||||
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const mergedRef = useMergeRefs(listRef, ref);
|
||||
|
||||
const scrollCtx = useScrollButtons(listRef);
|
||||
|
||||
return (
|
||||
<div className="navds-tabs__tablist-wrapper">
|
||||
{scrollCtx.show && (
|
||||
<ScrollButton
|
||||
dir="left"
|
||||
hidden={!scrollCtx.start}
|
||||
onClick={scrollCtx.scrollLeft}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
ref={mergedRef}
|
||||
{...rest}
|
||||
onScroll={scrollCtx.update}
|
||||
className={cl("navds-tabs__tablist", className)}
|
||||
role="tablist"
|
||||
aria-orientation="horizontal"
|
||||
onKeyDown={composeEventHandlers(onKeyDown, _onKeyDown)}
|
||||
/>
|
||||
{scrollCtx.show && (
|
||||
<ScrollButton
|
||||
dir="right"
|
||||
hidden={!scrollCtx.end}
|
||||
onClick={scrollCtx.scrollRight}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default TabList;
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { debounce } from "../../../util";
|
||||
|
||||
export function useScrollButtons(listRef: React.RefObject<HTMLDivElement>) {
|
||||
const [displayScroll, setDisplayScroll] = useState({
|
||||
start: false,
|
||||
end: false,
|
||||
});
|
||||
|
||||
const updateScrollButtonState = useMemo(
|
||||
() =>
|
||||
debounce(() => {
|
||||
if (!listRef?.current) return;
|
||||
const { scrollWidth, clientWidth } = listRef.current;
|
||||
const scrollLeft = listRef.current.scrollLeft;
|
||||
// use 1 for the potential rounding error with browser zooms.
|
||||
const showStartScroll = scrollLeft > 1;
|
||||
const showEndScroll = scrollLeft < scrollWidth - clientWidth - 1;
|
||||
|
||||
setDisplayScroll((oldDisplayScroll) =>
|
||||
showStartScroll === oldDisplayScroll.start &&
|
||||
showEndScroll === oldDisplayScroll.end
|
||||
? oldDisplayScroll
|
||||
: { start: showStartScroll, end: showEndScroll },
|
||||
);
|
||||
}),
|
||||
[listRef],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => updateScrollButtonState();
|
||||
const win = listRef.current?.ownerDocument ?? document ?? window;
|
||||
win.addEventListener("resize", handleResize);
|
||||
|
||||
let resizeObserver;
|
||||
|
||||
if (typeof ResizeObserver !== "undefined") {
|
||||
resizeObserver = new ResizeObserver(handleResize);
|
||||
resizeObserver.observe(listRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
win.removeEventListener("resize", handleResize);
|
||||
resizeObserver && resizeObserver.disconnect();
|
||||
updateScrollButtonState.clear();
|
||||
};
|
||||
}, [listRef, updateScrollButtonState]);
|
||||
|
||||
useEffect(() => {
|
||||
updateScrollButtonState();
|
||||
});
|
||||
|
||||
return {
|
||||
update: updateScrollButtonState,
|
||||
start: displayScroll.start,
|
||||
end: displayScroll.end,
|
||||
show: displayScroll.end || displayScroll.start,
|
||||
scrollLeft: () => {
|
||||
if (listRef.current) {
|
||||
listRef.current.scrollLeft -= 100;
|
||||
}
|
||||
},
|
||||
scrollRight: () => {
|
||||
if (listRef.current) {
|
||||
listRef.current.scrollLeft += 100;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useCallback } from "react";
|
||||
import { useTabsContext, useTabsDescendantsContext } from "../../Tabs.context";
|
||||
|
||||
/**
|
||||
* TabList hook to manage multiple tab buttons,
|
||||
* and ensures only one tab is selected at a time.
|
||||
*/
|
||||
export function useTabList() {
|
||||
const { focusedValue, loop, selectedValue, setFocusedValue } =
|
||||
useTabsContext();
|
||||
|
||||
const descendants = useTabsDescendantsContext();
|
||||
|
||||
/**
|
||||
* Implements rowing-tabindex for horizontal tabs
|
||||
*/
|
||||
const onKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent) => {
|
||||
/**
|
||||
* Tabs.Tab is registered with its prop 'value'.
|
||||
* We can then use it to find the current focuses descendant
|
||||
*/
|
||||
const idx = descendants
|
||||
.values()
|
||||
.findIndex((x) => x.value === focusedValue);
|
||||
|
||||
const nextTab = () => {
|
||||
const next = descendants.nextEnabled(idx, loop);
|
||||
next && next.node?.focus();
|
||||
};
|
||||
const prevTab = () => {
|
||||
const prev = descendants.prevEnabled(idx, loop);
|
||||
prev && prev.node?.focus();
|
||||
};
|
||||
const firstTab = () => {
|
||||
const first = descendants.firstEnabled();
|
||||
first && first.node?.focus();
|
||||
};
|
||||
const lastTab = () => {
|
||||
const last = descendants.lastEnabled();
|
||||
last && last.node?.focus();
|
||||
};
|
||||
|
||||
const keyMap: Record<string, React.KeyboardEventHandler> = {
|
||||
ArrowLeft: prevTab,
|
||||
ArrowRight: nextTab,
|
||||
Home: firstTab,
|
||||
End: lastTab,
|
||||
};
|
||||
|
||||
const action = keyMap[event.key];
|
||||
|
||||
if (action) {
|
||||
event.preventDefault();
|
||||
action(event);
|
||||
} else if (event.key === "Tab") {
|
||||
/**
|
||||
* Imperative focus during keydown is risky so we prevent React's batching updates
|
||||
* to avoid potential bugs. See: https://github.com/facebook/react/issues/20332
|
||||
*/
|
||||
selectedValue && setTimeout(() => setFocusedValue(selectedValue));
|
||||
}
|
||||
},
|
||||
[descendants, focusedValue, loop, selectedValue, setFocusedValue],
|
||||
);
|
||||
|
||||
return { onKeyDown };
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import cl from "clsx";
|
||||
import React, { forwardRef } from "react";
|
||||
import { useTabPanel } from "./useTabPanel";
|
||||
|
||||
export interface TabPanelProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
* Tab panel content.
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* Value for state-handling.
|
||||
*/
|
||||
value: string;
|
||||
/**
|
||||
* If true, will only render children when selected.
|
||||
* @default true
|
||||
*/
|
||||
lazy?: boolean;
|
||||
/**
|
||||
* Overrides auto-generated id.
|
||||
*
|
||||
* **Warning**: TabPanel generates an id if not provided. If you need to override it,
|
||||
* make sure to also include the correct `aria-labelledby` id for the Tab that labels it.
|
||||
*/
|
||||
id?: string;
|
||||
}
|
||||
|
||||
const TabPanel = forwardRef<HTMLDivElement, TabPanelProps>(
|
||||
({ className, value, children, lazy = true, id, ...rest }, ref) => {
|
||||
const ctx = useTabPanel({ value });
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
{...rest}
|
||||
className={cl("navds-tabs__tabpanel", className)}
|
||||
role="tabpanel"
|
||||
tabIndex={0}
|
||||
aria-labelledby={rest["aria-labelledby"] ?? ctx.labelledbyId}
|
||||
id={id ?? ctx.id}
|
||||
hidden={ctx.hidden}
|
||||
data-state={!ctx.hidden ? "active" : "inactive"}
|
||||
>
|
||||
{lazy && ctx.hidden ? null : children}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default TabPanel;
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useTabsContext } from "../../Tabs.context";
|
||||
|
||||
type TabPanelProps = {
|
||||
value: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Tabs hook for managing the visible/hidden state of Tabs.Panel
|
||||
*/
|
||||
export function useTabPanel({ value }: TabPanelProps) {
|
||||
const { id, selectedValue, makeTabId, makeTabPanelId } = useTabsContext();
|
||||
|
||||
return {
|
||||
labelledbyId: makeTabId(id, value),
|
||||
hidden: selectedValue !== value,
|
||||
id: makeTabPanelId(id, value),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useId } from "../util";
|
||||
import { useControllableState } from "../util/hooks/useControllableState";
|
||||
import { TabsProps } from "./Tabs.types";
|
||||
|
||||
export function useTabs({
|
||||
onChange,
|
||||
value,
|
||||
defaultValue = "",
|
||||
id,
|
||||
}: Pick<TabsProps, "onChange" | "value" | "defaultValue" | "id">) {
|
||||
const [focusedValue, setFocusedValue] = useState(defaultValue);
|
||||
|
||||
const [selectedValue, setSelectedValue] = useControllableState({
|
||||
defaultValue,
|
||||
value,
|
||||
onChange,
|
||||
});
|
||||
|
||||
/**
|
||||
* Sync focused `value` with controlled `selectedValue`
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (value != null) {
|
||||
setFocusedValue(value);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
/**
|
||||
* Scope ids for better tracking
|
||||
*/
|
||||
const uuid = useId();
|
||||
|
||||
return {
|
||||
id: `tabs-${id ?? uuid}`,
|
||||
selectedValue,
|
||||
setSelectedValue,
|
||||
focusedValue,
|
||||
setFocusedValue,
|
||||
makeTabId,
|
||||
makeTabPanelId,
|
||||
};
|
||||
}
|
||||
|
||||
function makeTabId(id: string, value: string) {
|
||||
return `${id}--tab-${value}`;
|
||||
}
|
||||
|
||||
function makeTabPanelId(id: string, value: string) {
|
||||
return `${id}--tabpanel-${value}`;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { createContext as ReactCreateContext } from "react";
|
||||
import { createContext } from "../util/create-context";
|
||||
import { createDescendantContext } from "../util/hooks/descendants/useDescendant";
|
||||
import { ToggleGroupProps } from "./ToggleGroup.types";
|
||||
import { useToggleGroup } from "./useToggleGroup";
|
||||
|
||||
interface ToggleContextProps {
|
||||
size: "medium" | "small";
|
||||
}
|
||||
|
||||
export const ToggleGroupContext = ReactCreateContext<ToggleContextProps | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
export const [
|
||||
ToggleGroupDescendantsProvider,
|
||||
useToggleGroupDescendantsContext,
|
||||
useToggleGroupDescendants,
|
||||
useToggleGroupDescendant,
|
||||
] = createDescendantContext<HTMLButtonElement, { value: string }>();
|
||||
|
||||
type ToggleGroupProviderProps = ReturnType<typeof useToggleGroup> &
|
||||
Pick<ToggleGroupProps, "size">;
|
||||
|
||||
/* State context */
|
||||
export const [ToggleGroupProvider, useToggleGroupContext] =
|
||||
createContext<ToggleGroupProviderProps>({
|
||||
name: "ToggleGroupContext",
|
||||
hookName: "useToggleGroupContext",
|
||||
providerName: "ToggleGroupProvider",
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
EnvelopeOpenIcon,
|
||||
InboxUpIcon,
|
||||
} from "@navikt/aksel-icons";
|
||||
import { VStack } from "../layout/stack";
|
||||
import ToggleGroup from "./ToggleGroup";
|
||||
|
||||
const meta: Meta<typeof ToggleGroup> = {
|
||||
@@ -22,7 +23,11 @@ const meta: Meta<typeof ToggleGroup> = {
|
||||
control: { type: "radio" },
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
chromatic: { disable: true },
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
const Items = (icon?: boolean, both?: boolean) => (
|
||||
@@ -71,6 +76,7 @@ export const Default = (props) => {
|
||||
</ToggleGroup>
|
||||
);
|
||||
};
|
||||
|
||||
Default.args = {
|
||||
icon: true,
|
||||
text: true,
|
||||
@@ -81,7 +87,7 @@ export const Compositions = () => {
|
||||
const [activeValue, setActiveValue] = useState("ulest");
|
||||
|
||||
return (
|
||||
<div className="colgap">
|
||||
<VStack gap="6">
|
||||
<ToggleGroup value={activeValue} onChange={setActiveValue}>
|
||||
{Items()}
|
||||
</ToggleGroup>
|
||||
@@ -91,7 +97,10 @@ export const Compositions = () => {
|
||||
<ToggleGroup value={activeValue} onChange={setActiveValue}>
|
||||
{Items(true)}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
<ToggleGroup fill value={activeValue} onChange={setActiveValue}>
|
||||
{Items(true)}
|
||||
</ToggleGroup>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -99,7 +108,7 @@ export const Variants = () => {
|
||||
const [activeValue, setActiveValue] = useState("ulest");
|
||||
|
||||
return (
|
||||
<div className="colgap">
|
||||
<VStack gap="6">
|
||||
<ToggleGroup
|
||||
variant="action"
|
||||
value={activeValue}
|
||||
@@ -114,7 +123,7 @@ export const Variants = () => {
|
||||
>
|
||||
{Items(true, true)}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -122,7 +131,7 @@ export const Small = () => {
|
||||
const [activeValue, setActiveValue] = useState("ulest");
|
||||
|
||||
return (
|
||||
<div className="colgap">
|
||||
<VStack gap="6">
|
||||
<ToggleGroup size="small" value={activeValue} onChange={setActiveValue}>
|
||||
{Items()}
|
||||
</ToggleGroup>
|
||||
@@ -132,6 +141,58 @@ export const Small = () => {
|
||||
<ToggleGroup size="small" value={activeValue} onChange={setActiveValue}>
|
||||
{Items(true)}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
export const Chromatic = {
|
||||
render: () => (
|
||||
<VStack gap="6">
|
||||
<div>
|
||||
<h2>Text</h2>
|
||||
<ToggleGroup value="ulest" onChange={console.log}>
|
||||
{Items()}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Icon</h2>
|
||||
<ToggleGroup value="ulest" onChange={console.log}>
|
||||
{Items(true)}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Text + icon</h2>
|
||||
<ToggleGroup value="ulest" onChange={console.log}>
|
||||
{Items(true, true)}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
<div style={{ minWidth: 600 }}>
|
||||
<h2>Fill</h2>
|
||||
<ToggleGroup value="ulest" onChange={console.log} fill>
|
||||
{Items(true, true)}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Small</h2>
|
||||
<ToggleGroup value="ulest" onChange={console.log} size="small">
|
||||
{Items(true, true)}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Small + fill</h2>
|
||||
<ToggleGroup value="ulest" onChange={console.log} size="small" fill>
|
||||
{Items(true, true)}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Neutral</h2>
|
||||
<ToggleGroup value="ulest" onChange={console.log} variant="neutral">
|
||||
{Items(true, true)}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
</VStack>
|
||||
),
|
||||
parameters: {
|
||||
chromatic: { disable: false },
|
||||
},
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import React from "react";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { ToggleGroup } from "./ToggleGroup";
|
||||
@@ -20,36 +21,76 @@ const TestToggleGroup = ({ value, onChange, defaultValue }: any) => (
|
||||
describe("ToggleGroup", () => {
|
||||
test("sets default value correctly", () => {
|
||||
render(<TestToggleGroup defaultValue="toggle2" />);
|
||||
const toggle = screen.getByTestId("toggle2");
|
||||
const toggle2 = screen.getByTestId("toggle2");
|
||||
|
||||
expect(toggle).toHaveAttribute("aria-checked", "true");
|
||||
expect(toggle2).toHaveAttribute("aria-checked", "true");
|
||||
});
|
||||
|
||||
test("sets correct attributes on active toggle", () => {
|
||||
render(<TestToggleGroup defaultValue="toggle2" />);
|
||||
const toggle = screen.getByTestId("toggle2");
|
||||
const toggle2 = screen.getByTestId("toggle2");
|
||||
|
||||
expect(toggle).toHaveAttribute("aria-checked", "true");
|
||||
expect(toggle).toHaveAttribute("role", "radio");
|
||||
expect(toggle).toHaveAttribute("type", "button");
|
||||
expect(toggle).toHaveAttribute("tabindex", "-1");
|
||||
expect(toggle2).toHaveAttribute("aria-checked", "true");
|
||||
expect(toggle2).toHaveAttribute("role", "radio");
|
||||
expect(toggle2).toHaveAttribute("type", "button");
|
||||
expect(toggle2).toHaveAttribute("tabindex", "0");
|
||||
});
|
||||
|
||||
test("sets correct attributes on idle toggle", () => {
|
||||
render(<TestToggleGroup defaultValue="toggle1" />);
|
||||
const toggle = screen.getByTestId("toggle2");
|
||||
const toggle2 = screen.getByTestId("toggle2");
|
||||
|
||||
expect(toggle).toHaveAttribute("aria-checked", "false");
|
||||
expect(toggle).toHaveAttribute("role", "radio");
|
||||
expect(toggle).toHaveAttribute("type", "button");
|
||||
expect(toggle).toHaveAttribute("tabindex", "-1");
|
||||
expect(toggle2).toHaveAttribute("aria-checked", "false");
|
||||
expect(toggle2).toHaveAttribute("role", "radio");
|
||||
expect(toggle2).toHaveAttribute("type", "button");
|
||||
expect(toggle2).toHaveAttribute("tabindex", "-1");
|
||||
});
|
||||
|
||||
test("sets tabindex to 0 when focused", () => {
|
||||
render(<TestToggleGroup defaultValue="toggle2" />);
|
||||
const toggle = screen.getByTestId("toggle2");
|
||||
const toggle2 = screen.getByTestId("toggle2");
|
||||
|
||||
fireEvent.focus(toggle);
|
||||
expect(toggle).toHaveAttribute("tabindex", "0");
|
||||
fireEvent.focus(toggle2);
|
||||
expect(toggle2).toHaveAttribute("tabindex", "0");
|
||||
});
|
||||
|
||||
test("roving tabindex keydown moves focus", () => {
|
||||
render(<TestToggleGroup defaultValue="toggle1" />);
|
||||
const toggle1 = screen.getByTestId("toggle1");
|
||||
|
||||
expect(toggle1).toHaveAttribute("tabindex", "0");
|
||||
fireEvent.keyDown(toggle1, { key: "ArrowRight" });
|
||||
|
||||
expect(toggle1).toHaveAttribute("tabindex", "-1");
|
||||
expect(screen.getByTestId("toggle2")).toHaveAttribute("tabindex", "0");
|
||||
expect(screen.getByTestId("toggle2")).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"false",
|
||||
);
|
||||
});
|
||||
|
||||
test("Space selects focused toggle-item", async () => {
|
||||
render(<TestToggleGroup defaultValue="toggle1" />);
|
||||
const toggle1 = screen.getByTestId("toggle1");
|
||||
|
||||
expect(toggle1).toHaveAttribute("tabindex", "0");
|
||||
fireEvent.keyDown(toggle1, { key: "ArrowRight" });
|
||||
|
||||
expect(toggle1).toHaveAttribute("tabindex", "-1");
|
||||
expect(screen.getByTestId("toggle2")).toHaveAttribute("tabindex", "0");
|
||||
expect(screen.getByTestId("toggle2")).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"false",
|
||||
);
|
||||
|
||||
// eslint-disable-next-line testing-library/no-unnecessary-act
|
||||
await act(async () => {
|
||||
await userEvent.keyboard(" ");
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("toggle2")).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,55 +1,24 @@
|
||||
import * as RadixToggleGroup from "@radix-ui/react-toggle-group";
|
||||
import cl from "clsx";
|
||||
import React, { HTMLAttributes, forwardRef, useState } from "react";
|
||||
import React, { forwardRef } from "react";
|
||||
import { Label } from "../typography";
|
||||
import { useId } from "../util/hooks";
|
||||
import ToggleItem, { ToggleGroupItemProps } from "./ToggleItem";
|
||||
import { ToggleGroupContext } from "./context";
|
||||
|
||||
export interface ToggleGroupProps
|
||||
extends Omit<HTMLAttributes<HTMLDivElement>, "onChange" | "dir"> {
|
||||
/**
|
||||
* Toggles.Item elements
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* Changes padding and font-size
|
||||
* @default "medium"
|
||||
*/
|
||||
size?: "medium" | "small";
|
||||
/**
|
||||
* Controlled selected value
|
||||
*/
|
||||
value?: string;
|
||||
/**
|
||||
* If not controlled, a default-value needs to be set
|
||||
*/
|
||||
defaultValue?: string;
|
||||
/**
|
||||
* Callback for selected toggle
|
||||
*/
|
||||
onChange: (value: string) => void;
|
||||
/**
|
||||
* Label describing ToggleGroup
|
||||
*/
|
||||
label?: React.ReactNode;
|
||||
/**
|
||||
* Changes design and interaction-visuals
|
||||
* @default "action"
|
||||
*/
|
||||
variant?: "action" | "neutral";
|
||||
}
|
||||
import { useId } from "../util";
|
||||
import {
|
||||
ToggleGroupDescendantsProvider,
|
||||
ToggleGroupProvider,
|
||||
useToggleGroupDescendants,
|
||||
} from "./ToggleGroup.context";
|
||||
import { ToggleGroupProps } from "./ToggleGroup.types";
|
||||
import ToggleItem from "./parts/ToggleItem";
|
||||
import { useToggleGroup } from "./useToggleGroup";
|
||||
|
||||
interface ToggleGroupComponent
|
||||
extends React.ForwardRefExoticComponent<
|
||||
ToggleGroupProps & React.RefAttributes<HTMLDivElement>
|
||||
> {
|
||||
/**
|
||||
* @see 🏷️ {@link ToggleGroupItemProps}
|
||||
* @see 🏷️ {@link ToggleItem}
|
||||
*/
|
||||
Item: React.ForwardRefExoticComponent<
|
||||
ToggleGroupItemProps & React.RefAttributes<HTMLButtonElement>
|
||||
>;
|
||||
Item: typeof ToggleItem;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,70 +46,74 @@ export const ToggleGroup = forwardRef<HTMLDivElement, ToggleGroupProps>(
|
||||
label,
|
||||
value,
|
||||
defaultValue,
|
||||
"aria-describedby": desc,
|
||||
"aria-describedby": userDescribedby,
|
||||
variant = "action",
|
||||
fill = false,
|
||||
...rest
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const [groupValue, setGroupValue] = useState(defaultValue);
|
||||
const labelId = useId();
|
||||
const descendants = useToggleGroupDescendants();
|
||||
|
||||
const handleValueChange = (v: string) => {
|
||||
if (v !== "") {
|
||||
setGroupValue(v);
|
||||
onChange?.(v);
|
||||
}
|
||||
const toggleGroupContext = useToggleGroup({
|
||||
defaultValue,
|
||||
value,
|
||||
onChange,
|
||||
});
|
||||
|
||||
/**
|
||||
* ToggleGroupProvider handles memoization.
|
||||
*/
|
||||
const context = {
|
||||
...toggleGroupContext,
|
||||
size,
|
||||
};
|
||||
|
||||
if (!value && !defaultValue) {
|
||||
console.error("ToggleGroup without value/defaultvalue is not allowed");
|
||||
}
|
||||
const labelId = useId();
|
||||
|
||||
const describeBy = cl({
|
||||
[desc ?? ""]: !!desc,
|
||||
[labelId ?? ""]: !!label,
|
||||
});
|
||||
if (!value && !defaultValue) {
|
||||
console.error("ToggleGroup without value or defaultvalue is not allowed");
|
||||
}
|
||||
|
||||
if (!value && !defaultValue) {
|
||||
console.error("ToggleGroup needs either a value or defaultValue");
|
||||
}
|
||||
|
||||
return (
|
||||
<ToggleGroupContext.Provider
|
||||
value={{
|
||||
size,
|
||||
}}
|
||||
>
|
||||
<div className={cl("navds-toggle-group__wrapper", className)}>
|
||||
{label && (
|
||||
<Label
|
||||
size={size}
|
||||
className="navds-toggle-group__label"
|
||||
id={labelId}
|
||||
>
|
||||
{label}
|
||||
</Label>
|
||||
)}
|
||||
<RadixToggleGroup.Root
|
||||
{...rest}
|
||||
onValueChange={handleValueChange}
|
||||
value={value ?? groupValue}
|
||||
defaultValue={defaultValue}
|
||||
ref={ref}
|
||||
className={cl(
|
||||
"navds-toggle-group",
|
||||
`navds-toggle-group--${size}`,
|
||||
`navds-toggle-group--${variant}`,
|
||||
)}
|
||||
{...(describeBy && { "aria-describedby": describeBy })}
|
||||
role="radiogroup"
|
||||
type="single"
|
||||
<ToggleGroupDescendantsProvider value={descendants}>
|
||||
<ToggleGroupProvider {...context}>
|
||||
<div
|
||||
className={cl("navds-toggle-group__wrapper", className, {
|
||||
"navds-toggle-group__wrapper--fill": fill,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</RadixToggleGroup.Root>
|
||||
</div>
|
||||
</ToggleGroupContext.Provider>
|
||||
{label && (
|
||||
<Label
|
||||
size={size}
|
||||
className="navds-toggle-group__label"
|
||||
id={labelId}
|
||||
>
|
||||
{label}
|
||||
</Label>
|
||||
)}
|
||||
<div
|
||||
{...rest}
|
||||
ref={ref}
|
||||
className={cl(
|
||||
"navds-toggle-group",
|
||||
`navds-toggle-group--${size}`,
|
||||
`navds-toggle-group--${variant}`,
|
||||
)}
|
||||
aria-describedby={
|
||||
cl(userDescribedby, !!label && labelId) || undefined
|
||||
}
|
||||
role="radiogroup"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</ToggleGroupProvider>
|
||||
</ToggleGroupDescendantsProvider>
|
||||
);
|
||||
},
|
||||
) as ToggleGroupComponent;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { HTMLAttributes } from "react";
|
||||
|
||||
export interface ToggleGroupProps
|
||||
extends Omit<HTMLAttributes<HTMLDivElement>, "onChange" | "dir"> {
|
||||
/**
|
||||
* Toggles.Item elements.
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* Changes padding and font-size.
|
||||
* @default "medium"
|
||||
*/
|
||||
size?: "medium" | "small";
|
||||
/**
|
||||
* Controlled selected value.
|
||||
*/
|
||||
value?: string;
|
||||
/**
|
||||
* If not controlled, a default-value needs to be set.
|
||||
*/
|
||||
defaultValue?: string;
|
||||
/**
|
||||
* Callback for selected toggle.
|
||||
*/
|
||||
onChange: (value: string) => void;
|
||||
/**
|
||||
* Label describing ToggleGroup.
|
||||
*/
|
||||
label?: React.ReactNode;
|
||||
/**
|
||||
* Changes design and interaction-visuals.
|
||||
* @default "action"
|
||||
*/
|
||||
variant?: "action" | "neutral";
|
||||
/**
|
||||
* Stretch each button to fill avaliable space in container.
|
||||
* @default false
|
||||
*/
|
||||
fill?: boolean;
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import * as RadixToggleGroup from "@radix-ui/react-toggle-group";
|
||||
import cl from "clsx";
|
||||
import React, { forwardRef, useContext } from "react";
|
||||
import { BodyShort } from "../typography/BodyShort";
|
||||
import { ToggleGroupContext } from "./context";
|
||||
|
||||
export interface ToggleGroupItemProps
|
||||
extends React.HTMLAttributes<HTMLButtonElement> {
|
||||
/**
|
||||
* Content
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* Value for state-handling
|
||||
*/
|
||||
value: string;
|
||||
}
|
||||
|
||||
const ToggleItem = forwardRef<HTMLButtonElement, ToggleGroupItemProps>(
|
||||
({ className, children, ...rest }, ref) => {
|
||||
const context = useContext(ToggleGroupContext);
|
||||
|
||||
return (
|
||||
<RadixToggleGroup.Item
|
||||
{...rest}
|
||||
ref={ref}
|
||||
className={cl("navds-toggle-group__button", className)}
|
||||
>
|
||||
<BodyShort
|
||||
as="span"
|
||||
className="navds-toggle-group__button-inner"
|
||||
size={context?.size}
|
||||
>
|
||||
{children}
|
||||
</BodyShort>
|
||||
</RadixToggleGroup.Item>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default ToggleItem;
|
||||
@@ -1,9 +0,0 @@
|
||||
import { createContext } from "react";
|
||||
|
||||
interface ToggleContextProps {
|
||||
size: "medium" | "small";
|
||||
}
|
||||
|
||||
export const ToggleGroupContext = createContext<ToggleContextProps | null>(
|
||||
null,
|
||||
);
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
export { default as ToggleGroup, type ToggleGroupProps } from "./ToggleGroup";
|
||||
export { default as ToggleGroup } from "./ToggleGroup";
|
||||
export { type ToggleGroupProps } from "./ToggleGroup.types";
|
||||
export {
|
||||
default as ToggleGroupItem,
|
||||
type ToggleGroupItemProps,
|
||||
} from "./ToggleItem";
|
||||
} from "./parts/ToggleItem";
|
||||
@@ -0,0 +1,55 @@
|
||||
import cl from "clsx";
|
||||
import React, { forwardRef } from "react";
|
||||
import { BodyShort } from "../../typography/BodyShort";
|
||||
import { useToggleGroupContext } from "../ToggleGroup.context";
|
||||
import { useToggleItem } from "./useToggleItem";
|
||||
|
||||
export interface ToggleGroupItemProps
|
||||
extends React.HTMLAttributes<HTMLButtonElement> {
|
||||
/**
|
||||
* Content.
|
||||
*/
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* Value for state-handling.
|
||||
*/
|
||||
value: string;
|
||||
}
|
||||
|
||||
const ToggleItem = forwardRef<HTMLButtonElement, ToggleGroupItemProps>(
|
||||
(
|
||||
{ className, children, value, onClick, onFocus, onKeyDown, ...rest },
|
||||
forwardedRef,
|
||||
) => {
|
||||
const itemCtx = useToggleItem(
|
||||
{ value, onClick, onFocus, disabled: false, onKeyDown },
|
||||
forwardedRef,
|
||||
);
|
||||
const ctx = useToggleGroupContext();
|
||||
|
||||
return (
|
||||
<button
|
||||
{...rest}
|
||||
ref={itemCtx.ref}
|
||||
className={cl("navds-toggle-group__button", className)}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={itemCtx.isSelected}
|
||||
tabIndex={itemCtx.isFocused ? 0 : -1}
|
||||
onClick={itemCtx.onClick}
|
||||
onFocus={itemCtx.onFocus}
|
||||
onKeyDown={itemCtx.onKeyDown}
|
||||
>
|
||||
<BodyShort
|
||||
as="span"
|
||||
className="navds-toggle-group__button-inner"
|
||||
size={ctx?.size}
|
||||
>
|
||||
{children}
|
||||
</BodyShort>
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default ToggleItem;
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useCallback } from "react";
|
||||
import { composeEventHandlers } from "../../util/composeEventHandlers";
|
||||
import { mergeRefs } from "../../util/hooks/useMergeRefs";
|
||||
import {
|
||||
useToggleGroupContext,
|
||||
useToggleGroupDescendant,
|
||||
} from "../ToggleGroup.context";
|
||||
|
||||
export interface UseToggleItemProps {
|
||||
/**
|
||||
* If `true`, the `ToggleItem` won't be toggleable
|
||||
* @default false
|
||||
*/
|
||||
disabled?: boolean;
|
||||
onClick?: React.MouseEventHandler;
|
||||
onFocus?: React.FocusEventHandler;
|
||||
onKeyDown?: React.KeyboardEventHandler;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export function useToggleItem<P extends UseToggleItemProps>(
|
||||
{
|
||||
value,
|
||||
disabled = false,
|
||||
onFocus: _onFocus,
|
||||
onClick,
|
||||
onKeyDown: _onKeyDown,
|
||||
}: P,
|
||||
ref: React.ForwardedRef<HTMLButtonElement>,
|
||||
) {
|
||||
const { setSelectedValue, setFocusedValue, selectedValue, focusedValue } =
|
||||
useToggleGroupContext();
|
||||
|
||||
const { register, descendants } = useToggleGroupDescendant({
|
||||
disabled,
|
||||
value,
|
||||
});
|
||||
|
||||
const isSelected = value === selectedValue;
|
||||
|
||||
const onFocus = () => setFocusedValue(value);
|
||||
|
||||
/**
|
||||
* Implements roving-tabindex for horizontal tabs
|
||||
*/
|
||||
const onKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent) => {
|
||||
/**
|
||||
* ToggleGroup.Item is registered with its prop 'value'.
|
||||
* We can then use it to find the current focuses descendant
|
||||
*/
|
||||
const idx = descendants
|
||||
.values()
|
||||
.findIndex((x) => x.value === focusedValue);
|
||||
|
||||
const nextTab = () => {
|
||||
const next = descendants.nextEnabled(idx, false);
|
||||
next && next.node?.focus();
|
||||
};
|
||||
const prevTab = () => {
|
||||
const prev = descendants.prevEnabled(idx, false);
|
||||
prev && prev.node?.focus();
|
||||
};
|
||||
const firstTab = () => {
|
||||
const first = descendants.firstEnabled();
|
||||
first && first.node?.focus();
|
||||
};
|
||||
const lastTab = () => {
|
||||
const last = descendants.lastEnabled();
|
||||
last && last.node?.focus();
|
||||
};
|
||||
|
||||
const keyMap: Record<string, React.KeyboardEventHandler> = {
|
||||
ArrowLeft: prevTab,
|
||||
ArrowRight: nextTab,
|
||||
Home: firstTab,
|
||||
End: lastTab,
|
||||
};
|
||||
|
||||
const action = keyMap[event.key];
|
||||
|
||||
if (action) {
|
||||
event.preventDefault();
|
||||
action(event);
|
||||
} else if (event.key === "Tab") {
|
||||
/**
|
||||
* Imperative focus during keydown is risky so we prevent React's batching updates
|
||||
* to avoid potential bugs. See: https://github.com/facebook/react/issues/20332
|
||||
*/
|
||||
selectedValue && setTimeout(() => setFocusedValue(selectedValue));
|
||||
}
|
||||
},
|
||||
[descendants, focusedValue, selectedValue, setFocusedValue],
|
||||
);
|
||||
|
||||
return {
|
||||
ref: mergeRefs([register, ref]),
|
||||
isSelected,
|
||||
isFocused: focusedValue === value,
|
||||
onClick: composeEventHandlers(onClick, () => setSelectedValue(value)),
|
||||
onFocus: disabled ? undefined : composeEventHandlers(_onFocus, onFocus),
|
||||
onKeyDown: composeEventHandlers(_onKeyDown, onKeyDown),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useControllableState } from "../util/hooks/useControllableState";
|
||||
import { ToggleGroupProps } from "./ToggleGroup.types";
|
||||
|
||||
export function useToggleGroup({
|
||||
onChange,
|
||||
value,
|
||||
defaultValue = "",
|
||||
}: Pick<ToggleGroupProps, "onChange" | "value" | "defaultValue">) {
|
||||
const [focusedValue, setFocusedValue] = useState(defaultValue);
|
||||
|
||||
const [selectedValue, setSelectedValue] = useControllableState({
|
||||
defaultValue,
|
||||
value,
|
||||
onChange,
|
||||
});
|
||||
|
||||
/**
|
||||
* Sync focused `value` with controlled `selectedValue`
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (value != null) {
|
||||
setFocusedValue(value);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
return {
|
||||
selectedValue,
|
||||
setSelectedValue,
|
||||
focusedValue,
|
||||
setFocusedValue,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
# @navikt/ds-tailwind
|
||||
|
||||
## 6.5.0
|
||||
|
||||
## 6.4.1
|
||||
|
||||
## 6.4.0
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/ds-tailwind",
|
||||
"version": "6.4.1",
|
||||
"version": "6.5.0",
|
||||
"description": "Tailwind config based on @navikt/ds-tokens",
|
||||
"author": "Aksel | NAV designsystem team",
|
||||
"keywords": [
|
||||
@@ -25,7 +25,7 @@
|
||||
"directory": "@navikt/core/tailwind"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@navikt/ds-tokens": "^6.4.1",
|
||||
"@navikt/ds-tokens": "^6.5.0",
|
||||
"color": "4.2.3",
|
||||
"lodash": "^4.17.21",
|
||||
"tailwindcss": "^3.3.3",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# @navikt/ds-tokens
|
||||
|
||||
## 6.5.0
|
||||
|
||||
## 6.4.1
|
||||
|
||||
## 6.4.0
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@navikt/ds-tokens",
|
||||
"version": "6.4.1",
|
||||
"version": "6.5.0",
|
||||
"description": "Design-tokens for NAV designsystem",
|
||||
"author": "Aksel | NAV designsystem team",
|
||||
"keywords": [
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
# Changelog
|
||||
|
||||
## 6.5
|
||||
|
||||
### @navikt/ds-react
|
||||
|
||||
- Tabs: Ny prop `lazy` som rendrer innhold i TabPanel selv når panel er skjult (har fortsatt display:none) ([#2621](https://github.com/navikt/aksel/pull/2621))
|
||||
|
||||
- ToggleGroup: Erstattet bruk av dependency `@radix-ui/react-toggle-group` med egen implementasjon. ([#2620](https://github.com/navikt/aksel/pull/2620))
|
||||
|
||||
- Tabs: La til ny prop `fill` som lar Tabs.Tab-elementer strekke seg over tilgjengelig bredde. ([#2621](https://github.com/navikt/aksel/pull/2621))
|
||||
|
||||
- Tabs: Erstattet bruk av dependency `@radix-ui/react-tabs` med egen implementasjon. ([#2621](https://github.com/navikt/aksel/pull/2621))
|
||||
|
||||
* DatePicker/MonthPicker: Valgte datoer får nå `aria-pressed` for å bedre indikere valg for skjermleser. ([#2838](https://github.com/navikt/aksel/pull/2838))
|
||||
|
||||
* DatePicker/MonthPicker: `required`-prop stoppet ikke de-select av allerede valgt dato. ([#2838](https://github.com/navikt/aksel/pull/2838))
|
||||
|
||||
* ToggleGroup: La til ny prop `fill` som strekker ToggleGroup til å ta opp all tilgjengelig bredde. ([#2620](https://github.com/navikt/aksel/pull/2620))
|
||||
|
||||
## 6.4
|
||||
|
||||
### @navikt/ds-css
|
||||
|
||||
@@ -15,10 +15,10 @@
|
||||
"playroom"
|
||||
],
|
||||
"dependencies": {
|
||||
"@navikt/aksel-icons": "^6.4.1",
|
||||
"@navikt/ds-css": "^6.4.1",
|
||||
"@navikt/ds-react": "^6.4.1",
|
||||
"@navikt/ds-tailwind": "^6.4.1",
|
||||
"@navikt/ds-tokens": "^6.4.1"
|
||||
"@navikt/aksel-icons": "^6.5.0",
|
||||
"@navikt/ds-css": "^6.5.0",
|
||||
"@navikt/ds-react": "^6.5.0",
|
||||
"@navikt/ds-tailwind": "^6.5.0",
|
||||
"@navikt/ds-tokens": "^6.5.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* SSR-safe hook that listens for the Escape keydown event and calls the provided callback.
|
||||
* @param onEscapeKeyDown callback for when Escape key is pressed
|
||||
* @param deps additional dependencies to keep the hook up-to-date
|
||||
*/
|
||||
export function useEscapeKeydown(
|
||||
onEscapeKeyDown: (event: KeyboardEvent) => void,
|
||||
deps: React.DependencyList = [],
|
||||
) {
|
||||
useEffect(() => {
|
||||
const ownerDocument: Document = globalThis?.document;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
onEscapeKeyDown(event);
|
||||
}
|
||||
};
|
||||
ownerDocument.addEventListener("keydown", handleKeyDown);
|
||||
return () => ownerDocument.removeEventListener("keydown", handleKeyDown);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [onEscapeKeyDown, ...deps]);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useMemo } from "react";
|
||||
import { BodyLong, Heading } from "@navikt/ds-react";
|
||||
import GpArticleCard from "@/layout/god-praksis-page/cards/GpArticleCard";
|
||||
import {
|
||||
GpSlugQueryResponse,
|
||||
ParsedGPArticle,
|
||||
} from "@/layout/god-praksis-page/interface";
|
||||
import { useGpViews } from "@/layout/god-praksis-page/useGpViews";
|
||||
|
||||
type GpArticleCardProps = {
|
||||
articles: ParsedGPArticle[];
|
||||
undertemaList: GpSlugQueryResponse["tema"]["undertema"];
|
||||
};
|
||||
|
||||
export function ArticleSections({
|
||||
articles,
|
||||
undertemaList,
|
||||
}: GpArticleCardProps) {
|
||||
const queryState = useGpViews();
|
||||
|
||||
const sections: [
|
||||
GpSlugQueryResponse["tema"]["undertema"][0],
|
||||
ParsedGPArticle[],
|
||||
][] = useMemo(() => {
|
||||
const getUndertemaNameFromTema = (undertema: string) =>
|
||||
undertemaList.find((ut) => ut.title === undertema) ?? null;
|
||||
|
||||
const groupedByUndertema = articles.reduce((map, article) => {
|
||||
const undertema = getUndertemaNameFromTema(article.undertema);
|
||||
if (!undertema) {
|
||||
return map;
|
||||
}
|
||||
|
||||
const articlesForUndertema = map.get(undertema) || [];
|
||||
articlesForUndertema.push(article);
|
||||
map.set(undertema, articlesForUndertema);
|
||||
|
||||
return map;
|
||||
}, new Map<GpSlugQueryResponse["tema"]["undertema"][0], ParsedGPArticle[]>());
|
||||
|
||||
if (queryState.view === "none") {
|
||||
return [...groupedByUndertema.entries()];
|
||||
} else if (queryState.view === "undertema") {
|
||||
const currentUndertema = getUndertemaNameFromTema(queryState.undertema);
|
||||
if (!currentUndertema) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const utArticles = groupedByUndertema.get(currentUndertema);
|
||||
if (!utArticles) {
|
||||
return [];
|
||||
}
|
||||
return [[currentUndertema, utArticles]];
|
||||
} else if (queryState.view === "innholdstype") {
|
||||
const filteredMap = new Map();
|
||||
for (const [undertema, utArticles] of groupedByUndertema) {
|
||||
const filteredArticles = utArticles.filter(
|
||||
(article) => article.innholdstype === queryState.innholdstype,
|
||||
);
|
||||
if (filteredArticles.length > 0) {
|
||||
filteredMap.set(undertema, filteredArticles);
|
||||
}
|
||||
}
|
||||
|
||||
if (filteredMap.size === 0) {
|
||||
return [];
|
||||
}
|
||||
return [...filteredMap.entries()];
|
||||
} else if (queryState.view === "both") {
|
||||
const matchingArticles = articles.filter(
|
||||
(article) =>
|
||||
article.undertema === queryState.undertema &&
|
||||
article.innholdstype === queryState.innholdstype,
|
||||
);
|
||||
|
||||
if (matchingArticles.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
[
|
||||
{
|
||||
title: `Artikler for ${queryState.undertema} og ${queryState.innholdstype}`,
|
||||
},
|
||||
matchingArticles,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
}, [articles, undertemaList, queryState]);
|
||||
|
||||
const SectionTag =
|
||||
queryState.view === "undertema" || queryState.view === "both"
|
||||
? "div"
|
||||
: "section";
|
||||
|
||||
return sections.map(([ut, utArticles]) => (
|
||||
<SectionTag
|
||||
key={ut.title}
|
||||
aria-label={
|
||||
["none", "innholdstype"].includes(queryState.view)
|
||||
? `Undertema ${ut.title}`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<IntroSection title={ut.title} description={ut.description} />
|
||||
<GpCardGrid>
|
||||
{utArticles.map((article) => (
|
||||
<li key={article.slug}>
|
||||
<GpArticleCard
|
||||
href={`${article.slug}`.replace("god-praksis", "gp")}
|
||||
innholdstype={article.innholdstype}
|
||||
undertema={article.undertema}
|
||||
publishedAt={article.publishedAt}
|
||||
description={article.description}
|
||||
>
|
||||
{article.heading}
|
||||
</GpArticleCard>
|
||||
</li>
|
||||
))}
|
||||
</GpCardGrid>
|
||||
</SectionTag>
|
||||
));
|
||||
}
|
||||
|
||||
function IntroSection({
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Heading level="2" size="medium" className="mb-2 text-aksel-heading">
|
||||
{title}
|
||||
</Heading>
|
||||
{description && <BodyLong spacing>{description}</BodyLong>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function GpCardGrid({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ul className="grid grid-cols-1 gap-3 *:grid md:grid-cols-2 md:gap-6">
|
||||
{children}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
export function FallbackPictogram() {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden
|
||||
viewBox="0 0 48 48"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g clipPath="url(#clip0_4376_826)">
|
||||
<rect
|
||||
x="14.25"
|
||||
width="20.6625"
|
||||
height="20.6625"
|
||||
rx="1.76601"
|
||||
transform="rotate(45 14.25 0)"
|
||||
className="fill-teal-200 group-aria-[current]:fill-teal-500"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M31.9835 10.9238L38.9205 16.7453L27.8505 30.2028L18.4766 34.4401L20.9189 24.374L31.9835 10.9238ZM22.984 25.4054L32.2776 14.1079L35.7675 17.0366L26.4459 28.3685L21.75 30.4912L22.984 25.4054Z"
|
||||
className="fill-text-default group-aria-[current]:fill-text-on-inverted"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M34.8732 7.43541L34.9073 7.40063C36.5368 5.73522 39.2262 5.47011 41.0842 7.06044L41.1244 7.09484L41.3513 7.30836L41.3882 7.34644C43.03 9.04376 43.0583 11.719 41.5681 13.5305L38.0109 17.8515L31.074 12.0302L34.6618 7.67111L34.8732 7.43541ZM36.3686 9.13798L36.5155 8.97419C37.378 8.09268 38.7282 8.00555 39.6211 8.76981L39.771 8.91079C40.5762 9.74316 40.6324 11.1263 39.8305 12.1011L37.7174 14.6679L34.2275 11.7393L36.3686 9.13798Z"
|
||||
className="fill-text-default group-aria-[current]:fill-text-on-inverted"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M10.2246 12.9243H26.25C26.8713 12.9243 27.375 12.4206 27.375 11.7993C27.375 11.178 26.8713 10.6743 26.25 10.6743H9.84961C8.81408 10.6743 7.97461 11.5138 7.97461 12.5493V41.716C7.97461 42.7515 8.81408 43.591 9.84961 43.591H35.9496C36.9851 43.591 37.8246 42.7515 37.8246 41.716V25.125C37.8246 24.5037 37.3209 24 36.6996 24C36.0783 24 35.5746 24.5037 35.5746 25.125V41.341H10.2246V12.9243Z"
|
||||
className="fill-text-default group-aria-[current]:fill-text-on-inverted"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_4376_826">
|
||||
<rect width="48" height="48" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import dynamic from "next/dynamic";
|
||||
import { Box, Page, VStack } from "@navikt/ds-react";
|
||||
import Footer from "@/layout/footer/Footer";
|
||||
import { GpEntryPageProps } from "@/layout/god-praksis-page/interface";
|
||||
import useInitialState from "@/layout/god-praksis-page/useInitialState";
|
||||
import Header from "@/layout/header/Header";
|
||||
import Hero from "./hero/Hero";
|
||||
|
||||
const ArticleList = dynamic(() => import("./articles/ArticleList"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
const ChipGroup = dynamic(() => import("./chips/ChipGroup"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
function GodPraksisPage(props: GpEntryPageProps) {
|
||||
const { initialData } = useInitialState(props.initialArticles);
|
||||
|
||||
return (
|
||||
/* TODO: Add surface-subtle to page-component props */
|
||||
<Page
|
||||
footer={<Footer />}
|
||||
footerPosition="belowFold"
|
||||
className="bg-surface-subtle"
|
||||
>
|
||||
<Header variant="subtle" />
|
||||
<Box paddingBlock="10">
|
||||
<Page.Block width="xl" gutters>
|
||||
<VStack gap="10">
|
||||
<VStack gap="6">
|
||||
<Hero tema={props.tema} heroNav={props.heroNav} />
|
||||
<ChipGroup data={props.chipsData} showTema={!!props.tema} />
|
||||
</VStack>
|
||||
<ArticleList initialArticles={initialData} />
|
||||
</VStack>
|
||||
</Page.Block>
|
||||
</Box>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
export default GodPraksisPage;
|
||||
@@ -1,91 +0,0 @@
|
||||
import cl from "clsx";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ChevronRightIcon,
|
||||
FileFillIcon,
|
||||
TagFillIcon,
|
||||
} from "@navikt/aksel-icons";
|
||||
import { BodyLong, Detail, Heading, VStack } from "@navikt/ds-react";
|
||||
import { useFormatedDate } from "@/hooks/useFormatedDate";
|
||||
import { GpArticleT } from "@/layout/god-praksis-page/interface";
|
||||
import styles from "./articles.module.css";
|
||||
|
||||
/**
|
||||
* TODO:
|
||||
* - Implement reference-design from Figma (WIP)
|
||||
* - Implement better screen-reader ux
|
||||
* - - Only Heading should be a Link(<a>) with ::after for click-area
|
||||
* - - (see `components/sanity-modules/cards/ArtikkelCard.tsx`)
|
||||
*/
|
||||
export const ArticleCard = ({
|
||||
heading,
|
||||
ingress,
|
||||
slug,
|
||||
publishedAt,
|
||||
group,
|
||||
delay,
|
||||
innholdstype,
|
||||
currentUndertema,
|
||||
}: GpArticleT & {
|
||||
group: "initial" | "lazy";
|
||||
delay?: number;
|
||||
}) => {
|
||||
const date = useFormatedDate(publishedAt ?? undefined);
|
||||
|
||||
const tDelay = delay ? { transitionDuration: `${delay}ms` } : undefined;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/${
|
||||
slug.replace("god-praksis/", "gp/") /* TODO: Remove replace */
|
||||
}`}
|
||||
className={cl(
|
||||
"w-full flex-shrink overflow-hidden text-ellipsis rounded-large bg-surface-default p-5 shadow-small transition-shadow ease-out hover:shadow-large focus:outline-none focus-visible:shadow-focus",
|
||||
{
|
||||
[styles.animatedFade]: group === "initial",
|
||||
[styles.animatedFadeLazy]: group === "lazy",
|
||||
},
|
||||
)}
|
||||
style={tDelay}
|
||||
>
|
||||
<VStack justify="space-between" className="relative min-h-full">
|
||||
<div>
|
||||
<Heading
|
||||
size="small"
|
||||
level="2"
|
||||
className="text-aksel-heading underline"
|
||||
>
|
||||
{heading}
|
||||
</Heading>
|
||||
{/* TODO: Can do this serverside in initialProps (saves data sent to user) */}
|
||||
{date && (
|
||||
<Detail textColor="subtle" className="mt-1">
|
||||
{date}
|
||||
</Detail>
|
||||
)}
|
||||
|
||||
{ingress && (
|
||||
<BodyLong className={cl("mt-2 line-clamp-2", styles.lineClamp)}>
|
||||
{ingress}
|
||||
</BodyLong>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-6 flex items-center justify-between gap-2">
|
||||
<div className="flex flex-wrap items-center gap-3 font-semibold">
|
||||
{currentUndertema && (
|
||||
<span className="flex items-center gap-05 text-teal-700">
|
||||
<TagFillIcon aria-hidden />
|
||||
<span>{currentUndertema}</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-05 text-violet-600">
|
||||
<FileFillIcon aria-hidden />
|
||||
<span>{innholdstype}</span>
|
||||
</span>
|
||||
</div>
|
||||
<ChevronRightIcon aria-hidden className="flex-shrink-0 text-2xl" />
|
||||
</div>
|
||||
</VStack>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
@@ -1,56 +0,0 @@
|
||||
import cl from "clsx";
|
||||
import { Heading } from "@navikt/ds-react";
|
||||
import { GpArticleT } from "@/layout/god-praksis-page/interface";
|
||||
import { ArticleCard } from "./ArticleCard";
|
||||
import styles from "./articles.module.css";
|
||||
|
||||
type ArticleGridT = {
|
||||
initialData: GpArticleT[];
|
||||
data: GpArticleT[];
|
||||
loaded: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* TODO:
|
||||
* - Does this need to be a separate component from `ArticleList`?
|
||||
* - Don`t use `loaded` as a prop here. Parent-component rendering this should just not render it if !loaded
|
||||
*/
|
||||
function ArticleGrid({ data, initialData, loaded }: ArticleGridT) {
|
||||
if (!initialData) {
|
||||
/**
|
||||
* TODO:
|
||||
* - Add content for emptystate here
|
||||
*/
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!loaded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.animatedFade}>
|
||||
<Heading level="2" size="medium" className="text-aksel-heading">
|
||||
Siste
|
||||
</Heading>
|
||||
|
||||
<div className="grid gap-6">
|
||||
<div className={cl("pt-5", styles.cardGrid)}>
|
||||
{initialData.map((article, idx) => (
|
||||
<ArticleCard key={article._id + idx} group="initial" {...article} />
|
||||
))}
|
||||
{data.map((article, idx) => (
|
||||
<ArticleCard
|
||||
key={idx}
|
||||
group="lazy"
|
||||
delay={((idx % 3) + 1) * 300}
|
||||
{...article}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ArticleGrid;
|
||||
@@ -1,110 +0,0 @@
|
||||
import cl from "clsx";
|
||||
import { useRouter } from "next/router";
|
||||
import useSWRInfinite from "swr/infinite";
|
||||
import { ChevronDownCircleIcon } from "@navikt/aksel-icons";
|
||||
import { Button } from "@navikt/ds-react";
|
||||
import ErrorBoundary from "@/error-boundary";
|
||||
import {
|
||||
GP_LAZYLOADED_ARTICLES,
|
||||
GpArticleT,
|
||||
} from "@/layout/god-praksis-page/interface";
|
||||
import useGpQuery from "@/layout/god-praksis-page/useGpQuery";
|
||||
import ArticleGrid from "./ArticleGrid";
|
||||
import styles from "./articles.module.css";
|
||||
|
||||
const getKey = ({
|
||||
input: { pageIndex, previousPageData },
|
||||
innholdstypeQuery,
|
||||
undertemaQuery,
|
||||
temaQuery,
|
||||
}: {
|
||||
input: { pageIndex: number; previousPageData: any[] };
|
||||
innholdstypeQuery: string | null;
|
||||
undertemaQuery: string | null;
|
||||
temaQuery: string | null;
|
||||
}) => {
|
||||
if (previousPageData && previousPageData.length < 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `/api/god-praksis/v2?page=${pageIndex}${
|
||||
temaQuery ? `&tema=${temaQuery}` : ""
|
||||
}${innholdstypeQuery ? `&innholdstype=${innholdstypeQuery}` : ""}${
|
||||
undertemaQuery ? `&undertema=${undertemaQuery}` : ""
|
||||
}`;
|
||||
};
|
||||
|
||||
type ArticleListT = {
|
||||
initialArticles: GpArticleT[];
|
||||
};
|
||||
|
||||
/**
|
||||
* TODO:
|
||||
* - Handle errors
|
||||
* - Refactor this component to be more generic.
|
||||
* - - Idea: Pull lazy-loading out to a hook that handles loading, button and errors
|
||||
*
|
||||
* - Better names for styles-css
|
||||
* - Grid-layout is experimental, check with designer
|
||||
*/
|
||||
function ArticleList({ initialArticles }: ArticleListT) {
|
||||
const router = useRouter();
|
||||
const { innholdstypeQuery, undertemaQuery, temaQuery } = useGpQuery();
|
||||
|
||||
const {
|
||||
data = [],
|
||||
size,
|
||||
setSize,
|
||||
isValidating,
|
||||
} = useSWRInfinite<GpArticleT[]>(
|
||||
(pageIndex, previousPageData) =>
|
||||
initialArticles.length < 9
|
||||
? null
|
||||
: getKey({
|
||||
input: {
|
||||
pageIndex: pageIndex + GP_LAZYLOADED_ARTICLES,
|
||||
previousPageData: previousPageData ?? [],
|
||||
},
|
||||
innholdstypeQuery,
|
||||
undertemaQuery,
|
||||
temaQuery,
|
||||
}),
|
||||
(query) => fetch(query).then((res) => res.json()),
|
||||
{
|
||||
revalidateFirstPage: false,
|
||||
initialSize: 0,
|
||||
},
|
||||
);
|
||||
|
||||
const atEndOfLazy = data && data[data.length - 1]?.length < 3;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ArticleGrid
|
||||
initialData={initialArticles}
|
||||
data={data.flat()}
|
||||
loaded={router.isReady}
|
||||
/>
|
||||
{!atEndOfLazy && initialArticles.length === 9 && router.isReady && (
|
||||
<div className={cl("flex justify-center pt-8", styles.animatedFade)}>
|
||||
<Button
|
||||
variant="tertiary-neutral"
|
||||
onClick={() => setSize(size + 1)}
|
||||
disabled={isValidating}
|
||||
icon={<ChevronDownCircleIcon aria-hidden />}
|
||||
>
|
||||
Last flere artikler
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Component(props: ArticleListT) {
|
||||
return (
|
||||
<ErrorBoundary boundaryName="GpArticleList">
|
||||
<ArticleList {...props} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
.animatedFade {
|
||||
animation: fadeUpKeyFrames 600ms forwards;
|
||||
}
|
||||
|
||||
.animatedFadeLazy {
|
||||
animation: fadeUpKeyFramesLazy 600ms forwards;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
@keyframes fadeUpKeyFrames {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, 1rem, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeUpKeyFramesLazy {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(2rem);
|
||||
}
|
||||
}
|
||||
|
||||
.cardGrid {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.cardGrid {
|
||||
gap: var(--a-spacing-4);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) and (max-width: 1279px) {
|
||||
.cardGrid {
|
||||
gap: var(--a-spacing-4);
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.cardGrid > :is(:nth-child(5n + 1)) {
|
||||
grid-column: span 2 / span 2;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.cardGrid {
|
||||
gap: var(--a-spacing-6);
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
|
||||
--text-clamp-lines: 2;
|
||||
}
|
||||
|
||||
.cardGrid > :is(:nth-child(6n + 4), :nth-child(6n + 6)) {
|
||||
grid-column: span 2 / span 2;
|
||||
}
|
||||
|
||||
.cardGrid > :is(:nth-child(6n + 5)) {
|
||||
grid-row: span 2 / span 2;
|
||||
|
||||
--text-clamp-lines: 10;
|
||||
}
|
||||
}
|
||||
|
||||
.lineClamp {
|
||||
-webkit-line-clamp: var(--text-clamp-lines);
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { HGrid } from "@navikt/ds-react";
|
||||
import { AkselTheme } from "@/sb-util";
|
||||
import GpArticleCard from "./GpArticleCard";
|
||||
|
||||
const meta = {
|
||||
title: "Layout/god-praksis/cards/ArticleCard",
|
||||
component: GpArticleCard,
|
||||
tags: ["autodocs"],
|
||||
decorators: [AkselTheme],
|
||||
} satisfies Meta<typeof GpArticleCard>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
const title = {
|
||||
short: "AI",
|
||||
medium: "Machine Learning Basics",
|
||||
long: "A Comprehensive Guide to Understanding and Implementing Transformer Models in NLP",
|
||||
};
|
||||
|
||||
export const CompactSingle: Story = {
|
||||
args: {
|
||||
href: "gp/brukeropplevelse",
|
||||
children: title.long,
|
||||
innholdstype: "Testing",
|
||||
undertema: "WCAG",
|
||||
description: "",
|
||||
publishedAt: "",
|
||||
},
|
||||
};
|
||||
|
||||
export const CompactMultiple = {
|
||||
render: (props) => {
|
||||
return (
|
||||
<HGrid gap="4" columns={{ xs: 1, md: 2 }}>
|
||||
<GpArticleCard {...props} href="test">
|
||||
{title.short}
|
||||
</GpArticleCard>
|
||||
<GpArticleCard {...props} href="test">
|
||||
{title.short}
|
||||
</GpArticleCard>
|
||||
<GpArticleCard {...props} href="test">
|
||||
{title.medium}
|
||||
</GpArticleCard>
|
||||
<GpArticleCard {...props} href="test">
|
||||
{title.long}
|
||||
</GpArticleCard>
|
||||
</HGrid>
|
||||
);
|
||||
},
|
||||
args: {
|
||||
innholdstype: "Testing",
|
||||
undertema: "WCAG",
|
||||
},
|
||||
};
|
||||
|
||||
export const ExpressiveSingle: Story = {
|
||||
args: {
|
||||
href: "gp/brukeropplevelse",
|
||||
children: "Brukeropplevelse",
|
||||
innholdstype: "Testing",
|
||||
undertema: "WCAG",
|
||||
description:
|
||||
"La brukeren sortere innholdet på en måte som er logisk for dem med kortsortering og UXtweak.",
|
||||
publishedAt: "2021-01-01",
|
||||
},
|
||||
};
|
||||
|
||||
export const ExpressiveMultiple = {
|
||||
render: (props) => {
|
||||
return (
|
||||
<HGrid gap="4" columns={{ xs: 1, md: 2 }}>
|
||||
<GpArticleCard {...props} href="test">
|
||||
{title.short}
|
||||
</GpArticleCard>
|
||||
<GpArticleCard {...props} href="test">
|
||||
{title.short}
|
||||
</GpArticleCard>
|
||||
<GpArticleCard {...props} href="test">
|
||||
{title.medium}
|
||||
</GpArticleCard>
|
||||
<GpArticleCard {...props} href="test">
|
||||
{title.long}
|
||||
</GpArticleCard>
|
||||
</HGrid>
|
||||
);
|
||||
},
|
||||
args: {
|
||||
children: title.medium,
|
||||
undertema: "WCAG",
|
||||
description:
|
||||
"La brukeren sortere innholdet på en måte som er logisk for dem med kortsortering og UXtweak.",
|
||||
publishedAt: "2021-01-01",
|
||||
},
|
||||
};
|
||||
|
||||
export const ExpressiveNoDescription: Story = {
|
||||
args: {
|
||||
href: "gp/brukeropplevelse",
|
||||
children: title.long,
|
||||
innholdstype: "Testing",
|
||||
undertema: "WCAG",
|
||||
publishedAt: "2021-01-01",
|
||||
},
|
||||
};
|
||||
|
||||
export const ExpressiveLongDescription: Story = {
|
||||
args: {
|
||||
href: "gp/brukeropplevelse",
|
||||
children: title.long,
|
||||
innholdstype: "Testing",
|
||||
undertema: "WCAG",
|
||||
publishedAt: "2021-01-01",
|
||||
description:
|
||||
"This is a description that is very long and should be truncated to a reasonable length to fit the card layout. This is a description that is very long and should be truncated to a reasonable length to fit the card layout. This is a description that is very long and should be truncated to a reasonable length to fit the card layout. This is a description that is very long and should be truncated to a reasonable length to fit the card layout. This is a description that is very long and should be truncated to a reasonable length to fit the card layout. This is a description that is very long and should be truncated to a reasonable length to fit the card layout.",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
import cl from "clsx";
|
||||
import Link from "next/link";
|
||||
import { FileFillIcon, TagFillIcon } from "@navikt/aksel-icons";
|
||||
import { BodyLong, Detail, Heading } from "@navikt/ds-react";
|
||||
import { AnimatedChevron } from "@/web/AnimatedChevron";
|
||||
|
||||
type GpArticleCardProps = {
|
||||
children: React.ReactNode;
|
||||
href: string;
|
||||
innholdstype?: string;
|
||||
undertema?: string;
|
||||
description?: string;
|
||||
publishedAt?: string;
|
||||
};
|
||||
|
||||
function GpArticleCard({
|
||||
href,
|
||||
children,
|
||||
innholdstype,
|
||||
undertema,
|
||||
description,
|
||||
publishedAt,
|
||||
}: GpArticleCardProps) {
|
||||
return (
|
||||
<div className="group relative flex flex-col gap-1 rounded-lg bg-surface-default p-4 pb-3 text-text-default shadow-xsmall hover:shadow-small has-[:focus-visible]:shadow-focus md:p-5 md:pb-4">
|
||||
<Link
|
||||
href={`/${href}`}
|
||||
className="peer after:absolute after:inset-0 after:z-10 after:rounded-lg focus:outline-none"
|
||||
>
|
||||
<Heading
|
||||
size="small"
|
||||
as="h3"
|
||||
className="text-aksel-heading underline group-hover:no-underline"
|
||||
>
|
||||
{children}
|
||||
</Heading>
|
||||
</Link>
|
||||
{publishedAt && (
|
||||
<Detail as="time" textColor="subtle" uppercase>
|
||||
{publishedAt}
|
||||
</Detail>
|
||||
)}
|
||||
|
||||
{description && (
|
||||
<BodyLong className="mt-1 line-clamp-2">{description}</BodyLong>
|
||||
)}
|
||||
<div
|
||||
className={cl("mt-auto flex h-fit justify-between text-deepblue-700", {
|
||||
"pt-5": description,
|
||||
})}
|
||||
>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{undertema && (
|
||||
<div className="flex items-center gap-05 text-teal-700">
|
||||
<TagFillIcon aria-hidden fontSize="1rem" />
|
||||
<Detail weight="semibold" as="span">
|
||||
{undertema}
|
||||
</Detail>
|
||||
</div>
|
||||
)}
|
||||
{innholdstype && (
|
||||
<div className="flex items-center gap-05 text-violet-600">
|
||||
<FileFillIcon aria-hidden fontSize="1rem" />
|
||||
<Detail weight="semibold" as="span">
|
||||
{innholdstype}
|
||||
</Detail>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AnimatedChevron />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default GpArticleCard;
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { HStack } from "@navikt/ds-react";
|
||||
import { AkselTheme } from "@/sb-util";
|
||||
import GpHeroCard from "./GpHeroCard";
|
||||
|
||||
const meta = {
|
||||
title: "Layout/god-praksis/cards/HeroCard",
|
||||
component: GpHeroCard,
|
||||
tags: ["autodocs"],
|
||||
decorators: [AkselTheme],
|
||||
} satisfies Meta<typeof GpHeroCard>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Single: Story = {
|
||||
args: {
|
||||
articleCount: 4,
|
||||
href: "gp/brukeropplevelse",
|
||||
children: "Brukeropplevelse",
|
||||
},
|
||||
};
|
||||
|
||||
export const Multiple = {
|
||||
render: () => {
|
||||
return (
|
||||
<HStack gap="4">
|
||||
<GpHeroCard href="test" articleCount={4}>
|
||||
Brukerinnsikt
|
||||
</GpHeroCard>
|
||||
<GpHeroCard href="test" articleCount={20}>
|
||||
Universell Utforming
|
||||
</GpHeroCard>
|
||||
<GpHeroCard href="test" articleCount={2}>
|
||||
Innsiktsarbeid
|
||||
</GpHeroCard>
|
||||
<GpHeroCard href="test" articleCount={40}>
|
||||
nav.no
|
||||
</GpHeroCard>
|
||||
</HStack>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import { SanityImageSource } from "@sanity/image-url/lib/types/types";
|
||||
import cl from "clsx";
|
||||
import Image from "next/legacy/image";
|
||||
import Link from "next/link";
|
||||
import { HTMLAttributes, forwardRef } from "react";
|
||||
import { BodyShort, Heading } from "@navikt/ds-react";
|
||||
import { FallbackPictogram } from "@/layout/god-praksis-page/FallbackPictogram";
|
||||
import { urlFor } from "@/sanity/interface";
|
||||
|
||||
type GpHeroCardProps = {
|
||||
children: React.ReactNode;
|
||||
href: string;
|
||||
articleCount?: number;
|
||||
image?: SanityImageSource;
|
||||
compact?: boolean;
|
||||
} & HTMLAttributes<HTMLAnchorElement>;
|
||||
|
||||
const GpHeroCard = forwardRef<HTMLAnchorElement, GpHeroCardProps>(
|
||||
({ href, articleCount, children, image, compact = false, ...rest }, ref) => {
|
||||
return (
|
||||
<Link
|
||||
ref={ref}
|
||||
href={`/${href}`}
|
||||
className="group flex gap-2 rounded-lg bg-surface-default py-2 pl-2 pr-3 shadow-xsmall outline-none hover:shadow-small focus-visible:shadow-focus aria-[current]:bg-teal-800 aria-[current]:focus-visible:shadow-focus-gap md:gap-3 md:py-3 md:pl-3 md:pr-6"
|
||||
{...rest}
|
||||
>
|
||||
<div
|
||||
className={cl("relative my-auto shrink-0", {
|
||||
"size-8 md:size-12": !compact,
|
||||
"size-6": compact,
|
||||
})}
|
||||
>
|
||||
{image ? (
|
||||
<Image
|
||||
src={urlFor(image).auto("format").url()}
|
||||
decoding="sync"
|
||||
layout="fill"
|
||||
objectFit="contain"
|
||||
aria-hidden
|
||||
priority
|
||||
/>
|
||||
) : (
|
||||
<FallbackPictogram />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-05">
|
||||
<Heading
|
||||
size="small"
|
||||
as="span"
|
||||
className="truncate text-aksel-heading underline group-hover:no-underline group-aria-[current]:text-text-on-inverted group-aria-[current]:no-underline"
|
||||
>
|
||||
{children}
|
||||
</Heading>
|
||||
{articleCount && (
|
||||
<BodyShort
|
||||
as="span"
|
||||
size="small"
|
||||
textColor="subtle"
|
||||
className="uppercase"
|
||||
>{`${articleCount} ${
|
||||
articleCount === 1 ? "artikkel" : "artikler"
|
||||
}`}</BodyShort>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default GpHeroCard;
|
||||
File renamed without changes.
@@ -0,0 +1,31 @@
|
||||
import cl from "clsx";
|
||||
|
||||
type GpChipProps = {
|
||||
children: React.ReactNode;
|
||||
pressed: boolean;
|
||||
onClick: () => void;
|
||||
type: "innholdstype" | "undertema";
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
export function GpChip(props: GpChipProps) {
|
||||
return (
|
||||
<button
|
||||
aria-pressed={props.pressed}
|
||||
onClick={props.onClick}
|
||||
className={cl(
|
||||
"grid min-h-8 place-content-center whitespace-nowrap rounded-full bg-surface-neutral-subtle px-3 py-1 ring-1 ring-inset transition-opacity focus:outline-none focus-visible:shadow-focus-gap aria-pressed:text-text-on-inverted",
|
||||
"disabled:bg-surface-neutral-subtle disabled:opacity-40 disabled:ring-border-default",
|
||||
{
|
||||
"ring-violet-700/50 hover:bg-violet-50 aria-pressed:bg-violet-700 hover:aria-pressed:bg-violet-800":
|
||||
props.type === "innholdstype",
|
||||
"ring-teal-700/50 hover:bg-teal-50 aria-pressed:bg-teal-700 hover:aria-pressed:bg-teal-800":
|
||||
props.type === "undertema",
|
||||
},
|
||||
)}
|
||||
disabled={props.disabled}
|
||||
>
|
||||
{props.children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import { VStack } from "@navikt/ds-react";
|
||||
import { getArticleCounts } from "@/layout/god-praksis-page/count-articles";
|
||||
import { ParsedGPArticle } from "@/layout/god-praksis-page/interface";
|
||||
import { useGpViews } from "@/layout/god-praksis-page/useGpViews";
|
||||
import { GpChipRow } from "./GpChipRow";
|
||||
|
||||
type GpChipNavigationProps = {
|
||||
articles: ParsedGPArticle[];
|
||||
};
|
||||
|
||||
export function GpChipNavigation({ articles }: GpChipNavigationProps) {
|
||||
const view = useGpViews();
|
||||
|
||||
const articleCount = getArticleCounts(
|
||||
articles,
|
||||
view.undertema,
|
||||
view.innholdstype,
|
||||
);
|
||||
|
||||
return (
|
||||
<VStack gap="4">
|
||||
<GpChipRow
|
||||
type="undertema"
|
||||
entries={Object.entries(articleCount.undertemaCounts)}
|
||||
/>
|
||||
<GpChipRow
|
||||
type="innholdstype"
|
||||
entries={Object.entries(articleCount.innholdstypeCounts)}
|
||||
/>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import cl from "clsx";
|
||||
import omit from "lodash/omit";
|
||||
import { useRouter } from "next/router";
|
||||
import { useId } from "react";
|
||||
import { FileFillIcon, TagFillIcon } from "@navikt/aksel-icons";
|
||||
import { Label } from "@navikt/ds-react";
|
||||
import { GpChip } from "@/layout/god-praksis-page/chipnavigation/GpChip";
|
||||
import { safeString } from "@/layout/god-praksis-page/useGpViews";
|
||||
import { capitalize } from "@/utils";
|
||||
import styles from "./Chips.module.css";
|
||||
|
||||
export type ChipsRenderData = { title: string; count: number }[];
|
||||
|
||||
type GpChipRowProps = {
|
||||
type: "innholdstype" | "undertema";
|
||||
entries: [string, number][];
|
||||
};
|
||||
|
||||
export function GpChipRow({ type, entries }: GpChipRowProps) {
|
||||
const id = useId();
|
||||
|
||||
const { query, replace } = useRouter();
|
||||
|
||||
const reset = () => {
|
||||
replace({ query: omit(query, [type]) }, undefined, {
|
||||
shallow: true,
|
||||
});
|
||||
};
|
||||
|
||||
const handleClick = (titleRaw: string) => {
|
||||
query[type] === titleRaw
|
||||
? reset()
|
||||
: replace({ query: { ...query, [type]: titleRaw } }, undefined, {
|
||||
shallow: true,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
/* TODO: After release of new GP structure this could be removed since all articles will required proper data, thus always having chips */
|
||||
className="hidden has-[.chiplist]:grid"
|
||||
>
|
||||
<Label
|
||||
as="h2"
|
||||
className={cl("flex items-center gap-1 text-aksel-heading", {
|
||||
"text-violet-600": type === "innholdstype",
|
||||
"text-teal-700": type === "undertema",
|
||||
})}
|
||||
>
|
||||
{type == "undertema" && <TagFillIcon fontSize="20" aria-hidden />}
|
||||
{type == "innholdstype" && <FileFillIcon fontSize="20" aria-hidden />}
|
||||
{capitalize(type)}
|
||||
</Label>
|
||||
|
||||
<div className="relative mt-2">
|
||||
<ul id={id} className={cl("flex flex-wrap gap-2 p-1", styles.chips)}>
|
||||
<GpChip
|
||||
type={type}
|
||||
disabled={false}
|
||||
onClick={reset}
|
||||
pressed={!safeString(query?.[type])}
|
||||
>
|
||||
{`Alle (${entries.reduce((acc, [, count]) => acc + count, 0)})`}
|
||||
</GpChip>
|
||||
{entries.map(([entryName, count]) => {
|
||||
return (
|
||||
<li key={entryName} className="chiplist">
|
||||
<GpChip
|
||||
type={type}
|
||||
disabled={count === 0}
|
||||
onClick={() => handleClick(entryName)}
|
||||
pressed={entryName === safeString(query?.[type])}
|
||||
>{`${entryName} (${count})`}</GpChip>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { HStack } from "@navikt/ds-react";
|
||||
import { AkselTheme } from "@/sb-util";
|
||||
import { GpTemaLink } from "./GpTemaLink";
|
||||
|
||||
const meta = {
|
||||
title: "Layout/god-praksis/chips/TemaLink",
|
||||
component: GpTemaLink,
|
||||
tags: ["autodocs"],
|
||||
decorators: [AkselTheme],
|
||||
} satisfies Meta<typeof GpTemaLink>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Tema: Story = {
|
||||
args: {
|
||||
children: "Innholdstarbeid",
|
||||
href: "/gp/123",
|
||||
},
|
||||
};
|
||||
|
||||
export const Group = {
|
||||
render: () => (
|
||||
<HStack gap="2">
|
||||
<GpTemaLink href="#">Universell utforming</GpTemaLink>
|
||||
<GpTemaLink href="#">Innholdsarbeid</GpTemaLink>
|
||||
<GpTemaLink href="#">Universell utforming</GpTemaLink>
|
||||
<GpTemaLink href="#">Innholdsarbeid</GpTemaLink>
|
||||
</HStack>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import NextLink from "next/link";
|
||||
import { ChevronRightIcon, TagFillIcon } from "@navikt/aksel-icons";
|
||||
|
||||
type GpTemaLinkProps = {
|
||||
children?: React.ReactNode;
|
||||
href: string;
|
||||
};
|
||||
|
||||
export function GpTemaLink({ children, href, ...rest }: GpTemaLinkProps) {
|
||||
return (
|
||||
<NextLink
|
||||
href={href}
|
||||
className="group flex min-h-8 w-fit items-center whitespace-nowrap rounded-full bg-teal-100 px-2 py-1 ring-1 ring-inset ring-teal-700 hover:underline focus:outline-none focus-visible:shadow-focus-gap"
|
||||
{...rest}
|
||||
>
|
||||
<TagFillIcon className="text-teal-700" aria-hidden />
|
||||
<span className="ml-05 leading-none text-text-default">{children}</span>
|
||||
<ChevronRightIcon
|
||||
fontSize="1.5rem"
|
||||
aria-hidden
|
||||
className="text-teal-700 transition-transform group-hover:translate-x-0.5 group-focus-visible:translate-x-0.5"
|
||||
/>
|
||||
</NextLink>
|
||||
);
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
import { VStack } from "@navikt/ds-react";
|
||||
import { ChipsData } from "@/layout/god-praksis-page/interface";
|
||||
import useGpQuery from "@/layout/god-praksis-page/useGpQuery";
|
||||
import ChipNav from "./ChipNav";
|
||||
|
||||
export type ChipsRenderData = { title: string; count: number }[];
|
||||
|
||||
const countUniques = (
|
||||
type: "innholdstype" | "undertema",
|
||||
data: ChipsData,
|
||||
query: string | null,
|
||||
comparisonQuery: string | null,
|
||||
): ChipsRenderData => {
|
||||
const lens =
|
||||
type === "innholdstype" ? "innholdstype-title" : "undertema-title";
|
||||
|
||||
const lensFlipped =
|
||||
type === "innholdstype" ? "undertema-title" : "innholdstype-title";
|
||||
|
||||
const map = new Map<string, number>();
|
||||
|
||||
data.forEach((entry) => {
|
||||
let add = false;
|
||||
if (comparisonQuery && query) {
|
||||
add = entry[lens] === query;
|
||||
}
|
||||
if (comparisonQuery && !query) {
|
||||
add = entry[lensFlipped] === comparisonQuery;
|
||||
} else if (comparisonQuery && query) {
|
||||
add = entry[lensFlipped] === comparisonQuery && entry[lens] === query;
|
||||
} else {
|
||||
add = true;
|
||||
}
|
||||
|
||||
const count = map.get(entry[lens]) || 0;
|
||||
map.set(entry[lens], count + (add ? 1 : 0));
|
||||
});
|
||||
|
||||
const chipData: { title: string; count: number }[] = [];
|
||||
for (const [key, value] of map.entries()) {
|
||||
chipData.push({ title: key, count: value });
|
||||
}
|
||||
return chipData;
|
||||
};
|
||||
|
||||
type ChipsGroupProps = { data: ChipsData; showTema: boolean };
|
||||
|
||||
function ChipsGroup(props: ChipsGroupProps) {
|
||||
const { innholdstypeQuery, undertemaQuery } = useGpQuery();
|
||||
|
||||
const list = useMemo(() => {
|
||||
return {
|
||||
innholdstype: countUniques(
|
||||
"innholdstype",
|
||||
props.data,
|
||||
innholdstypeQuery,
|
||||
undertemaQuery,
|
||||
),
|
||||
undertema: countUniques(
|
||||
"undertema",
|
||||
props.data,
|
||||
undertemaQuery,
|
||||
innholdstypeQuery,
|
||||
),
|
||||
};
|
||||
}, [props.data, innholdstypeQuery, undertemaQuery]);
|
||||
|
||||
return (
|
||||
<VStack gap="4">
|
||||
{props.showTema && <ChipNav type="undertema" data={list.undertema} />}
|
||||
<ChipNav type="innholdstype" data={list.innholdstype} />
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChipsGroup;
|
||||
@@ -1,86 +0,0 @@
|
||||
import cl from "clsx";
|
||||
import omit from "lodash/omit";
|
||||
import { useRouter } from "next/router";
|
||||
import { useId } from "react";
|
||||
import { FileFillIcon, TagFillIcon } from "@navikt/aksel-icons";
|
||||
import { HGrid, Label } from "@navikt/ds-react";
|
||||
import { capitalize } from "@/utils";
|
||||
import styles from "./Chips.module.css";
|
||||
import ScrollFade from "./ScrollFade";
|
||||
|
||||
export type ChipsRenderData = { title: string; count: number }[];
|
||||
|
||||
type ChipsNavProps = {
|
||||
type: "innholdstype" | "undertema";
|
||||
data?: ChipsRenderData;
|
||||
};
|
||||
|
||||
/**
|
||||
* TODO:
|
||||
* - On initial load, scroll selected into view
|
||||
*/
|
||||
function ChipNav({ type, data }: ChipsNavProps) {
|
||||
const id = useId();
|
||||
|
||||
const { query, replace } = useRouter();
|
||||
|
||||
const handleClick = async (titleRaw: string) => {
|
||||
const title = encodeURIComponent(titleRaw);
|
||||
|
||||
query[type] === title
|
||||
? replace({ query: omit(query, [type]) }, undefined, {
|
||||
shallow: true,
|
||||
})
|
||||
: replace({ query: { ...query, [type]: title } }, undefined, {
|
||||
shallow: true,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<HGrid gap="2" columns={{ md: 1, lg: "auto 1fr" }} align="center">
|
||||
<Label
|
||||
as="span"
|
||||
className={cl("flex items-center gap-2 text-aksel-heading", {
|
||||
"text-violet-600": type === "innholdstype",
|
||||
"text-teal-700": type === "undertema",
|
||||
})}
|
||||
>
|
||||
{type == "undertema" && <TagFillIcon fontSize="20" aria-hidden />}
|
||||
{type == "innholdstype" && <FileFillIcon fontSize="20" aria-hidden />}
|
||||
{capitalize(type)}
|
||||
</Label>
|
||||
|
||||
<div className="relative overflow-hidden scroll-smooth">
|
||||
<ScrollFade id={id} />
|
||||
<ul
|
||||
id={id}
|
||||
className={cl("flex gap-2 overflow-x-scroll p-1", styles.chips)}
|
||||
>
|
||||
{data?.map((entry) => (
|
||||
<li key={entry.title}>
|
||||
<button
|
||||
aria-pressed={encodeURIComponent(entry.title) === query?.[type]}
|
||||
onClick={() => handleClick(entry.title)}
|
||||
className={cl(
|
||||
"grid min-h-8 place-content-center whitespace-nowrap rounded-full bg-surface-neutral-subtle px-3 py-1 ring-1 ring-inset transition-opacity focus:outline-none focus-visible:shadow-focus-gap aria-pressed:text-text-on-inverted",
|
||||
"disabled:bg-surface-neutral-subtle disabled:opacity-40 disabled:ring-border-default",
|
||||
{
|
||||
"ring-violet-700/50 hover:bg-violet-50 aria-pressed:bg-violet-700 hover:aria-pressed:bg-violet-800":
|
||||
type === "innholdstype",
|
||||
"ring-teal-700/50 hover:bg-teal-50 aria-pressed:bg-teal-700 hover:aria-pressed:bg-teal-800":
|
||||
type === "undertema",
|
||||
},
|
||||
)}
|
||||
disabled={entry.count === 0}
|
||||
>
|
||||
{`${entry.title} (${entry.count})`}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</HGrid>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChipNav;
|
||||
@@ -1,58 +0,0 @@
|
||||
import cl from "clsx";
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "@navikt/aksel-icons";
|
||||
import { Button } from "@navikt/ds-react";
|
||||
import styles from "./Chips.module.css";
|
||||
import { useScrollRangeOpacity } from "./useScrollRange";
|
||||
|
||||
type ScrollFadeProps = {
|
||||
id: string;
|
||||
};
|
||||
/**
|
||||
* TODO: Implement button functionality, scroll snap?
|
||||
*/
|
||||
function ScrollFade({ id }: ScrollFadeProps) {
|
||||
const scrollBlock = useScrollRangeOpacity(id);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{ opacity: scrollBlock.start ? 1 : 0 }}
|
||||
aria-hidden
|
||||
className={cl(styles.shadow, styles.shadowStart)}
|
||||
/>
|
||||
<div
|
||||
style={{ opacity: scrollBlock.end ? 1 : 0 }}
|
||||
aria-hidden
|
||||
className={cl(styles.shadow, styles.shadowEnd)}
|
||||
/>
|
||||
<Button
|
||||
variant="tertiary-neutral"
|
||||
size="small"
|
||||
className={cl("absolute left-0 z-20 h-full w-12 justify-start", {
|
||||
hidden: !scrollBlock.start,
|
||||
})}
|
||||
icon={<ChevronLeftIcon fontSize="1.5rem" />}
|
||||
onClick={() => {
|
||||
const ref = document.getElementById(id);
|
||||
if (!ref) return;
|
||||
ref.scrollLeft -= 100;
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="tertiary-neutral"
|
||||
size="small"
|
||||
className={cl("absolute right-0 z-20 h-full w-12 justify-end", {
|
||||
hidden: !scrollBlock.end,
|
||||
})}
|
||||
icon={<ChevronRightIcon fontSize="1.5rem" />}
|
||||
onClick={() => {
|
||||
const ref = document.getElementById(id);
|
||||
if (!ref) return;
|
||||
ref.scrollLeft += 100;
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ScrollFade;
|
||||
@@ -1,44 +0,0 @@
|
||||
import { ChipsData, chipsDataAllQueryResponse } from "../interface";
|
||||
|
||||
type ChipsDataGroupedByTema = {
|
||||
[temaSlug: string]: ChipsData;
|
||||
};
|
||||
|
||||
export const groupByTema = (
|
||||
chipsData: chipsDataAllQueryResponse["chipsDataAll"],
|
||||
): ChipsDataGroupedByTema => {
|
||||
const chipNavData: ChipsDataGroupedByTema = {};
|
||||
for (const entry of chipsData) {
|
||||
for (const undertema of entry.undertema) {
|
||||
if (!chipNavData[undertema.temaSlug]) {
|
||||
chipNavData[undertema.temaSlug] = [
|
||||
{
|
||||
"undertema-title": undertema.title,
|
||||
"innholdstype-title": entry.innholdstype,
|
||||
},
|
||||
];
|
||||
continue;
|
||||
}
|
||||
chipNavData[undertema.temaSlug].push({
|
||||
"undertema-title": undertema.title,
|
||||
"innholdstype-title": entry.innholdstype,
|
||||
});
|
||||
}
|
||||
}
|
||||
return chipNavData;
|
||||
};
|
||||
|
||||
export const chipsDataForAllTema = (
|
||||
chipsData: chipsDataAllQueryResponse["chipsDataAll"],
|
||||
): ChipsData => {
|
||||
const chipNavData: ChipsData = [];
|
||||
for (const entry of chipsData) {
|
||||
for (const undertema of entry.undertema) {
|
||||
chipNavData.push({
|
||||
"undertema-title": undertema.title,
|
||||
"innholdstype-title": entry.innholdstype,
|
||||
});
|
||||
}
|
||||
}
|
||||
return chipNavData;
|
||||
};
|
||||
@@ -1,40 +0,0 @@
|
||||
import throttle from "lodash/throttle";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { useClientLayoutEffect } from "@navikt/ds-react";
|
||||
|
||||
/**
|
||||
* TODO: Add resizeObserver here?
|
||||
* - Will user actually resize app? If not, ignore
|
||||
*/
|
||||
export function useScrollRangeOpacity(id: string) {
|
||||
const [scrollInline, setScrollInline] = useState({
|
||||
start: false,
|
||||
end: false,
|
||||
});
|
||||
const router = useRouter();
|
||||
|
||||
useClientLayoutEffect(() => {
|
||||
const ref = document.getElementById(id);
|
||||
if (!ref) {
|
||||
return;
|
||||
}
|
||||
|
||||
const onScroll = () => {
|
||||
setScrollInline({
|
||||
start: ref.scrollLeft > 1,
|
||||
end: ref.scrollLeft < ref.scrollWidth - ref.clientWidth - 1,
|
||||
});
|
||||
};
|
||||
|
||||
const func = throttle(onScroll, 50);
|
||||
|
||||
onScroll();
|
||||
ref.addEventListener("scroll", func);
|
||||
return () => {
|
||||
ref.removeEventListener("scroll", func);
|
||||
};
|
||||
}, [router.asPath]);
|
||||
|
||||
return scrollInline;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { ParsedGPArticle } from "@/layout/god-praksis-page/interface";
|
||||
import { getArticleCounts } from "./count-articles";
|
||||
|
||||
describe("getArticleCounts", () => {
|
||||
test("should count articles correctly without filters", () => {
|
||||
const articles = [
|
||||
{ undertema: "A", innholdstype: "1" },
|
||||
{ undertema: "A", innholdstype: "2" },
|
||||
{ undertema: "B", innholdstype: "1" },
|
||||
{ undertema: "B", innholdstype: "2" },
|
||||
{ undertema: "A", innholdstype: "1" },
|
||||
] as ParsedGPArticle[];
|
||||
|
||||
const result = getArticleCounts(articles);
|
||||
|
||||
expect(result.undertemaCounts).toEqual({ A: 3, B: 2 });
|
||||
expect(result.innholdstypeCounts).toEqual({ "1": 3, "2": 2 });
|
||||
});
|
||||
|
||||
test("should count articles correctly with undertema filter", () => {
|
||||
const articles = [
|
||||
{ undertema: "A", innholdstype: "1" },
|
||||
{ undertema: "A", innholdstype: "2" },
|
||||
{ undertema: "B", innholdstype: "1" },
|
||||
{ undertema: "B", innholdstype: "2" },
|
||||
{ undertema: "A", innholdstype: "1" },
|
||||
] as ParsedGPArticle[];
|
||||
|
||||
const result = getArticleCounts(articles, "A");
|
||||
|
||||
expect(result.undertemaCounts).toEqual({ A: 3, B: 2 });
|
||||
expect(result.innholdstypeCounts).toEqual({ "1": 2, "2": 1 });
|
||||
});
|
||||
|
||||
test("should count articles correctly with innholdstype filter", () => {
|
||||
const articles = [
|
||||
{ undertema: "A", innholdstype: "1" },
|
||||
{ undertema: "A", innholdstype: "2" },
|
||||
{ undertema: "B", innholdstype: "1" },
|
||||
{ undertema: "B", innholdstype: "2" },
|
||||
{ undertema: "A", innholdstype: "1" },
|
||||
] as ParsedGPArticle[];
|
||||
|
||||
const result = getArticleCounts(articles, undefined, "1");
|
||||
|
||||
expect(result.undertemaCounts).toEqual({ A: 2, B: 1 });
|
||||
expect(result.innholdstypeCounts).toEqual({ "1": 3, "2": 2 });
|
||||
});
|
||||
|
||||
test("should count articles correctly with both filters", () => {
|
||||
const articles = [
|
||||
{ undertema: "A", innholdstype: "1" },
|
||||
{ undertema: "A", innholdstype: "2" },
|
||||
{ undertema: "B", innholdstype: "1" },
|
||||
{ undertema: "B", innholdstype: "2" },
|
||||
{ undertema: "A", innholdstype: "1" },
|
||||
] as ParsedGPArticle[];
|
||||
|
||||
const result = getArticleCounts(articles, "A", "1");
|
||||
|
||||
expect(result.undertemaCounts).toEqual({ A: 2, B: 1 });
|
||||
expect(result.innholdstypeCounts).toEqual({ "1": 2, "2": 1 });
|
||||
});
|
||||
|
||||
test("should return empty counts when articles array is empty", () => {
|
||||
const articles: ParsedGPArticle[] = [];
|
||||
const result = getArticleCounts(articles);
|
||||
expect(result.undertemaCounts).toEqual({});
|
||||
expect(result.innholdstypeCounts).toEqual({});
|
||||
});
|
||||
|
||||
test("should count articles correctly when all articles have the same undertema and innholdstype", () => {
|
||||
const articles = Array(5).fill({
|
||||
undertema: "A",
|
||||
innholdstype: "1",
|
||||
}) as ParsedGPArticle[];
|
||||
const result = getArticleCounts(articles);
|
||||
expect(result.undertemaCounts).toEqual({ A: 5 });
|
||||
expect(result.innholdstypeCounts).toEqual({ "1": 5 });
|
||||
});
|
||||
|
||||
test("should return empty counts when filters do not match any articles", () => {
|
||||
const articles = [
|
||||
{ undertema: "A", innholdstype: "1" },
|
||||
{ undertema: "B", innholdstype: "2" },
|
||||
] as ParsedGPArticle[];
|
||||
const result = getArticleCounts(articles, "C", "3");
|
||||
expect(result.undertemaCounts).toEqual({ A: 0, B: 0 });
|
||||
expect(result.innholdstypeCounts).toEqual({ 1: 0, 2: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { ParsedGPArticle } from "@/layout/god-praksis-page/interface";
|
||||
|
||||
/**
|
||||
* To make sure that all keys are present in the counts object,
|
||||
* we initialize the counts object with all possible keys.
|
||||
*/
|
||||
function initializeCounts(
|
||||
articles: ParsedGPArticle[],
|
||||
type: string,
|
||||
): Record<string, number> {
|
||||
const counts: Record<string, number> = {};
|
||||
|
||||
for (const article of articles) {
|
||||
const key = article[type];
|
||||
counts[key] = 0;
|
||||
}
|
||||
|
||||
return counts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count the number of articles for each undertema and innholdstype.
|
||||
* If filterUndertema or filterInnholdstype is provided, only count the articles that match the filter.
|
||||
* The key in Record is the undertema or innholdstype name
|
||||
*/
|
||||
export function getArticleCounts(
|
||||
articles: ParsedGPArticle[],
|
||||
filterUndertema?: string,
|
||||
filterInnholdstype?: string,
|
||||
): {
|
||||
undertemaCounts: Record<string, number>;
|
||||
innholdstypeCounts: Record<string, number>;
|
||||
} {
|
||||
const undertemaCounts: Record<string, number> = initializeCounts(
|
||||
articles,
|
||||
"undertema",
|
||||
);
|
||||
const innholdstypeCounts: Record<string, number> = initializeCounts(
|
||||
articles,
|
||||
"innholdstype",
|
||||
);
|
||||
|
||||
for (const article of articles) {
|
||||
if (!filterUndertema || article.undertema === filterUndertema) {
|
||||
innholdstypeCounts[article.innholdstype] =
|
||||
(innholdstypeCounts[article.innholdstype] || 0) + 1;
|
||||
}
|
||||
if (!filterInnholdstype || article.innholdstype === filterInnholdstype) {
|
||||
undertemaCounts[article.undertema] =
|
||||
(undertemaCounts[article.undertema] || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { undertemaCounts, innholdstypeCounts };
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
function Cube() {
|
||||
return (
|
||||
<svg
|
||||
width="720"
|
||||
height="409"
|
||||
viewBox="0 0 720 409"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="pointer-events-none absolute right-0 top-0 -z-10"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M1329.81 -664C1330.61 -664 1331.37 -663.684 1331.93 -663.122L2078.12 85.3978C2079.29 86.5681 2079.29 88.4656 2078.12 89.6359L1331.93 838.156C1331.37 838.718 1330.61 839.034 1329.82 839.034C1329.02 839.034 1328.26 838.718 1327.7 838.156L581.505 89.6359C580.945 89.0739 580.63 88.3116 580.63 87.5168C580.63 86.722 580.945 85.9598 581.505 85.3978L1327.7 -663.122C1328.26 -663.684 1329.02 -664 1329.81 -664ZM1329.81 -656.765L587.842 87.5168L1329.82 831.799L2071.79 87.5168L1329.81 -656.765ZM130.185 -208.034C130.977 -208.034 131.737 -207.718 132.297 -207.156L878.495 541.364C879.662 542.534 879.662 544.432 878.495 545.602L132.298 1294.12C131.737 1294.68 130.977 1295 130.185 1295C129.393 1295 128.633 1294.68 128.073 1294.12L-618.125 545.602C-619.292 544.432 -619.292 542.534 -618.125 541.364L128.073 -207.156C128.633 -207.718 129.393 -208.034 130.185 -208.034ZM-611.788 543.483L130.185 1287.77L872.158 543.483L130.185 -200.799L-611.788 543.483Z"
|
||||
className="fill-deepblue-50"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default Cube;
|
||||
@@ -0,0 +1,27 @@
|
||||
.heroSelector {
|
||||
--aksel-website-hero-selector-x: ;
|
||||
--aksel-website-hero-selector-y: ;
|
||||
|
||||
clip-path: circle(140% at var(--aksel-website-hero-selector-x) var(--aksel-website-hero-selector-y));
|
||||
animation: clipExpand 800ms cubic-bezier(0.3, 0, 0.15, 1);
|
||||
height: fit-content;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
@keyframes clipExpand {
|
||||
0% {
|
||||
clip-path: circle(0% at var(--aksel-website-hero-selector-x) var(--aksel-website-hero-selector-y));
|
||||
}
|
||||
|
||||
100% {
|
||||
clip-path: circle(140% at var(--aksel-website-hero-selector-x) var(--aksel-website-hero-selector-y));
|
||||
}
|
||||
}
|
||||
|
||||
.heroGradient {
|
||||
background: linear-gradient(85deg, #5eead4 0%, #99f6e4 48%, #99f6e4 100%);
|
||||
}
|
||||
|
||||
.heroGradientOpen {
|
||||
background: linear-gradient(265deg, #14b8a6 0%, #2dd4bf 48.5%, #2dd4bf 100%);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { BodyLong, Box, Heading } from "@navikt/ds-react";
|
||||
import Cube from "@/layout/god-praksis-page/hero/Cube";
|
||||
import HeroSelect from "@/layout/god-praksis-page/hero/HeroSelect";
|
||||
import { GpTemaT, HeroNavT } from "@/layout/god-praksis-page/interface";
|
||||
|
||||
/**
|
||||
* TODO:
|
||||
* - Handle dynamic resize better than vw fontsize?
|
||||
* - - Can flex-shrink be used here?
|
||||
*
|
||||
* - Replace liste-elements with chips like in figma
|
||||
* - Better aria-label for <nav>
|
||||
*/
|
||||
function Hero({ tema, heroNav }: { tema: GpTemaT | null } & HeroNavT) {
|
||||
return (
|
||||
<Box
|
||||
background="surface-alt-3-subtle"
|
||||
borderRadius="large"
|
||||
paddingInline={{ xs: "4", lg: "10" }}
|
||||
paddingBlock="10 6"
|
||||
className="relative isolate overflow-clip bg-gradient-to-tr from-deepblue-200 via-deepblue-100 to-deepblue-100 transition-[height]"
|
||||
>
|
||||
<Cube />
|
||||
<HeroSelect heroNav={heroNav} currentSlug={tema?.slug} />
|
||||
<Heading level="1" size="xlarge" className="z-10 mt-2 text-aksel-heading">
|
||||
{tema?.title ?? "Alle tema"}
|
||||
</Heading>
|
||||
{tema?.description && (
|
||||
<BodyLong size="large" className="relative z-10 mt-4">
|
||||
{tema.description}
|
||||
</BodyLong>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default Hero;
|
||||
@@ -0,0 +1,62 @@
|
||||
import cl from "clsx";
|
||||
|
||||
type CubeProps = {
|
||||
variant?: "light" | "dark";
|
||||
};
|
||||
|
||||
function Cube({ variant = "light" }: CubeProps) {
|
||||
return (
|
||||
<div
|
||||
className={cl(
|
||||
"pointer-events-none absolute inset-0 z-10 overflow-clip rounded-large",
|
||||
{
|
||||
"text-teal-300": variant === "light",
|
||||
"text-teal-400": variant === "dark",
|
||||
},
|
||||
)}
|
||||
>
|
||||
<svg
|
||||
className="absolute right-0 -z-10"
|
||||
width="390"
|
||||
height="560"
|
||||
viewBox="0 0 390 560"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g clipPath="url(#clip0_4792_2769)">
|
||||
<path
|
||||
d="M414.265 -22.7451L296.355 95.1649L414.089 95.1649L531.999 -22.7451L414.265 -22.7451Z"
|
||||
stroke="#5EEAD4"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M343.496 119.165L225.645 237.016L343.496 354.867L461.347 237.016L343.496 119.165Z"
|
||||
stroke="#5EEAD4"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M390.754 378.867L272.844 496.777L390.577 496.777L508.487 378.867L390.754 378.867Z"
|
||||
stroke="#5EEAD4"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M202.133 638.686L84.2234 520.777L201.956 520.777L319.867 638.686L202.133 638.686Z"
|
||||
stroke="#5EEAD4"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_4792_2769">
|
||||
<rect width="390" height="580" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Cube;
|
||||
@@ -1,71 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import { useRef } from "react";
|
||||
import { ChevronDownIcon } from "@navikt/aksel-icons";
|
||||
import { BodyShort, Chips, Modal } from "@navikt/ds-react";
|
||||
import { HeroNavT } from "@/layout/god-praksis-page/interface";
|
||||
|
||||
function HeroSelect({
|
||||
heroNav,
|
||||
currentSlug,
|
||||
}: {
|
||||
heroNav: HeroNavT["heroNav"];
|
||||
currentSlug?: string;
|
||||
}) {
|
||||
const modalRef = useRef<HTMLDialogElement>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<BodyShort
|
||||
size="large"
|
||||
as="button"
|
||||
className="flex items-center gap-05 rounded-full bg-surface-subtle py-05 pl-4 pr-2 shadow-xsmall focus:outline-none focus-visible:shadow-focus"
|
||||
onClick={() => modalRef.current?.showModal()}
|
||||
>
|
||||
Tema
|
||||
<ChevronDownIcon aria-hidden className="shrink-0 text-2xl" />
|
||||
</BodyShort>
|
||||
|
||||
<Modal
|
||||
ref={modalRef}
|
||||
header={{ heading: "Tema" }}
|
||||
width={500}
|
||||
closeOnBackdropClick
|
||||
>
|
||||
<Modal.Body>
|
||||
<nav aria-label="hovedmeny">
|
||||
<Chips className="justify-center">
|
||||
<Chips.Toggle
|
||||
as={Link}
|
||||
href="/gp"
|
||||
onClick={() => {
|
||||
modalRef.current?.close();
|
||||
}}
|
||||
checkmark={false}
|
||||
variant="neutral"
|
||||
selected={!currentSlug}
|
||||
>
|
||||
Alle tema
|
||||
</Chips.Toggle>
|
||||
{heroNav.map((x) => (
|
||||
<Chips.Toggle
|
||||
key={x.slug}
|
||||
as={Link}
|
||||
href={`/gp/${x.slug}`}
|
||||
onClick={() => {
|
||||
modalRef.current?.close();
|
||||
}}
|
||||
checkmark={false}
|
||||
variant="neutral"
|
||||
selected={currentSlug === x.slug}
|
||||
>
|
||||
{x.title}
|
||||
</Chips.Toggle>
|
||||
))}
|
||||
</Chips>
|
||||
</nav>
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
export default HeroSelect;
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { AkselTheme } from "@/sb-util";
|
||||
import Hero from "./IntroHero";
|
||||
|
||||
const meta = {
|
||||
title: "Layout/god-praksis/hero/StaticHero",
|
||||
component: Hero,
|
||||
tags: ["autodocs"],
|
||||
decorators: [AkselTheme],
|
||||
} satisfies Meta<typeof Hero>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const NoChildren: Story = {
|
||||
args: {
|
||||
title: "God praksis",
|
||||
children: null,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import cl from "clsx";
|
||||
import { Box, Heading } from "@navikt/ds-react";
|
||||
import Cube from "@/layout/god-praksis-page/hero/HeroCube";
|
||||
import styles from "../Hero.module.css";
|
||||
|
||||
type GpIntroHeroProps = {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
function IntroHero({ title, children }: GpIntroHeroProps) {
|
||||
return (
|
||||
<Box
|
||||
background="surface-alt-3-subtle"
|
||||
borderRadius="large"
|
||||
paddingInline={{ xs: "4", lg: "10" }}
|
||||
paddingBlock={{ xs: "6", lg: "10" }}
|
||||
className={cl(
|
||||
"relative isolate ring-1 ring-inset ring-teal-400",
|
||||
styles.heroGradient,
|
||||
)}
|
||||
>
|
||||
<Cube />
|
||||
<Heading level="1" size="xlarge" className="relative z-10 mt-2">
|
||||
{title}
|
||||
</Heading>
|
||||
<div className="relative z-10">{children}</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default IntroHero;
|
||||
+19
-10
@@ -1,9 +1,9 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { AkselTheme } from "@/sb-util";
|
||||
import Hero from "./Hero";
|
||||
import Hero from "./TemaHero";
|
||||
|
||||
const meta = {
|
||||
title: "Layout/god-praksis/Hero",
|
||||
title: "Layout/god-praksis/hero/TemaHero",
|
||||
component: Hero,
|
||||
tags: ["autodocs"],
|
||||
decorators: [AkselTheme],
|
||||
@@ -16,35 +16,45 @@ export const Description: Story = {
|
||||
args: {
|
||||
heroNav: [
|
||||
{
|
||||
hasRefs: true,
|
||||
slug: "b",
|
||||
title: "Brukerinnsikt",
|
||||
},
|
||||
{
|
||||
hasRefs: true,
|
||||
slug: "c",
|
||||
title: "Produktutvikling",
|
||||
},
|
||||
{
|
||||
hasRefs: true,
|
||||
slug: "universell-utforming",
|
||||
title: "Universell utforming",
|
||||
},
|
||||
{
|
||||
hasRefs: true,
|
||||
slug: "c",
|
||||
title: "Design",
|
||||
},
|
||||
{
|
||||
hasRefs: true,
|
||||
slug: "c",
|
||||
title: "Innholdsarbeid",
|
||||
},
|
||||
{
|
||||
hasRefs: true,
|
||||
slug: "c3214",
|
||||
title: "Interne flater",
|
||||
},
|
||||
{
|
||||
slug: "c321",
|
||||
title: "nav.no",
|
||||
},
|
||||
{
|
||||
slug: "c",
|
||||
title: "Skjemaer",
|
||||
},
|
||||
{
|
||||
slug: "c123",
|
||||
title: "Fremragende team",
|
||||
},
|
||||
{
|
||||
slug: "c1234",
|
||||
title: "Produktutviklingsmodellen",
|
||||
},
|
||||
],
|
||||
tema: {
|
||||
slug: "universell-utforming",
|
||||
@@ -64,7 +74,6 @@ export const NoDescription: Story = {
|
||||
args: {
|
||||
heroNav: [
|
||||
{
|
||||
hasRefs: true,
|
||||
slug: "universell-utforming",
|
||||
title: "Universell utforming",
|
||||
},
|
||||
@@ -77,7 +86,6 @@ export const LongDescription: Story = {
|
||||
args: {
|
||||
heroNav: [
|
||||
{
|
||||
hasRefs: true,
|
||||
slug: "universell-utforming",
|
||||
title: "Universell utforming",
|
||||
},
|
||||
@@ -89,6 +97,7 @@ export const LongDescription: Story = {
|
||||
{ description: "Lorem ipsum", title: "WCAG" },
|
||||
{ description: "Lorem ipsum", title: "Testing" },
|
||||
],
|
||||
|
||||
description: `Alle som jobber med produktutvikling i NAV sitter på kunnskap og
|
||||
erfaring som er nyttig for andre. Derfor deler vi god praksis med
|
||||
hverandre her.
|
||||
@@ -0,0 +1,137 @@
|
||||
import cl from "clsx";
|
||||
import { CSSProperties, useCallback, useState } from "react";
|
||||
import { Box } from "@navikt/ds-react";
|
||||
import { useEscapeKeydown } from "@/hooks/useEscapeKeydown";
|
||||
import Cube from "@/layout/god-praksis-page/hero/HeroCube";
|
||||
import { HeroCardList } from "@/layout/god-praksis-page/hero/tema-hero/parts/HeroCardList";
|
||||
import { HeroIntro } from "@/layout/god-praksis-page/hero/tema-hero/parts/HeroIntro";
|
||||
import { HeroSelectButton } from "@/layout/god-praksis-page/hero/tema-hero/parts/HeroSelectButton";
|
||||
import { GpTemaT, HeroNavT } from "@/layout/god-praksis-page/interface";
|
||||
import styles from "../Hero.module.css";
|
||||
|
||||
type GpTemaHeroProps = { tema: GpTemaT | null } & HeroNavT;
|
||||
|
||||
function TemaHero({ tema, heroNav }: GpTemaHeroProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const [dialogHeight, setDialogHeight] = useState(0);
|
||||
const [wrapperHeight, setWrapperHeight] = useState(0);
|
||||
const [animationRef, setAnimationRef] = useState({ x: 0, y: 0 });
|
||||
|
||||
const [openDialogButton, setOpenDialogButton] = useState<HTMLElement | null>(
|
||||
null,
|
||||
);
|
||||
const [closeDialogButton, setCloseDialogButton] =
|
||||
useState<HTMLElement | null>(null);
|
||||
|
||||
const inlineStyles: CSSProperties = {
|
||||
"--aksel-website-hero-selector-x": animationRef.x + "px",
|
||||
"--aksel-website-hero-selector-y": animationRef.y + "px",
|
||||
};
|
||||
|
||||
const handleOpen = (
|
||||
e: React.MouseEvent<HTMLElement, globalThis.MouseEvent>,
|
||||
) => {
|
||||
closeDialogButton?.focus();
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
|
||||
setAnimationRef({
|
||||
x: e.currentTarget.offsetLeft + rect.width / 2,
|
||||
y: e.currentTarget.offsetTop + rect.height / 2,
|
||||
});
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setOpen(false);
|
||||
openDialogButton?.focus();
|
||||
}, [openDialogButton]);
|
||||
|
||||
useEscapeKeydown(handleClose, [handleClose]);
|
||||
|
||||
/**
|
||||
* Tries to equal height for both wrapper and absolute-element
|
||||
* by increasing the margin-bottom of the wrapper
|
||||
*/
|
||||
const getMargin = () => {
|
||||
if (!open) return 0;
|
||||
const height = dialogHeight ? dialogHeight - wrapperHeight : 0;
|
||||
if (height > 0) return height;
|
||||
return 0;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
background="surface-alt-3-subtle"
|
||||
borderRadius="large"
|
||||
paddingInline={{ xs: "4", lg: "10" }}
|
||||
paddingBlock="10 6"
|
||||
className={cl(
|
||||
"relative ring-1 ring-teal-400 transition-[margin] duration-500",
|
||||
styles.heroGradient,
|
||||
)}
|
||||
style={{
|
||||
marginBottom: getMargin(),
|
||||
transitionTimingFunction: open
|
||||
? "cubic-bezier(0.3, 0, 0.15, 1)"
|
||||
: "cubic-bezier(0, 0.3, 0.15, 1)",
|
||||
}}
|
||||
ref={(el) => {
|
||||
setWrapperHeight(el?.getBoundingClientRect().height || 0);
|
||||
}}
|
||||
>
|
||||
<Cube />
|
||||
<HeroSelectButton
|
||||
onClick={handleOpen}
|
||||
expanded={open}
|
||||
ref={setOpenDialogButton}
|
||||
hidden={open}
|
||||
/>
|
||||
<HeroIntro
|
||||
title={tema?.title}
|
||||
description={tema?.description}
|
||||
hidden={open}
|
||||
image={tema?.image}
|
||||
/>
|
||||
|
||||
<Box
|
||||
borderRadius="large"
|
||||
paddingInline={{ xs: "4", lg: "10" }}
|
||||
paddingBlock="10 6"
|
||||
className={cl(
|
||||
"absolute inset-0 z-20 overflow-clip ring-1 ring-teal-500",
|
||||
styles.heroSelector,
|
||||
styles.heroGradientOpen,
|
||||
{
|
||||
hidden: !open,
|
||||
},
|
||||
)}
|
||||
shadow="medium"
|
||||
style={inlineStyles}
|
||||
role="dialog"
|
||||
aria-labelledby="tema-selector-title"
|
||||
aria-modal="false"
|
||||
ref={(el) => {
|
||||
setDialogHeight(el?.getBoundingClientRect().height || 0);
|
||||
}}
|
||||
>
|
||||
<Cube variant="dark" />
|
||||
|
||||
<HeroSelectButton
|
||||
onClick={handleClose}
|
||||
expanded={true}
|
||||
hidden={false}
|
||||
ref={setCloseDialogButton}
|
||||
/>
|
||||
|
||||
<HeroCardList
|
||||
currentSlug={tema?.slug}
|
||||
heroNav={heroNav}
|
||||
setOpen={setOpen}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default TemaHero;
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import GpHeroCard from "@/layout/god-praksis-page/cards/GpHeroCard";
|
||||
import { HeroNavT } from "@/layout/god-praksis-page/interface";
|
||||
|
||||
type HeroListProps = {
|
||||
heroNav: HeroNavT["heroNav"];
|
||||
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
currentSlug?: string;
|
||||
};
|
||||
|
||||
export function HeroCardList({ heroNav, setOpen, currentSlug }: HeroListProps) {
|
||||
return (
|
||||
<nav aria-label="Temavelger" className="relative z-10 mt-5">
|
||||
<ul className="flex flex-col flex-wrap gap-2 md:flex-row lg:gap-4">
|
||||
<li>
|
||||
<GpHeroCard href="gp" compact>
|
||||
Alle tema
|
||||
</GpHeroCard>
|
||||
</li>
|
||||
{heroNav.map((tema, idx) => (
|
||||
<li key={tema.slug + idx}>
|
||||
<GpHeroCard
|
||||
href={`gp/${tema.slug}`}
|
||||
image={
|
||||
currentSlug === tema.slug ? tema.imageInverted : tema.image
|
||||
}
|
||||
compact
|
||||
aria-current={currentSlug === tema.slug ? "page" : undefined}
|
||||
onClick={() => {
|
||||
// Since navigating to same page will not trigger a new render, we have to manually close it
|
||||
currentSlug === tema.slug && setOpen(false);
|
||||
}}
|
||||
>
|
||||
{tema.title}
|
||||
</GpHeroCard>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
import Image from "next/legacy/image";
|
||||
import { BodyLong, Heading } from "@navikt/ds-react";
|
||||
import { FallbackPictogram } from "@/layout/god-praksis-page/FallbackPictogram";
|
||||
import { urlFor } from "@/sanity/interface";
|
||||
|
||||
type HeroIntroProps = {
|
||||
title?: string;
|
||||
description?: string;
|
||||
hidden?: boolean;
|
||||
image?: any;
|
||||
};
|
||||
|
||||
export function HeroIntro({
|
||||
title,
|
||||
description,
|
||||
hidden,
|
||||
image,
|
||||
}: HeroIntroProps) {
|
||||
return (
|
||||
<div className="relative z-10 mt-4" aria-hidden={hidden}>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* To avoid having duplicate images for different backgrounds, we up the contrast instead */}
|
||||
<div className="relative my-auto size-8 shrink-0 contrast-200 md:size-12">
|
||||
{image ? (
|
||||
<Image
|
||||
src={urlFor(image).auto("format").url()}
|
||||
decoding="sync"
|
||||
layout="fill"
|
||||
objectFit="contain"
|
||||
aria-hidden
|
||||
priority
|
||||
/>
|
||||
) : (
|
||||
<FallbackPictogram />
|
||||
)}
|
||||
</div>
|
||||
<Heading level="1" size="xlarge" className="z-10">
|
||||
{title}
|
||||
</Heading>
|
||||
</div>
|
||||
{description && (
|
||||
<BodyLong
|
||||
size="large"
|
||||
className="relative z-10 mt-4 rounded-xlarge backdrop-blur-[1px]"
|
||||
>
|
||||
{description}
|
||||
</BodyLong>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
import cl from "clsx";
|
||||
import { forwardRef } from "react";
|
||||
import { ChevronDownUpIcon, ChevronUpDownIcon } from "@navikt/aksel-icons";
|
||||
import { BodyShort } from "@navikt/ds-react";
|
||||
|
||||
type HeroSelectButtonProps = {
|
||||
onClick: (e: React.MouseEvent<HTMLElement, MouseEvent>) => void;
|
||||
expanded: boolean;
|
||||
hidden?: boolean;
|
||||
};
|
||||
|
||||
export const HeroSelectButton = forwardRef<
|
||||
HTMLButtonElement,
|
||||
HeroSelectButtonProps
|
||||
>(({ onClick, expanded, hidden }: HeroSelectButtonProps, ref) => {
|
||||
return (
|
||||
<BodyShort
|
||||
size="large"
|
||||
as="button"
|
||||
className={cl(
|
||||
"relative z-10 flex items-center gap-05 rounded-full py-1 pl-4 pr-2 shadow-xsmall focus:outline-none focus-visible:shadow-focus",
|
||||
{
|
||||
"bg-teal-800 text-text-on-inverted focus-visible:shadow-focus-gap":
|
||||
expanded,
|
||||
"bg-teal-50 text-text-default": !expanded,
|
||||
},
|
||||
)}
|
||||
onClick={onClick}
|
||||
aria-expanded={expanded}
|
||||
ref={ref}
|
||||
aria-hidden={hidden}
|
||||
tabIndex={hidden ? -1 : 0}
|
||||
>
|
||||
Tema
|
||||
{expanded ? (
|
||||
<ChevronDownUpIcon aria-hidden className="shrink-0 text-2xl" />
|
||||
) : (
|
||||
<ChevronUpDownIcon aria-hidden className="shrink-0 text-2xl" />
|
||||
)}
|
||||
</BodyShort>
|
||||
);
|
||||
});
|
||||
-82
@@ -1,82 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { GpGroupedArticlesT } from "../interface";
|
||||
import { getArticleList } from "./get-article-list";
|
||||
|
||||
const baseArticle = (
|
||||
id: string,
|
||||
publishedAt: string | null,
|
||||
): GpGroupedArticlesT[number] => ({
|
||||
innholdstype: "type1",
|
||||
undertema: "tema1",
|
||||
article: {
|
||||
_id: id,
|
||||
heading: `Article ${id}`,
|
||||
ingress: "ingress",
|
||||
undertema: ["tema1", "tema2"],
|
||||
innholdstype: "type1",
|
||||
slug: "url",
|
||||
publishedAt,
|
||||
currentUndertema: null,
|
||||
},
|
||||
});
|
||||
|
||||
describe("getArticleList function", () => {
|
||||
test("should return an empty array when no articles match the filters", () => {
|
||||
const articles = [
|
||||
baseArticle("1", "2022-01-01"),
|
||||
baseArticle("2", "2022-01-02"),
|
||||
];
|
||||
const result = getArticleList(articles, "type2", "tema2");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("should correctly filter articles by innholdstype", () => {
|
||||
const articles = [
|
||||
baseArticle("2", "2022-01-02"),
|
||||
baseArticle("1", "2022-01-01"),
|
||||
];
|
||||
const result = getArticleList(articles, "type1", null);
|
||||
expect(result).toEqual(articles);
|
||||
});
|
||||
|
||||
test("should correctly filter articles by undertema", () => {
|
||||
const articles = [
|
||||
baseArticle("2", "2022-01-02"),
|
||||
baseArticle("1", "2022-01-01"),
|
||||
];
|
||||
const result = getArticleList(articles, null, "tema1");
|
||||
expect(result).toEqual(articles);
|
||||
});
|
||||
|
||||
test("should correctly sort articles by publishedAt", () => {
|
||||
const articles = [
|
||||
baseArticle("2", "2022-01-01"),
|
||||
baseArticle("3", "2022-01-03"),
|
||||
baseArticle("4", "2022-01-04"),
|
||||
baseArticle("1", "2022-01-02"),
|
||||
];
|
||||
const result = getArticleList(articles, "type1", "tema1");
|
||||
expect(result).toEqual([
|
||||
baseArticle("4", "2022-01-04"),
|
||||
baseArticle("3", "2022-01-03"),
|
||||
baseArticle("1", "2022-01-02"),
|
||||
baseArticle("2", "2022-01-01"),
|
||||
]);
|
||||
});
|
||||
|
||||
test("should correctly limit the number of articles to 9", () => {
|
||||
const articles = Array.from({ length: 10 }, (_, i) =>
|
||||
baseArticle(String(i + 1), `2022-01-0${i + 1}`),
|
||||
);
|
||||
const result = getArticleList(articles, "type1", "tema1");
|
||||
expect(result.length).toEqual(9);
|
||||
});
|
||||
|
||||
test("should return all articles when no filter is set", () => {
|
||||
const articles = Array.from({ length: 10 }, (_, i) =>
|
||||
baseArticle(String(i + 1), `2022-01-0${i + 1}`),
|
||||
);
|
||||
const result = getArticleList(articles, null, null);
|
||||
expect(result.length).toEqual(9);
|
||||
});
|
||||
});
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
import { GP_INITIAL_ARTICLES, GpGroupedArticlesT } from "../interface";
|
||||
|
||||
export function getArticleList(
|
||||
articles: GpGroupedArticlesT,
|
||||
innholdstype: string | null,
|
||||
undertema: string | null,
|
||||
): GpGroupedArticlesT {
|
||||
return articles
|
||||
.filter(
|
||||
(article) =>
|
||||
(innholdstype ? article.innholdstype === innholdstype : true) &&
|
||||
(undertema ? article.undertema === undertema : true),
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.article?.publishedAt || "1970-01-01").getTime() -
|
||||
new Date(a.article?.publishedAt || "1970-01-01").getTime(),
|
||||
)
|
||||
.slice(0, GP_INITIAL_ARTICLES);
|
||||
}
|
||||
-146
@@ -1,146 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { GpArticleT } from "../interface";
|
||||
import { groupArticles } from "./group-articles";
|
||||
|
||||
const baseArticle = (id: string): GpArticleT => ({
|
||||
_id: id,
|
||||
heading: `Article ${id}`,
|
||||
ingress: "ingress",
|
||||
undertema: ["tema1", "tema2"],
|
||||
innholdstype: "metode",
|
||||
slug: "url",
|
||||
publishedAt: null,
|
||||
currentUndertema: null,
|
||||
});
|
||||
|
||||
describe("groupArticles function", () => {
|
||||
test("should return an empty array when both innholdstyper and undertema are empty", () => {
|
||||
const result = groupArticles({
|
||||
initialInnholdstype: [],
|
||||
initialUndertema: [],
|
||||
});
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("should correctly group articles by innholdstype", () => {
|
||||
const initialInnholdstype = [
|
||||
{
|
||||
title: "type1",
|
||||
articles: [baseArticle("1"), baseArticle("2")],
|
||||
},
|
||||
];
|
||||
const result = groupArticles({ initialInnholdstype, initialUndertema: [] });
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
innholdstype: "type1",
|
||||
undertema: null,
|
||||
article: baseArticle("1"),
|
||||
},
|
||||
{
|
||||
innholdstype: "type1",
|
||||
undertema: null,
|
||||
article: baseArticle("2"),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("should correctly group articles by undertema", () => {
|
||||
const initialUndertema = [
|
||||
{
|
||||
title: "tema1",
|
||||
articles: [baseArticle("1"), baseArticle("2")],
|
||||
},
|
||||
];
|
||||
const result = groupArticles({ initialInnholdstype: [], initialUndertema });
|
||||
expect(result).toEqual([
|
||||
{
|
||||
innholdstype: null,
|
||||
undertema: "tema1",
|
||||
article: baseArticle("1"),
|
||||
},
|
||||
{
|
||||
innholdstype: null,
|
||||
undertema: "tema1",
|
||||
article: baseArticle("2"),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("should correctly group overlapping articles", () => {
|
||||
const initialInnholdstype = [
|
||||
{
|
||||
title: "type1",
|
||||
articles: [baseArticle("1")],
|
||||
},
|
||||
];
|
||||
const initialUndertema = [
|
||||
{
|
||||
title: "tema1",
|
||||
articles: [baseArticle("1")],
|
||||
},
|
||||
];
|
||||
const result = groupArticles({ initialInnholdstype, initialUndertema });
|
||||
expect(result).toEqual([
|
||||
{
|
||||
innholdstype: "type1",
|
||||
undertema: "tema1",
|
||||
article: baseArticle("1"),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("should return an empty array when innholdstyper and undertema have no articles", () => {
|
||||
const result = groupArticles({
|
||||
initialInnholdstype: [{ title: "type1", articles: [] }],
|
||||
initialUndertema: [{ title: "tema1", articles: [] }],
|
||||
});
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("should correctly group multiple articles from multiple innholdstyper and undertema", () => {
|
||||
const initialInnholdstype = [
|
||||
{
|
||||
title: "type1",
|
||||
articles: [baseArticle("1"), baseArticle("2")],
|
||||
},
|
||||
{
|
||||
title: "type2",
|
||||
articles: [baseArticle("3"), baseArticle("4")],
|
||||
},
|
||||
];
|
||||
const initialUndertema = [
|
||||
{
|
||||
title: "tema1",
|
||||
articles: [baseArticle("1"), baseArticle("2")],
|
||||
},
|
||||
{
|
||||
title: "tema2",
|
||||
articles: [baseArticle("3"), baseArticle("4")],
|
||||
},
|
||||
];
|
||||
const result = groupArticles({ initialInnholdstype, initialUndertema });
|
||||
expect(result).toEqual([
|
||||
{
|
||||
innholdstype: "type1",
|
||||
undertema: "tema1",
|
||||
article: baseArticle("1"),
|
||||
},
|
||||
{
|
||||
innholdstype: "type1",
|
||||
undertema: "tema1",
|
||||
article: baseArticle("2"),
|
||||
},
|
||||
{
|
||||
innholdstype: "type2",
|
||||
undertema: "tema2",
|
||||
article: baseArticle("3"),
|
||||
},
|
||||
{
|
||||
innholdstype: "type2",
|
||||
undertema: "tema2",
|
||||
article: baseArticle("4"),
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
-51
@@ -1,51 +0,0 @@
|
||||
import {
|
||||
GpArticleT,
|
||||
GpGroupedArticlesInputT,
|
||||
GpGroupedArticlesT,
|
||||
} from "../interface";
|
||||
|
||||
/**
|
||||
* De-duplicates re-occuring articles and maps the to the matching innholdstype and undertema
|
||||
* This reduces amount for data sent to user on load.
|
||||
* TODO:
|
||||
* - Returns 0 articles for url /gp/brukerinnsikt?undertema=Innsikt&innholdstype=How-to
|
||||
* - - Innnholdstype gets 1 less article resovled from initial query
|
||||
*/
|
||||
export function groupArticles({
|
||||
initialInnholdstype = [],
|
||||
initialUndertema = [],
|
||||
}: GpGroupedArticlesInputT): GpGroupedArticlesT {
|
||||
const articleMap = new Map<
|
||||
string,
|
||||
{
|
||||
innholdstype: string | null;
|
||||
undertema: string | null;
|
||||
article: GpArticleT;
|
||||
}
|
||||
>();
|
||||
|
||||
initialInnholdstype.forEach((innholdstype) => {
|
||||
innholdstype.articles.forEach((article) => {
|
||||
articleMap.set(article._id, {
|
||||
article,
|
||||
innholdstype: innholdstype.title,
|
||||
undertema: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
initialUndertema.forEach((tema) => {
|
||||
tema.articles.forEach((article) => {
|
||||
const found = articleMap.get(article._id);
|
||||
|
||||
found
|
||||
? articleMap.set(article._id, { ...found, undertema: tema.title })
|
||||
: articleMap.set(article._id, {
|
||||
undertema: tema.title,
|
||||
article,
|
||||
innholdstype: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
return [...articleMap.values()];
|
||||
}
|
||||
@@ -1,23 +1,13 @@
|
||||
import { groq } from "next-sanity";
|
||||
|
||||
/**
|
||||
* `GP_LAZYLOADED_ARTICLES`: Number of articles loaded when clicking "load more"
|
||||
* `GP_INITIAL_ARTICLES`: Initial list-length of articles fetched in getStaticProps
|
||||
*/
|
||||
export const GP_LAZYLOADED_ARTICLES = 3;
|
||||
export const GP_INITIAL_ARTICLES = 9;
|
||||
import { SanityImageSource } from "@sanity/image-url/lib/types/types";
|
||||
import { SanityDocument } from "sanity";
|
||||
import { ArticleT } from "@/cms/frontpage-blocks/latest-articles/Card";
|
||||
|
||||
export type GpTemaT = {
|
||||
title: string;
|
||||
slug: string;
|
||||
description?: string;
|
||||
undertema: { title: string; description: string }[];
|
||||
};
|
||||
|
||||
export type GpInnholdstypeT = {
|
||||
title: string;
|
||||
description?: string;
|
||||
hasRefs: boolean;
|
||||
image?: SanityImageSource;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -27,226 +17,43 @@ export type HeroNavT = {
|
||||
heroNav: {
|
||||
title: string;
|
||||
slug: string;
|
||||
hasRefs: boolean;
|
||||
}[];
|
||||
};
|
||||
|
||||
export const heroNavQuery = groq`
|
||||
"heroNav": *[_type == "gp.tema"]{
|
||||
title,
|
||||
"slug": slug.current,
|
||||
"hasRefs": count(*[_type=="aksel_artikkel"
|
||||
&& (^._id in undertema[]->tema._ref)]) > 0
|
||||
}
|
||||
`;
|
||||
|
||||
export type heroNavQueryResponse = {
|
||||
heroNav: {
|
||||
title: string;
|
||||
slug: string;
|
||||
hasRefs: boolean;
|
||||
image?: SanityImageSource;
|
||||
imageInverted?: SanityImageSource;
|
||||
}[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Chips navigation
|
||||
* Tema-pages
|
||||
*/
|
||||
export type ChipsData = {
|
||||
"undertema-title": string;
|
||||
"innholdstype-title": string;
|
||||
}[];
|
||||
|
||||
export const chipsDataAllQuery = groq`
|
||||
"chipsDataAll": *[_type == "aksel_artikkel" && defined(innholdstype) && defined(undertema)]{
|
||||
"innholdstype": innholdstype->title,
|
||||
"undertema": undertema[]->{title, "temaSlug": tema->slug.current}
|
||||
}`;
|
||||
|
||||
export type chipsDataAllQueryResponse = {
|
||||
chipsDataAll: {
|
||||
innholdstype: string;
|
||||
undertema: { title: string; temaSlug: string }[];
|
||||
}[];
|
||||
};
|
||||
|
||||
export const chipsUndertemaQuery = groq`
|
||||
"chipsUndertema": *[_type == "gp.tema.undertema"]{
|
||||
title,
|
||||
"tema": tema->slug.current,
|
||||
"count": count(*[_type=="aksel_artikkel"
|
||||
&& ^._id in undertema[]._ref])
|
||||
}`;
|
||||
export type chipsUndertemaQueryResponse = {
|
||||
chipsUndertema: {
|
||||
title: string;
|
||||
tema: string;
|
||||
count: number;
|
||||
}[];
|
||||
};
|
||||
|
||||
export const chipsInnholdstypeQuery = groq`
|
||||
"chipsInnholdstype": *[_type == "gp.tema.undertema"] {
|
||||
title,
|
||||
"slug": tema->slug.current,
|
||||
"types": *[_type== "gp.innholdstype"] {
|
||||
title,
|
||||
"count": count(*[_type == "aksel_artikkel"
|
||||
&& (^._id == innholdstype._ref ) && (^.^._id in undertema[]._ref)])
|
||||
}
|
||||
}`;
|
||||
export type chipsInnholdstypeQueryResponse = {
|
||||
chipsInnholdstype: {
|
||||
title: string;
|
||||
slug: string;
|
||||
types: {
|
||||
title: string;
|
||||
count: number;
|
||||
}[];
|
||||
}[];
|
||||
};
|
||||
|
||||
export type GpGroupedArticlesT = {
|
||||
innholdstype: string | null;
|
||||
undertema: string | null;
|
||||
article: GpArticleT;
|
||||
}[];
|
||||
|
||||
export type GpGroupedArticlesInputT = {
|
||||
initialInnholdstype?: {
|
||||
title: string;
|
||||
articles: GpArticleT[];
|
||||
}[];
|
||||
|
||||
initialUndertema?: {
|
||||
title: string;
|
||||
articles: GpArticleT[];
|
||||
}[];
|
||||
};
|
||||
|
||||
export type GpEntryPageProps = HeroNavT & {
|
||||
tema: GpTemaT | null;
|
||||
} & {
|
||||
initialArticles: GpGroupedArticlesT;
|
||||
} & { chipsData: ChipsData };
|
||||
|
||||
export const temaQuery = groq`
|
||||
"tema": *[_type == "gp.tema" && slug.current == $slug][0]{
|
||||
...,
|
||||
"slug": slug.current,
|
||||
"undertema": *[_type == "gp.tema.undertema" && tema->slug.current == $slug]{
|
||||
title,
|
||||
description
|
||||
}
|
||||
}`;
|
||||
|
||||
export type temaQueryResponse = {
|
||||
tema: {
|
||||
[key: string]: any;
|
||||
export type GpSlugQueryResponse = {
|
||||
tema: SanityDocument & {
|
||||
title: string;
|
||||
slug: string;
|
||||
description?: string;
|
||||
undertema: { title: string; description: string }[];
|
||||
seo?: ArticleT["seo"];
|
||||
image: SanityImageSource;
|
||||
};
|
||||
};
|
||||
|
||||
export const baseGpArticleData = groq`{
|
||||
_id,
|
||||
heading,
|
||||
ingress,
|
||||
"undertema": undertema[]->title,
|
||||
"innholdstype": innholdstype->title,
|
||||
"slug": slug.current,
|
||||
publishedAt
|
||||
}
|
||||
`;
|
||||
|
||||
export type GpArticleT = {
|
||||
_id: string;
|
||||
heading: string;
|
||||
ingress: string;
|
||||
undertema: string[];
|
||||
slug: string;
|
||||
innholdstype: string | null;
|
||||
publishedAt: string | null;
|
||||
currentUndertema: string | null;
|
||||
};
|
||||
|
||||
export const initialGpMainPageArticles = groq`
|
||||
"initialInnholdstype": *[_type == "gp.innholdstype"]{
|
||||
title,
|
||||
"articles": *[_type == "aksel_artikkel" && innholdstype._ref == ^._id]| order(publishedAt desc)[0...${GP_INITIAL_ARTICLES}]${baseGpArticleData}
|
||||
}`;
|
||||
|
||||
export type initialGpMainPageArticlesResponse = {
|
||||
initialInnholdstype: {
|
||||
heroNav: {
|
||||
title: string;
|
||||
articles: GpArticleT[];
|
||||
slug: string;
|
||||
image: SanityImageSource;
|
||||
imageInverted: SanityImageSource;
|
||||
}[];
|
||||
articles: {
|
||||
_id: string;
|
||||
heading: string;
|
||||
publishedAt: string;
|
||||
description: string;
|
||||
undertema: { title: string; temaTitle: string }[];
|
||||
innholdstype: string;
|
||||
slug: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
export const initialTemaPageArticles = groq`
|
||||
"initialUndertema": *[_type == "gp.tema.undertema" && $slug == tema->slug.current]{
|
||||
title,
|
||||
"articles": *[_type == "aksel_artikkel" && ^._id in undertema[]._ref] | order(publishedAt desc)[0...${GP_INITIAL_ARTICLES}]${baseGpArticleData}
|
||||
},
|
||||
"initialInnholdstype": *[_type == "gp.innholdstype"]{
|
||||
title,
|
||||
"articles": *[_type == "aksel_artikkel" && innholdstype._ref == ^._id && $slug in undertema[]->tema->slug.current]| order(publishedAt desc)[0...${GP_INITIAL_ARTICLES}]${baseGpArticleData}
|
||||
}`;
|
||||
|
||||
export type initialTemaPageArticlesResponse = {
|
||||
initialUndertema: {
|
||||
title: string;
|
||||
articles: GpArticleT[];
|
||||
}[];
|
||||
initialInnholdstype: {
|
||||
title: string;
|
||||
articles: GpArticleT[];
|
||||
}[];
|
||||
export type ParsedGPArticle = Omit<
|
||||
GpSlugQueryResponse["articles"][0],
|
||||
"undertema"
|
||||
> & {
|
||||
undertema: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* API-queries
|
||||
*/
|
||||
/**
|
||||
* @arguments innholdstype: string
|
||||
* @arguments undertema: string
|
||||
* @arguments start: number
|
||||
* @arguments end: number
|
||||
*/
|
||||
export const apiTemaPageQuery = groq`
|
||||
{
|
||||
"articles": {
|
||||
$undertema == "" && $innholdstype == "" => {
|
||||
"articles": *[_type == 'aksel_artikkel' && $tema in undertema[]->tema->slug.current] | order(publishedAt desc)[$start...$end]${baseGpArticleData},
|
||||
},
|
||||
$undertema != "" && $innholdstype == "" => {
|
||||
"articles": *[_type == 'aksel_artikkel' && $tema in undertema[]->tema->slug.current && $undertema in undertema[]->title ] | order(publishedAt desc)[$start...$end]${baseGpArticleData},
|
||||
},
|
||||
$undertema == "" && $innholdstype != "" => {
|
||||
"articles": *[_type == 'aksel_artikkel' && $tema in undertema[]->tema->slug.current && $innholdstype == innholdstype->title ] | order(publishedAt desc)[$start...$end]${baseGpArticleData},
|
||||
},
|
||||
$undertema != "" && $innholdstype != "" => {
|
||||
"articles": *[_type == 'aksel_artikkel' && $tema in undertema[]->tema->slug.current && $undertema in undertema[]->title && $innholdstype == innholdstype->title ] | order(publishedAt desc)[$start...$end]${baseGpArticleData},
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* @arguments innholdstype: string
|
||||
* @arguments start: number
|
||||
* @arguments end: number
|
||||
*/
|
||||
export const apiMainPageQuery = groq`
|
||||
{
|
||||
"articles": {
|
||||
$innholdstype == "" => {
|
||||
"articles": *[_type == 'aksel_artikkel' ] | order(publishedAt desc)[$start...$end]${baseGpArticleData},
|
||||
},
|
||||
$innholdstype != "" => {
|
||||
"articles": *[_type == 'aksel_artikkel' && $innholdstype == innholdstype->title ] | order(publishedAt desc)[$start...$end]${baseGpArticleData},
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -1,39 +0,0 @@
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
/**
|
||||
* TODO
|
||||
* - Is this the best way to get current query? Refactor
|
||||
* - Replace temaQuery with already known tema from initialProps, should not be needed here
|
||||
*/
|
||||
function useGpQuery(): {
|
||||
innholdstypeQuery: string | null;
|
||||
undertemaQuery: string | null;
|
||||
temaQuery: string | null;
|
||||
isReady: boolean;
|
||||
} {
|
||||
const { query, isReady } = useRouter();
|
||||
|
||||
const innholdstypeQuery = () => {
|
||||
const q = decodeURIComponent(query?.innholdstype?.toString?.() ?? "");
|
||||
return q.length > 0 ? q : null;
|
||||
};
|
||||
|
||||
const undertemaQuery = () => {
|
||||
const q = decodeURIComponent(query?.undertema?.toString?.() ?? "");
|
||||
return q.length > 0 ? q : null;
|
||||
};
|
||||
|
||||
const temaQuery = () => {
|
||||
const q = decodeURIComponent(query?.slug?.toString?.() ?? "");
|
||||
return q.length > 0 ? q : null;
|
||||
};
|
||||
|
||||
return {
|
||||
innholdstypeQuery: innholdstypeQuery(),
|
||||
undertemaQuery: undertemaQuery(),
|
||||
temaQuery: temaQuery(),
|
||||
isReady,
|
||||
};
|
||||
}
|
||||
|
||||
export default useGpQuery;
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
type ViewBoth = {
|
||||
view: "both";
|
||||
innholdstype: string;
|
||||
undertema: string;
|
||||
};
|
||||
|
||||
type ViewInnholdstype = {
|
||||
view: "innholdstype";
|
||||
innholdstype: string;
|
||||
undertema: undefined;
|
||||
};
|
||||
|
||||
type ViewUndertema = {
|
||||
view: "undertema";
|
||||
undertema: string;
|
||||
innholdstype: undefined;
|
||||
};
|
||||
|
||||
type ViewNone = {
|
||||
view: "none";
|
||||
undertema: undefined;
|
||||
innholdstype: undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Tracks the current filter for current God Praksis tema/page.
|
||||
* We track the current "undertema" and "innholdstype" in the URL query.
|
||||
* If both are present, we are in a "both" view.
|
||||
* If only "undertema" is present, we are in a "undertema" view.
|
||||
* If only "innholdstype" is present, we are in a "innholdstype" view.
|
||||
* If neither are present, we are in a "none" view.
|
||||
*
|
||||
* @returns The current view state.
|
||||
*/
|
||||
export function useGpViews():
|
||||
| ViewBoth
|
||||
| ViewInnholdstype
|
||||
| ViewUndertema
|
||||
| ViewNone {
|
||||
const { innholdstype: innholdstypeQuery, undertema: undertemaQuery } =
|
||||
useRouter().query;
|
||||
|
||||
const safeInnholdstype = safeString(innholdstypeQuery);
|
||||
const safeUndertema = safeString(undertemaQuery);
|
||||
|
||||
if (safeInnholdstype && safeUndertema) {
|
||||
return {
|
||||
view: "both",
|
||||
innholdstype: safeInnholdstype,
|
||||
undertema: safeUndertema,
|
||||
};
|
||||
}
|
||||
|
||||
if (safeInnholdstype) {
|
||||
return {
|
||||
view: "innholdstype",
|
||||
innholdstype: safeInnholdstype,
|
||||
undertema: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (safeUndertema) {
|
||||
return {
|
||||
view: "undertema",
|
||||
undertema: safeUndertema,
|
||||
innholdstype: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
view: "none",
|
||||
undertema: undefined,
|
||||
innholdstype: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function safeString(value: unknown): string | undefined {
|
||||
if (typeof value === "string") {
|
||||
return decodeURIComponent(value);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
Loaded 100 of 149 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user