├── .gitattributes ├── src ├── utils │ ├── index.ts │ ├── arrayUtils.ts │ ├── numberFormat.ts │ ├── numberFormat.test.tsx │ └── arrayUtils.test.ts ├── components │ ├── SvgIcon │ │ ├── index.ts │ │ ├── SvgIcon.tsx │ │ ├── Stories │ │ │ ├── success.svg │ │ │ └── SvgIcon.stories.tsx │ │ └── SvgIcon.test.tsx │ ├── AppWrapper │ │ ├── index.ts │ │ ├── AppWrapper.module.css │ │ ├── AppWrapper.tsx │ │ ├── AppWrapper.stories.tsx │ │ └── AppWrapper.mdx │ ├── Pagination │ │ ├── index.ts │ │ ├── first_page.svg │ │ ├── navigate_before.svg │ │ ├── navigate_next.svg │ │ ├── last_page.svg │ │ ├── Pagination.module.css │ │ ├── Pagination.stories.tsx │ │ ├── Pagination.test.tsx │ │ └── Pagination.tsx │ ├── SearchField │ │ ├── index.ts │ │ ├── SearchField.tsx │ │ ├── SearchField.stories.tsx │ │ └── SearchField.test.tsx │ ├── Map │ │ ├── index.ts │ │ ├── Map.module.css │ │ ├── Map.stories.tsx │ │ ├── Map.test.tsx │ │ └── Map.tsx │ ├── Panel │ │ ├── index.ts │ │ ├── PopoverPanel.module.css │ │ ├── success.svg │ │ ├── info.svg │ │ ├── Panel.stories.tsx │ │ ├── PopoverPanel.tsx │ │ ├── Panel.tsx │ │ ├── PopoverPanel.stories.tsx │ │ ├── PopoverPanel.test.tsx │ │ ├── Panel.module.css │ │ └── Panel.test.tsx │ ├── CircularProgress │ │ ├── index.ts │ │ ├── CircularProgress.module.css │ │ ├── CircularProgress.stories.tsx │ │ ├── CircularProgress.test.tsx │ │ └── CircularProgress.tsx │ ├── Page │ │ ├── Page.module.css │ │ ├── index.ts │ │ ├── PageContent.module.css │ │ ├── PageContent.tsx │ │ ├── Context.ts │ │ ├── Page.tsx │ │ ├── PageHeader.tsx │ │ ├── PageHeader.module.css │ │ ├── Page.stories.tsx │ │ └── Page.test.tsx │ ├── _InputWrapper │ │ ├── index.ts │ │ ├── error.svg │ │ ├── README.md │ │ ├── searchIcon.svg │ │ ├── Icon.test.tsx │ │ ├── Icon.tsx │ │ ├── utils.ts │ │ ├── utils.test.ts │ │ ├── InputWrapper.tsx │ │ ├── InputWrapper.module.css │ │ └── InputWrapper.test.tsx │ ├── List │ │ ├── index.ts │ │ ├── ListItem.module.css │ │ ├── ListItem.tsx │ │ ├── List.module.css │ │ ├── List.tsx │ │ ├── List.test.tsx │ │ └── List.stories.tsx │ └── index.ts ├── DesignTokens │ ├── index.css │ └── index.ts ├── index.ts ├── hooks │ ├── index.ts │ ├── usePrevious.ts │ ├── useEventListener.ts │ ├── useKeyboardEventListener.ts │ ├── useUpdate.ts │ ├── useMediaQuery.ts │ ├── usePrevious.test.ts │ ├── useUpdate.test.ts │ ├── useEventListener.test.ts │ ├── useKeyboardEventListener.test.ts │ └── useMediaQuery.test.ts └── assets │ └── Data.svg ├── .eslintignore ├── __mocks__ └── svg.ts ├── scripts ├── templates │ └── Component │ │ ├── index.ts │ │ ├── _COMPONENT_.module.css │ │ ├── _COMPONENT_.test.tsx │ │ ├── _COMPONENT_.tsx │ │ └── _COMPONENT_.stories.tsx └── add-component.mjs ├── .husky └── pre-commit ├── renovate.json ├── .yarn ├── sdks │ ├── eslint │ │ ├── package.json │ │ └── lib │ │ │ └── api.js │ ├── prettier │ │ ├── package.json │ │ └── index.js │ ├── integrations.yml │ └── typescript │ │ ├── package.json │ │ ├── bin │ │ ├── tsc │ │ └── tsserver │ │ └── lib │ │ ├── tsc.js │ │ ├── typescript.js │ │ ├── tsserver.js │ │ └── tsserverlibrary.js └── .gitignore ├── .vscode ├── extensions.json └── settings.json ├── .yarnrc.yml ├── .editorconfig ├── .babelrc.json ├── .stylelintrc.json ├── prettier.config.js ├── test ├── jest.setup.ts └── testUtils.ts ├── custom.d.ts ├── docker-compose.yml ├── .storybook ├── DeprecationWarning.tsx ├── preview.js ├── StoryPage.tsx └── main.js ├── jest.config.js ├── tsconfig.json ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.yml │ ├── bug_report.yml │ ├── chore.yml │ └── new_component.yml └── workflows │ ├── build.yaml │ └── release.yaml ├── rollup-terser.mjs ├── docs ├── intro.mdx └── README.md ├── .eslintrc.js ├── LICENSE ├── rollup.config.mjs ├── package.json ├── README.md └── .gitignore /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './numberFormat'; 2 | -------------------------------------------------------------------------------- /src/components/SvgIcon/index.ts: -------------------------------------------------------------------------------- 1 | export { SvgIcon } from './SvgIcon'; 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | **/*.d.ts* 2 | dist/ 3 | coverage/ 4 | storybook-static/ 5 | -------------------------------------------------------------------------------- /src/components/AppWrapper/index.ts: -------------------------------------------------------------------------------- 1 | export { AppWrapper } from './AppWrapper'; 2 | -------------------------------------------------------------------------------- /src/components/Pagination/index.ts: -------------------------------------------------------------------------------- 1 | export { Pagination } from './Pagination'; 2 | -------------------------------------------------------------------------------- /__mocks__/svg.ts: -------------------------------------------------------------------------------- 1 | export default 'SvgrURL'; 2 | export const ReactComponent = 'div'; 3 | -------------------------------------------------------------------------------- /scripts/templates/Component/index.ts: -------------------------------------------------------------------------------- 1 | export { _COMPONENT_ } from './_COMPONENT_'; 2 | -------------------------------------------------------------------------------- /src/DesignTokens/index.css: -------------------------------------------------------------------------------- 1 | @import '@altinn/figma-design-tokens/dist/tokens.css'; 2 | -------------------------------------------------------------------------------- /src/components/SearchField/index.ts: -------------------------------------------------------------------------------- 1 | export { SearchField } from './SearchField'; 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /scripts/templates/Component/_COMPONENT_.module.css: -------------------------------------------------------------------------------- 1 | _COMPONENT_ { 2 | /* TODO_ add some style */ 3 | } 4 | -------------------------------------------------------------------------------- /src/components/Map/index.ts: -------------------------------------------------------------------------------- 1 | export type { MapLayer, Location } from './Map'; 2 | export { Map } from './Map'; 3 | -------------------------------------------------------------------------------- /src/components/Map/Map.module.css: -------------------------------------------------------------------------------- 1 | .map { 2 | position: relative; 3 | height: 50rem; 4 | width: 100%; 5 | } 6 | -------------------------------------------------------------------------------- /src/components/Panel/index.ts: -------------------------------------------------------------------------------- 1 | export { Panel, PanelVariant } from './Panel'; 2 | export { PopoverPanel } from './PopoverPanel'; 3 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["local>Altinn/renovate-config"] 4 | } 5 | -------------------------------------------------------------------------------- /src/components/CircularProgress/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | CircularProgress, 3 | type CircularProgressProps, 4 | } from './CircularProgress'; 5 | -------------------------------------------------------------------------------- /.yarn/sdks/eslint/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eslint", 3 | "version": "8.14.0-sdk", 4 | "main": "./lib/api.js", 5 | "type": "commonjs" 6 | } 7 | -------------------------------------------------------------------------------- /.yarn/sdks/prettier/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "prettier", 3 | "version": "2.6.2-sdk", 4 | "main": "./index.js", 5 | "type": "commonjs" 6 | } 7 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './components'; 2 | export { tokens, jsonTokens } from './DesignTokens'; 3 | export { formatNumericText } from './utils'; 4 | -------------------------------------------------------------------------------- /.yarn/.gitignore: -------------------------------------------------------------------------------- 1 | # Yarn stuff; we're not using PnP/Zero installs 2 | /* 3 | !/.gitignore 4 | !/patches 5 | !/plugins 6 | !/releases 7 | !/sdks 8 | !/versions 9 | -------------------------------------------------------------------------------- /.yarn/sdks/integrations.yml: -------------------------------------------------------------------------------- 1 | # This file is automatically generated by @yarnpkg/sdks. 2 | # Manual changes might be lost! 3 | 4 | integrations: 5 | - vscode 6 | -------------------------------------------------------------------------------- /src/components/Page/Page.module.css: -------------------------------------------------------------------------------- 1 | .page { 2 | width: 100%; 3 | filter: drop-shadow(1px 1px 4px rgba(0, 0, 0, 0.25)); 4 | box-sizing: border-box; 5 | } 6 | -------------------------------------------------------------------------------- /src/components/_InputWrapper/index.ts: -------------------------------------------------------------------------------- 1 | export { InputWrapper } from './InputWrapper'; 2 | export { IconVariant, ReadOnlyVariant, InputVariant } from './utils'; 3 | -------------------------------------------------------------------------------- /.yarn/sdks/typescript/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typescript", 3 | "version": "4.6.4-sdk", 4 | "main": "./lib/typescript.js", 5 | "type": "commonjs" 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "arcanis.vscode-zipfs", 4 | "dbaeumer.vscode-eslint", 5 | "esbenp.prettier-vscode" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /src/DesignTokens/index.ts: -------------------------------------------------------------------------------- 1 | import * as tokens from '@altinn/figma-design-tokens'; 2 | import jsonTokens from '@altinn/figma-design-tokens/dist/tokens.json'; 3 | export { tokens, jsonTokens }; 4 | -------------------------------------------------------------------------------- /src/components/AppWrapper/AppWrapper.module.css: -------------------------------------------------------------------------------- 1 | @import 'https://altinncdn.no/fonts/altinn-din/altinn-din.css'; 2 | 3 | html { 4 | font-family: var(--font_family-default), sans-serif; 5 | } 6 | -------------------------------------------------------------------------------- /src/components/List/index.ts: -------------------------------------------------------------------------------- 1 | export { List } from './List'; 2 | export { ListItem } from './ListItem'; 3 | export type { ListProps } from './List'; 4 | export type { ListItemProps } from './ListItem'; 5 | -------------------------------------------------------------------------------- /src/components/Page/index.ts: -------------------------------------------------------------------------------- 1 | export { Page } from './Page'; 2 | export { PageHeader } from './PageHeader'; 3 | export { PageContent } from './PageContent'; 4 | export { PageColor, PageSize } from './Context'; 5 | -------------------------------------------------------------------------------- /src/components/Pagination/first_page.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/Panel/PopoverPanel.module.css: -------------------------------------------------------------------------------- 1 | .popover-panel { 2 | filter: drop-shadow(1px 1px 4px rgba(0, 0, 0, 0.25)); 3 | } 4 | 5 | .popover-panel__arrow { 6 | transform: scale(-1); 7 | margin-top: -1px; 8 | } 9 | -------------------------------------------------------------------------------- /src/components/Pagination/navigate_before.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/Pagination/navigate_next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export { useEventListener } from './useEventListener'; 2 | export { useKeyboardEventListener } from './useKeyboardEventListener'; 3 | export { usePrevious } from './usePrevious'; 4 | export { useUpdate } from './useUpdate'; 5 | -------------------------------------------------------------------------------- /src/components/Pagination/last_page.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/hooks/usePrevious.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | 3 | export function usePrevious(value: T) { 4 | const ref = useRef(); 5 | useEffect(() => { 6 | ref.current = value; 7 | }, [value]); 8 | return ref.current; 9 | } 10 | -------------------------------------------------------------------------------- /src/components/List/ListItem.module.css: -------------------------------------------------------------------------------- 1 | .listItem { 2 | border-bottom-color: var(--component-list-border_color); 3 | border-bottom-style: var(--component-list-border_style); 4 | border-bottom-width: var(--component-list-border_width); 5 | display: block; 6 | } 7 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | plugins: 2 | - path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs 3 | spec: '@yarnpkg/plugin-interactive-tools' 4 | 5 | yarnPath: .yarn/releases/yarn-3.6.0.cjs 6 | 7 | npmAuthToken: ${NODE_AUTH_TOKEN:-} 8 | enableTelemetry: false 9 | 10 | nodeLinker: 'node-modules' 11 | -------------------------------------------------------------------------------- /src/components/Page/PageContent.module.css: -------------------------------------------------------------------------------- 1 | .page-content { 2 | --component-page_content-vertical-padding: 2rem; 3 | background-color: white; 4 | padding-top: var(--component-page_content-vertical-padding); 5 | padding-bottom: var(--component-page_content-vertical-padding); 6 | box-sizing: inherit; 7 | } 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | indent_style = space 8 | indent_size = 2 9 | end_of_line = lf 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | 13 | [*.md] 14 | insert_final_newline = false 15 | trim_trailing_whitespace = false 16 | -------------------------------------------------------------------------------- /.babelrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "sourceType": "unambiguous", 3 | "presets": [ 4 | [ 5 | "@babel/preset-env", 6 | { 7 | "targets": { 8 | "chrome": 100 9 | } 10 | } 11 | ], 12 | "@babel/preset-typescript", 13 | "@babel/preset-react" 14 | ], 15 | "plugins": [] 16 | } 17 | -------------------------------------------------------------------------------- /src/hooks/useEventListener.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | export function useEventListener(eventType: string, action: () => void) { 4 | useEffect(() => { 5 | document.addEventListener(eventType, action); 6 | return () => document.removeEventListener(eventType, action); 7 | }, [eventType, action]); 8 | } 9 | -------------------------------------------------------------------------------- /src/components/_InputWrapper/error.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /scripts/templates/Component/_COMPONENT_.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { _COMPONENT_ } from './_COMPONENT_'; 4 | 5 | describe('_COMPONENT_', () => { 6 | it('should verify something', () => { 7 | // Please add some non-snapshot test to verify conditional statements 8 | expect(<_COMPONENT_ />).toBe(true); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /src/components/AppWrapper/AppWrapper.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import '@altinn/figma-design-tokens/dist/tokens.css'; 4 | import './AppWrapper.module.css'; 5 | 6 | export interface AppWrapperProps { 7 | children?: React.ReactNode; 8 | } 9 | 10 | export const AppWrapper = ({ children }: AppWrapperProps) => { 11 | return
{children}
; 12 | }; 13 | -------------------------------------------------------------------------------- /src/components/AppWrapper/AppWrapper.stories.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import { AppWrapper } from '@/components'; 4 | 5 | const meta: Meta = { 6 | component: AppWrapper, 7 | }; 8 | 9 | export default meta; 10 | 11 | type Story = StoryObj; 12 | 13 | export const App: Story = { 14 | args: {}, 15 | }; 16 | -------------------------------------------------------------------------------- /src/assets/Data.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/Page/PageContent.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import cn from 'classnames'; 3 | 4 | import classes from './PageContent.module.css'; 5 | 6 | export interface PageContentProps { 7 | children?: React.ReactNode; 8 | } 9 | 10 | export const PageContent = ({ children }: PageContentProps) => { 11 | return
{children}
; 12 | }; 13 | -------------------------------------------------------------------------------- /src/components/_InputWrapper/README.md: -------------------------------------------------------------------------------- 1 | InputWrapper is an internal component only, that is not exposed outside this package. 2 | Its purpose is to be a wrapper around several input components (f.ex TextField, TextArea), because they share look'n'feel. 3 | Parts of this component (f.ex enums and types) can still be exposed though, because those are used in the wrapped components, and consumers may also need those enums. 4 | -------------------------------------------------------------------------------- /src/components/List/ListItem.tsx: -------------------------------------------------------------------------------- 1 | import type { ComponentPropsWithoutRef } from 'react'; 2 | import React from 'react'; 3 | 4 | import classes from './ListItem.module.css'; 5 | 6 | export type ListItemProps = ComponentPropsWithoutRef<'li'>; 7 | 8 | export const ListItem = ({ children, ...rest }: ListItemProps) => ( 9 |
  • 13 | {children} 14 |
  • 15 | ); 16 | -------------------------------------------------------------------------------- /scripts/templates/Component/_COMPONENT_.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import cn from 'classnames'; 3 | 4 | import classes from './_COMPONENT_.module.css'; 5 | 6 | export interface _COMPONENT_Props { 7 | greeting?: string; // TODO: add props 8 | } 9 | 10 | export const _COMPONENT_ = ({ greeting }: _COMPONENT_Props) => { 11 | return ( 12 |
    {greeting} from _COMPONENT_
    13 | ); 14 | }; 15 | -------------------------------------------------------------------------------- /.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["stylelint-prettier", "stylelint-order"], 3 | "extends": ["stylelint-prettier/recommended", "stylelint-config-prettier"], 4 | "rules": { 5 | "prettier/prettier": true, 6 | "order/properties-alphabetical-order": true, 7 | "declaration-empty-line-before": "never", 8 | "rule-empty-line-before": [ 9 | "always", 10 | { "ignore": ["after-comment", "first-nested"] } 11 | ] 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/hooks/useKeyboardEventListener.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | export function useKeyboardEventListener(key: string, onKeyDown: () => void) { 4 | useEffect(() => { 5 | const keyDownEvent = (event: KeyboardEvent) => { 6 | if (event.key === key) onKeyDown(); 7 | }; 8 | document.addEventListener('keydown', keyDownEvent); 9 | return () => document.removeEventListener('keydown', keyDownEvent); 10 | }, [key, onKeyDown]); 11 | } 12 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | module.exports = { 3 | trailingComma: 'all', 4 | tabWidth: 2, 5 | useTabs: false, 6 | semi: true, 7 | singleQuote: true, 8 | printWidth: 80, 9 | quoteProps: 'as-needed', 10 | jsxSingleQuote: true, 11 | bracketSpacing: true, 12 | bracketSameLine: false, 13 | arrowParens: 'always', 14 | endOfLine: 'lf', 15 | proseWrap: 'preserve', 16 | htmlWhitespaceSensitivity: 'css', 17 | singleAttributePerLine: true, 18 | }; 19 | -------------------------------------------------------------------------------- /test/jest.setup.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | import { mockMediaQuery } from './testUtils'; 3 | 4 | // @radix-ui/react-popover uses resizeobserver, which is not supported in jsdom. 5 | // This is a simple mock to not break the tests. 6 | class ResizeObserver { 7 | observe = jest.fn(); 8 | unobserve = jest.fn(); 9 | disconnect = jest.fn(); 10 | } 11 | window.ResizeObserver = ResizeObserver; 12 | 13 | const { setScreenWidth } = mockMediaQuery(800); 14 | setScreenWidth(800); 15 | -------------------------------------------------------------------------------- /custom.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.svg' { 2 | import React = require('react'); 3 | export const ReactComponent: React.FunctionComponent< 4 | React.SVGProps 5 | >; 6 | const src: string; 7 | export default src; 8 | } 9 | 10 | declare module '*.css' { 11 | const styles: { [className: string]: string }; 12 | export default styles; 13 | } 14 | 15 | // Must be set so Typescript don't give errors when loading .png-files used in stories 16 | declare module '*.png' { 17 | const value: any; 18 | export = value; 19 | } -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | dev: 5 | image: node:18-alpine 6 | container_name: altinn-design-system_dev 7 | working_dir: /app 8 | command: 9 | [ 10 | 'sh', 11 | '-c', 12 | 'yarn start', 13 | ] 14 | volumes: 15 | - type: bind 16 | source: . 17 | target: /app 18 | networks: 19 | - altinn-design-system 20 | ports: 21 | - '3333:3333' 22 | 23 | networks: 24 | altinn-design-system: 25 | name: altinn-design-system 26 | -------------------------------------------------------------------------------- /src/hooks/useUpdate.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | 3 | // This hook corresponds to the componentDidUpdate function in class components. 4 | // It is similar to useEffect, but does not run on the first render. 5 | export const useUpdate: typeof useEffect = (effect, deps) => { 6 | const isFirst = useRef(true); 7 | useEffect(() => { 8 | if (isFirst.current) { 9 | isFirst.current = false; 10 | } else { 11 | return effect(); 12 | } 13 | }, deps); // eslint-disable-line react-hooks/exhaustive-deps 14 | }; 15 | -------------------------------------------------------------------------------- /.storybook/DeprecationWarning.tsx: -------------------------------------------------------------------------------- 1 | import React, {ReactNode} from 'react'; 2 | 3 | export interface DeprecationWarningProps { 4 | children: ReactNode; 5 | } 6 | 7 | export const DeprecationWarning = ({ children }: DeprecationWarningProps) => ( 8 |
    17 |

    Deprecated: {children}

    18 |
    19 | ); 20 | -------------------------------------------------------------------------------- /src/components/List/List.module.css: -------------------------------------------------------------------------------- 1 | .list { 2 | --component-list-border_width: 1px; 3 | 4 | border-top-color: var(--component-list-border_color); 5 | border-top-style: var(--component-list-border_style); 6 | border-top-width: var(--component-list-border_width); 7 | list-style-type: none; 8 | padding-left: 0; 9 | } 10 | 11 | .list.solid { 12 | --component-list-border_color: #bcc7cc; 13 | --component-list-border_style: solid; 14 | } 15 | 16 | .list.dashed { 17 | --component-list-border_color: #1eadf7; 18 | --component-list-border_style: dashed; 19 | } 20 | -------------------------------------------------------------------------------- /src/components/SvgIcon/SvgIcon.tsx: -------------------------------------------------------------------------------- 1 | import type { SVGAttributes } from 'react'; 2 | import { isValidElement, cloneElement } from 'react'; 3 | import type React from 'react'; 4 | 5 | export type IconComponentProps = { 6 | svgIconComponent: React.ReactNode; 7 | }; 8 | 9 | export type SvgIconProps = IconComponentProps & SVGAttributes; 10 | 11 | export const SvgIcon = ({ svgIconComponent, ...rest }: SvgIconProps) => { 12 | if (isValidElement(svgIconComponent)) { 13 | return cloneElement(svgIconComponent, { ...rest }); 14 | } 15 | return null; 16 | }; 17 | -------------------------------------------------------------------------------- /test/testUtils.ts: -------------------------------------------------------------------------------- 1 | export const mockMediaQuery = (maxWidth: number) => { 2 | const setScreenWidth = (width: number) => { 3 | Object.defineProperty(window, 'innerWidth', { 4 | writable: true, 5 | configurable: true, 6 | value: width, 7 | }); 8 | window.matchMedia = jest.fn().mockImplementation((query: string) => ({ 9 | matches: width <= maxWidth, 10 | media: query, 11 | onchange: null, 12 | addEventListener: jest.fn(), 13 | removeEventListener: jest.fn(), 14 | })); 15 | }; 16 | 17 | return { setScreenWidth }; 18 | }; 19 | -------------------------------------------------------------------------------- /src/components/CircularProgress/CircularProgress.module.css: -------------------------------------------------------------------------------- 1 | .svg { 2 | width: 100%; 3 | height: 100%; 4 | transform: rotate(-90deg); 5 | overflow: visible; 6 | } 7 | 8 | .circleTransition { 9 | transition: stroke-dashoffset 1s ease-in-out; 10 | } 11 | 12 | .container { 13 | position: relative; 14 | } 15 | 16 | .label { 17 | all: initial; 18 | font-family: inherit; 19 | font-size: var(--font_size-300); 20 | position: absolute; 21 | top: 0; 22 | right: 0; 23 | left: 0; 24 | bottom: 0; 25 | display: flex; 26 | justify-content: center; 27 | align-items: center; 28 | } 29 | -------------------------------------------------------------------------------- /src/components/_InputWrapper/searchIcon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/components/Page/Context.ts: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from 'react'; 2 | 3 | export enum PageColor { 4 | Primary = 'primary', 5 | Success = 'success', 6 | } 7 | 8 | export enum PageSize { 9 | Small = 'small', 10 | Medium = 'medium', 11 | } 12 | 13 | export const PageContext = createContext({ 14 | color: PageColor.Primary, 15 | size: PageSize.Medium, 16 | }); 17 | 18 | export const usePageContext = () => { 19 | const context = useContext(PageContext); 20 | if (context === undefined) { 21 | throw new Error('usePageContext must be used within a PageContext'); 22 | } 23 | 24 | return context; 25 | }; 26 | -------------------------------------------------------------------------------- /src/components/Page/Page.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { PageColor, PageContext, PageSize } from './Context'; 4 | import classes from './Page.module.css'; 5 | 6 | export interface PageProps { 7 | children?: React.ReactNode; 8 | color?: PageColor; 9 | size?: PageSize; 10 | } 11 | 12 | export const Page = ({ 13 | children, 14 | color = PageColor.Primary, 15 | size = PageSize.Medium, 16 | }: PageProps) => { 17 | return ( 18 |
    19 | 20 | {children} 21 | 22 |
    23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /src/components/List/List.tsx: -------------------------------------------------------------------------------- 1 | import type { ComponentPropsWithoutRef } from 'react'; 2 | import React from 'react'; 3 | import cn from 'classnames'; 4 | 5 | import classes from './List.module.css'; 6 | 7 | export type ListProps = { 8 | /** Select which border style between items*/ 9 | borderStyle?: 'solid' | 'dashed'; 10 | } & ComponentPropsWithoutRef<'ul'>; 11 | 12 | export const List = ({ 13 | borderStyle = 'solid', 14 | children, 15 | className, 16 | ...rest 17 | }: ListProps) => ( 18 |
      22 | {children} 23 |
    24 | ); 25 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | export { Panel, PanelVariant, PopoverPanel } from './Panel'; 2 | export { 3 | CircularProgress, 4 | type CircularProgressProps, 5 | } from './CircularProgress'; 6 | export { AppWrapper } from './AppWrapper'; 7 | export { Page, PageHeader, PageContent, PageColor, PageSize } from './Page'; 8 | export { List, ListItem } from './List'; 9 | export { SearchField } from './SearchField'; 10 | export type { Location, MapLayer } from './Map'; 11 | export { Map } from './Map'; 12 | export { IconVariant, ReadOnlyVariant } from './_InputWrapper'; 13 | export { Pagination } from './Pagination'; 14 | export { SvgIcon } from './SvgIcon'; 15 | -------------------------------------------------------------------------------- /src/components/Panel/success.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /.yarn/sdks/eslint/lib/api.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire, createRequireFromPath} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = (createRequire || createRequireFromPath)(absPnpApiPath); 11 | 12 | if (existsSync(absPnpApiPath)) { 13 | if (!process.versions.pnp) { 14 | // Setup the environment to be able to require eslint 15 | require(absPnpApiPath).setup(); 16 | } 17 | } 18 | 19 | // Defer to the real eslint your application uses 20 | module.exports = absRequire(`eslint`); 21 | -------------------------------------------------------------------------------- /src/components/SvgIcon/Stories/success.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /.yarn/sdks/prettier/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire, createRequireFromPath} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = (createRequire || createRequireFromPath)(absPnpApiPath); 11 | 12 | if (existsSync(absPnpApiPath)) { 13 | if (!process.versions.pnp) { 14 | // Setup the environment to be able to require prettier/index.js 15 | require(absPnpApiPath).setup(); 16 | } 17 | } 18 | 19 | // Defer to the real prettier/index.js your application uses 20 | module.exports = absRequire(`prettier/index.js`); 21 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 3 | module.exports = { 4 | preset: 'ts-jest', 5 | testEnvironment: 'jsdom', 6 | moduleNameMapper: { 7 | '.(css|less|scss)$': 'identity-obj-proxy', 8 | '\\.svg$': '/__mocks__/svg.ts', 9 | '^@/(.*)$': '/src/$1', 10 | '^@test/(.*)$': '/test/$1', 11 | }, 12 | modulePathIgnorePatterns: ['/dist/'], 13 | setupFilesAfterEnv: ['/test/jest.setup.ts'], 14 | testPathIgnorePatterns: ['/node_modules/', '/scripts/templates/'], 15 | transform: { 16 | '\\.[tj]sx?$': ['ts-jest'], 17 | }, 18 | transformIgnorePatterns: ['node_modules/(?!react-leaflet)/'], 19 | }; 20 | -------------------------------------------------------------------------------- /.yarn/sdks/typescript/bin/tsc: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire, createRequireFromPath} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = (createRequire || createRequireFromPath)(absPnpApiPath); 11 | 12 | if (existsSync(absPnpApiPath)) { 13 | if (!process.versions.pnp) { 14 | // Setup the environment to be able to require typescript/bin/tsc 15 | require(absPnpApiPath).setup(); 16 | } 17 | } 18 | 19 | // Defer to the real typescript/bin/tsc your application uses 20 | module.exports = absRequire(`typescript/bin/tsc`); 21 | -------------------------------------------------------------------------------- /.yarn/sdks/typescript/lib/tsc.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire, createRequireFromPath} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = (createRequire || createRequireFromPath)(absPnpApiPath); 11 | 12 | if (existsSync(absPnpApiPath)) { 13 | if (!process.versions.pnp) { 14 | // Setup the environment to be able to require typescript/lib/tsc.js 15 | require(absPnpApiPath).setup(); 16 | } 17 | } 18 | 19 | // Defer to the real typescript/lib/tsc.js your application uses 20 | module.exports = absRequire(`typescript/lib/tsc.js`); 21 | -------------------------------------------------------------------------------- /.yarn/sdks/typescript/bin/tsserver: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire, createRequireFromPath} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = (createRequire || createRequireFromPath)(absPnpApiPath); 11 | 12 | if (existsSync(absPnpApiPath)) { 13 | if (!process.versions.pnp) { 14 | // Setup the environment to be able to require typescript/bin/tsserver 15 | require(absPnpApiPath).setup(); 16 | } 17 | } 18 | 19 | // Defer to the real typescript/bin/tsserver your application uses 20 | module.exports = absRequire(`typescript/bin/tsserver`); 21 | -------------------------------------------------------------------------------- /src/hooks/useMediaQuery.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | export function useMediaQuery(query: string): boolean { 4 | const getMatches = (query: string): boolean => 5 | window?.matchMedia(query).matches ?? false; 6 | 7 | const [matches, setMatches] = useState(getMatches(query)); 8 | 9 | const eventListener = () => { 10 | setMatches(getMatches(query)); 11 | }; 12 | 13 | useEffect(() => { 14 | const matchMedia = window.matchMedia(query); 15 | eventListener(); 16 | matchMedia.addEventListener('change', eventListener); 17 | return () => matchMedia.removeEventListener('change', eventListener); 18 | }, [query]); // eslint-disable-line react-hooks/exhaustive-deps 19 | 20 | return matches; 21 | } 22 | -------------------------------------------------------------------------------- /.yarn/sdks/typescript/lib/typescript.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire, createRequireFromPath} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = (createRequire || createRequireFromPath)(absPnpApiPath); 11 | 12 | if (existsSync(absPnpApiPath)) { 13 | if (!process.versions.pnp) { 14 | // Setup the environment to be able to require typescript/lib/typescript.js 15 | require(absPnpApiPath).setup(); 16 | } 17 | } 18 | 19 | // Defer to the real typescript/lib/typescript.js your application uses 20 | module.exports = absRequire(`typescript/lib/typescript.js`); 21 | -------------------------------------------------------------------------------- /.storybook/preview.js: -------------------------------------------------------------------------------- 1 | import { themes } from '@storybook/theming'; 2 | 3 | export const parameters = { 4 | actions: { argTypesRegex: '^on[A-Z].*' }, 5 | controls: { 6 | matchers: { 7 | date: /Date$/, 8 | }, 9 | }, 10 | layout: 'centered', 11 | darkMode: { 12 | current: 'light', 13 | dark: { 14 | ...themes.dark, 15 | }, 16 | light: { 17 | ...themes.normal, 18 | }, 19 | }, 20 | backgrounds: { 21 | default: 'default', 22 | values: [ 23 | { 24 | name: 'default', 25 | value: '#979797', 26 | }, 27 | { 28 | name: 'light', 29 | value: '#F8F8F8', 30 | }, 31 | { 32 | name: 'dark', 33 | value: '#333333', 34 | }, 35 | ], 36 | }, 37 | }; 38 | -------------------------------------------------------------------------------- /src/components/SvgIcon/SvgIcon.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen } from '@testing-library/react'; 3 | import { Success as SuccessIcon } from '@navikt/ds-icons'; 4 | 5 | import { ReactComponent as MockIcon } from './Stories/success.svg'; 6 | import { SvgIcon } from './SvgIcon'; 7 | 8 | describe('SvgIcon', () => { 9 | it('should render an icon when given an icon in NAVs icon library', () => { 10 | render(} />); 11 | expect(screen.getByRole('img')).toBeInTheDocument(); 12 | }); 13 | it('should render an icon when given a svg imported as a react component', () => { 14 | render(TestIcon} />); 15 | expect(screen.getByText('TestIcon')).toBeInTheDocument(); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/utils/arrayUtils.ts: -------------------------------------------------------------------------------- 1 | // Checks if content of arrays is equal 2 | export function arraysEqual(array1?: T[], array2?: T[]): boolean { 3 | if (array1 === array2) return true; 4 | if (array1 === undefined || array2 === undefined) return false; 5 | if (array1.length !== array2.length) return false; 6 | for (const i in array1) { 7 | if (array1[i] !== array2[i]) return false; 8 | } 9 | return true; 10 | } 11 | 12 | // Returns the last item of the array or undefined if the array is empty 13 | export function lastItem(array: T[]): T | undefined { 14 | return array[array.length - 1]; 15 | } 16 | 17 | // Returns true if all items in the array are unique and false otherwise 18 | export function areItemsUnique(array: T[]): boolean { 19 | return array.length === new Set(array).size; 20 | } 21 | -------------------------------------------------------------------------------- /src/components/AppWrapper/AppWrapper.mdx: -------------------------------------------------------------------------------- 1 | import { Meta } from '@storybook/addon-docs'; 2 | import * as AppWrapperStories from './AppWrapper.stories'; 3 | 4 | 5 | 6 | # AppWrapper 7 | 8 | AppWrapper sørger for å laste eksterne globale styles (f.eks. CSS-variabler som kommer fra Figma Tokens), 9 | og setter noen globale styles (f.eks. font-family). 10 | 11 | For å få andre komponenter til å virke som de skal du derfor bruke AppWrapper. 12 | 13 | Denne komponenten bør lastes kun én gang, og så høyt opp i React-treet som mulig. 14 | 15 | ```jsx 16 | import { AppWrapper } from '@altinn/altinn-design-system'; 17 | 18 | const App = () => { 19 | return ( 20 | 21 |
    22 |

    Hello World

    23 |
    24 |
    25 | ); 26 | }; 27 | ``` 28 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "declarationDir": "dist/types", 5 | "emitDeclarationOnly": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "allowJs": true, 9 | "jsx": "react", 10 | "module": "es2020", 11 | "moduleResolution": "node", 12 | "outDir": "dist", 13 | "resolveJsonModule": true, 14 | "skipLibCheck": true, 15 | "strict": true, 16 | "target": "es2016", 17 | "baseUrl": ".", 18 | "paths": { 19 | "@/*": ["./src/*"], 20 | "@sb/*": ["./.storybook/*"], 21 | "@test/*": ["./test/*"] 22 | } 23 | }, 24 | "include": [ 25 | "**/*.ts", 26 | "**/*.tsx", 27 | "**/*.js", 28 | "**/*.mjs", 29 | "**/.eslintrc.js", 30 | "scripts/add-component.mjs" 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "workbench.colorCustomizations": { 3 | "sash.hoverBorder": "#d72aeb", 4 | "statusBar.background": "#bb13cf", 5 | "statusBar.foreground": "#e7e7e7", 6 | "statusBarItem.hoverBackground": "#d72aeb", 7 | "statusBarItem.remoteBackground": "#bb13cf", 8 | "statusBarItem.remoteForeground": "#e7e7e7", 9 | "titleBar.activeBackground": "#bb13cf", 10 | "titleBar.activeForeground": "#e7e7e7", 11 | "titleBar.inactiveBackground": "#bb13cf99", 12 | "titleBar.inactiveForeground": "#e7e7e799" 13 | }, 14 | "search.exclude": { 15 | "**/.yarn": true, 16 | "**/.pnp.*": true 17 | }, 18 | "typescript.tsdk": ".yarn/sdks/typescript/lib", 19 | "typescript.enablePromptUseWorkspaceTsdk": true, 20 | "eslint.nodePath": ".yarn/sdks", 21 | "prettier.prettierPath": ".yarn/sdks/prettier/index.js" 22 | } 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request ✨ 2 | description: Request a new feature or enhancement 3 | labels: ["kind/feature-request", "status/triage"] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | Please make sure this feature request hasn't been already submitted by someone by looking through other open/closed issues 9 | 10 | - type: textarea 11 | id: description 12 | attributes: 13 | label: Description 14 | description: Give us a brief description of the feature or enhancement you would like 15 | validations: 16 | required: true 17 | 18 | - type: textarea 19 | id: additional-information 20 | attributes: 21 | label: Additional Information 22 | description: Give us some additional information on the feature request like proposed solutions, links, screenshots, etc. -------------------------------------------------------------------------------- /src/hooks/usePrevious.test.ts: -------------------------------------------------------------------------------- 1 | import { renderHook } from '@testing-library/react'; 2 | 3 | import { usePrevious } from './usePrevious'; 4 | 5 | const renderUsePrevious = () => 6 | renderHook(({ state }) => usePrevious(state), { initialProps: { state: 0 } }); 7 | 8 | describe('usePrevious', () => { 9 | it('Returns undefined on initial render', () => { 10 | const { result } = renderUsePrevious(); 11 | expect(result.current).toBeUndefined(); 12 | }); 13 | 14 | it('Returns previous state after rerender', () => { 15 | const { result, rerender } = renderUsePrevious(); 16 | rerender({ state: 1 }); 17 | expect(result.current).toBe(0); 18 | rerender({ state: 2 }); 19 | expect(result.current).toBe(1); 20 | rerender({ state: 4 }); 21 | expect(result.current).toBe(2); 22 | rerender({ state: 8 }); 23 | expect(result.current).toBe(4); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/components/CircularProgress/CircularProgress.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { ComponentStory, ComponentMeta } from '@storybook/react'; 3 | 4 | import { StoryPage } from '@sb/StoryPage'; 5 | 6 | import { CircularProgress } from './CircularProgress'; 7 | 8 | export default { 9 | component: CircularProgress, 10 | parameters: { 11 | docs: { 12 | page: () => ( 13 | 16 | ), 17 | }, 18 | }, 19 | } as ComponentMeta; 20 | 21 | const CircularTemplate: ComponentStory = (args) => ( 22 | 23 | ); 24 | 25 | export const SimpleExample = CircularTemplate.bind({}); 26 | SimpleExample.args = { 27 | width: 100, 28 | value: 60, 29 | label: '3/5', 30 | id: 'progress', 31 | }; 32 | -------------------------------------------------------------------------------- /.storybook/StoryPage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Title, 4 | ArgsTable, 5 | Stories, 6 | Description, 7 | Heading, 8 | PRIMARY_STORY, 9 | } from '@storybook/addon-docs'; 10 | import { AppWrapper } from '@/components/AppWrapper/AppWrapper'; 11 | import {DeprecationWarning} from "@sb/DeprecationWarning"; 12 | 13 | interface StoryPageProps { 14 | description: string; // supports markdown 15 | deprecationWarning?: string; 16 | } 17 | 18 | export const StoryPage = ({ description, deprecationWarning }: StoryPageProps) => { 19 | return ( 20 | 21 | 22 | {deprecationWarning && <DeprecationWarning>{deprecationWarning}</DeprecationWarning>} 23 | <Description>{description}</Description> 24 | <Stories includePrimary={true} /> 25 | <Heading>Props</Heading> 26 | <ArgsTable story={PRIMARY_STORY} /> 27 | </AppWrapper> 28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /src/components/_InputWrapper/Icon.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render as renderRtl, screen } from '@testing-library/react'; 3 | 4 | import type { IconProps } from './Icon'; 5 | import { Icon } from './Icon'; 6 | import { IconVariant } from './utils'; 7 | 8 | describe('Icon', () => { 9 | it('should return error icon when variant is Error', () => { 10 | render({ variant: IconVariant.Error }); 11 | expect(screen.getByTestId('input-icon-error')).toBeInTheDocument(); 12 | }); 13 | 14 | [undefined, IconVariant.None].forEach((variant) => { 15 | it(`should return null when variant is ${variant}`, () => { 16 | const { container } = render({ variant }); 17 | expect(container.firstChild).toBeNull(); 18 | }); 19 | }); 20 | }); 21 | 22 | const render = (props: Partial<IconProps> = {}) => { 23 | const allProps = { 24 | ...props, 25 | }; 26 | 27 | return renderRtl(<Icon {...allProps} />); 28 | }; 29 | -------------------------------------------------------------------------------- /src/hooks/useUpdate.test.ts: -------------------------------------------------------------------------------- 1 | import { renderHook } from '@testing-library/react'; 2 | 3 | import { useUpdate } from '@/hooks'; 4 | 5 | describe('useUpdate', () => { 6 | it('Does not call effect on first render', () => { 7 | const effect = jest.fn(); 8 | renderHook(() => useUpdate(effect, [])); 9 | expect(effect).not.toHaveBeenCalled(); 10 | }); 11 | 12 | it('Calls effect on second render if a dependency changes', () => { 13 | const effect = jest.fn(); 14 | let dependency = 'Something'; 15 | const { rerender } = renderHook(() => useUpdate(effect, [dependency])); 16 | dependency = 'Something else'; 17 | rerender(); 18 | expect(effect).toHaveBeenCalledTimes(1); 19 | }); 20 | 21 | it('Does not call effect on second render if there is no dependency change', () => { 22 | const effect = jest.fn(); 23 | renderHook(() => useUpdate(effect, [])).rerender(); 24 | expect(effect).not.toHaveBeenCalled(); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /src/components/Page/PageHeader.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import cn from 'classnames'; 3 | 4 | import { SvgIcon } from '../SvgIcon'; 5 | 6 | import { PageSize, usePageContext } from './Context'; 7 | import classes from './PageHeader.module.css'; 8 | 9 | export interface PageHeaderProps { 10 | children?: React.ReactNode; 11 | icon?: React.ReactNode; 12 | } 13 | 14 | export const PageHeader = ({ children, icon }: PageHeaderProps) => { 15 | const { color, size } = usePageContext(); 16 | const iconSize = size === PageSize.Small ? 28 : 40; 17 | 18 | return ( 19 | <header 20 | className={cn( 21 | classes['page-header'], 22 | classes[`page-header--${color}`], 23 | classes[`page-header--${size}`], 24 | )} 25 | > 26 | <SvgIcon 27 | min-width={iconSize} 28 | min-height={iconSize} 29 | svgIconComponent={icon} 30 | /> 31 | <span>{children}</span> 32 | </header> 33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /src/components/Page/PageHeader.module.css: -------------------------------------------------------------------------------- 1 | .page-header { 2 | height: var(--page-header-height); 3 | background-color: var(--component-page_header-background-color); 4 | display: flex; 5 | width: 100%; 6 | color: var(--component-page_header-color); 7 | gap: 2rem; 8 | padding-left: 2rem; 9 | padding-right: 2rem; 10 | box-sizing: inherit; 11 | align-items: center; 12 | font-size: var(--page_header-title-font-size); 13 | fill: var(--component-page_header-color); 14 | } 15 | 16 | .page-header--primary { 17 | --component-page_header-background-color: #022f51; 18 | --component-page_header-color: white; 19 | } 20 | 21 | .page-header--success { 22 | --component-page_header-background-color: #17c96b; 23 | --component-page_header-color: black; 24 | } 25 | 26 | .page-header--small { 27 | --page_header-title-font-size: 18px; 28 | --page-header-height: 72px; 29 | } 30 | 31 | .page-header--medium { 32 | --page_header-title-font-size: 28px; 33 | --page-header-height: 96px; 34 | } 35 | -------------------------------------------------------------------------------- /scripts/templates/Component/_COMPONENT_.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { ComponentStory, ComponentMeta } from '@storybook/react'; 3 | 4 | import { StoryPage } from '@sb/StoryPage'; 5 | 6 | import { _COMPONENT_ } from './_COMPONENT_'; 7 | 8 | export default { 9 | component: _COMPONENT_, 10 | parameters: { 11 | docs: { 12 | page: () => ( 13 | <StoryPage 14 | description={`TODO: Add a description (supports markdown)`} 15 | /> 16 | ), 17 | }, 18 | }, 19 | args: { 20 | //TODO: Add default args 21 | }, 22 | } as ComponentMeta<typeof _COMPONENT_>; 23 | 24 | const Template: ComponentStory<typeof _COMPONENT_> = (args) => ( 25 | <_COMPONENT_ {...args} /> 26 | ); 27 | 28 | export const Example = Template.bind({}); 29 | Example.args = { 30 | // TODO: Add story specific args 31 | }; 32 | Example.parameters = { 33 | docs: { 34 | description: { 35 | story: '', // TODO: add story description, supports markdown 36 | }, 37 | }, 38 | }; 39 | -------------------------------------------------------------------------------- /src/utils/numberFormat.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | NumericFormatProps, 3 | PatternFormatProps, 4 | } from 'react-number-format'; 5 | import { numericFormatter, patternFormatter } from 'react-number-format'; 6 | 7 | export const isPatternFormat = ( 8 | numberFormat: NumericFormatProps | PatternFormatProps, 9 | ): numberFormat is PatternFormatProps => { 10 | return (numberFormat as PatternFormatProps).format !== undefined; 11 | }; 12 | 13 | export const isNumericFormat = ( 14 | numberFormat: NumericFormatProps | PatternFormatProps, 15 | ): numberFormat is NumericFormatProps => { 16 | return (numberFormat as PatternFormatProps).format === undefined; 17 | }; 18 | 19 | export const formatNumericText = ( 20 | text: string, 21 | format?: NumericFormatProps | PatternFormatProps, 22 | ) => { 23 | if (format && isNumericFormat(format)) { 24 | return numericFormatter(text, format); 25 | } else if (format && isPatternFormat(format)) { 26 | return patternFormatter(text, format); 27 | } else { 28 | return text; 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /src/components/_InputWrapper/Icon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import cn from 'classnames'; 3 | 4 | import { IconVariant } from './utils'; 5 | import { ReactComponent as ErrorIcon } from './error.svg'; 6 | import { ReactComponent as SearchIcon } from './searchIcon.svg'; 7 | import classes from './InputWrapper.module.css'; 8 | 9 | export interface IconProps { 10 | variant?: IconVariant; 11 | disabled?: boolean; 12 | } 13 | 14 | export const Icon = ({ variant, disabled = false }: IconProps) => { 15 | if (variant === IconVariant.Error) { 16 | return ( 17 | <div className={classes['InputWrapper__icon']}> 18 | <ErrorIcon data-testid='input-icon-error' /> 19 | </div> 20 | ); 21 | } else if (variant === IconVariant.Search) { 22 | return ( 23 | <div 24 | className={cn(classes['InputWrapper__icon'], { 25 | [classes['InputWrapper__icon--disabled']]: disabled, 26 | })} 27 | > 28 | <SearchIcon data-testid='input-icon-search' /> 29 | </div> 30 | ); 31 | } 32 | 33 | return null; 34 | }; 35 | -------------------------------------------------------------------------------- /src/components/SearchField/SearchField.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { HTMLProps } from 'react'; 3 | import cn from 'classnames'; 4 | 5 | import { InputWrapper } from '../_InputWrapper'; 6 | 7 | export interface SearchFieldProps 8 | extends Omit<HTMLProps<HTMLInputElement>, 'readOnly' | 'className'> { 9 | value?: string; 10 | } 11 | 12 | export const SearchField = ({ 13 | id, 14 | onChange, 15 | disabled = false, 16 | label, 17 | ...rest 18 | }: SearchFieldProps) => { 19 | return ( 20 | <InputWrapper 21 | disabled={disabled} 22 | isSearch={true} 23 | label={label} 24 | inputRenderer={({ className, variant }) => { 25 | const commonProps = { 26 | id, 27 | disabled, 28 | className: cn(className), 29 | }; 30 | return ( 31 | <input 32 | {...commonProps} 33 | {...rest} 34 | data-testid={`${id}-${variant}`} 35 | onChange={onChange} 36 | /> 37 | ); 38 | }} 39 | ></InputWrapper> 40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /rollup-terser.mjs: -------------------------------------------------------------------------------- 1 | /* 2 | The popular `rollup-terser-plugin` does not work well with Yarn PnP; its 3 | reliance on `jest-worker` makes the build hang — probably related to 4 | https://github.com/facebook/jest/issues/12060. This file provides a 5 | simpler plugin that invokes Terser without Jest workers. 6 | */ 7 | 8 | import { minify } from 'terser'; 9 | 10 | function terser(terserOptions = {}) { 11 | return { 12 | name: 'terser', 13 | 14 | async renderChunk(code, _chunk, outputOptions) { 15 | const defaultOptions = { 16 | sourceMap: !!outputOptions.sourcemap, 17 | }; 18 | 19 | // eslint-disable-next-line default-case 20 | switch (outputOptions.format) { 21 | case 'es': 22 | case 'esm': 23 | defaultOptions.module = true; 24 | break; 25 | case 'cjs': 26 | defaultOptions.toplevel = true; 27 | break; 28 | } 29 | 30 | const effectiveTerserOptions = { ...defaultOptions, ...terserOptions }; 31 | return await minify(code, effectiveTerserOptions); 32 | }, 33 | }; 34 | } 35 | 36 | export default terser; 37 | -------------------------------------------------------------------------------- /src/components/Panel/info.svg: -------------------------------------------------------------------------------- 1 | <svg viewBox="0 0 36 36" 2 | xmlns="http://www.w3.org/2000/svg"> 3 | <path d="M18.066 7.3872C18.3271 7.3872 18.5823 7.46462 18.7994 7.60966C19.0164 7.7547 19.1856 7.96086 19.2855 8.20206C19.3854 8.44326 19.4116 8.70867 19.3606 8.96472C19.3097 9.22078 19.184 9.45598 18.9994 9.64058C18.8148 9.82519 18.5796 9.95091 18.3235 10.0018C18.0675 10.0528 17.8021 10.0266 17.5609 9.92672C17.3197 9.82681 17.1135 9.65763 16.9685 9.44055C16.8234 9.22348 16.746 8.96827 16.746 8.7072C16.746 8.35712 16.8851 8.02137 17.1326 7.77382C17.3802 7.52627 17.7159 7.3872 18.066 7.3872Z" /> 4 | <path fill-rule="evenodd" clip-rule="evenodd" d="M18 2.82C9.61632 2.82 2.82 9.61632 2.82 18C2.82 26.3837 9.61632 33.18 18 33.18C26.3837 33.18 33.18 26.3837 33.18 18C33.18 9.61632 26.3837 2.82 18 2.82ZM1.5 18C1.5 8.8873 8.8873 1.5 18 1.5C27.1127 1.5 34.5 8.8873 34.5 18C34.5 27.1127 27.1127 34.5 18 34.5C8.8873 34.5 1.5 27.1127 1.5 18Z" /> 5 | <path fill-rule="evenodd" clip-rule="evenodd" d="M17.9736 15.3072H14.6736V13.9872H19.2936L19.2855 24.468H17.9736V15.3072Z" /> 6 | <path fill-rule="evenodd" clip-rule="evenodd" d="M23.2932 26.0256H14.0532V24.468H23.2932V26.0256Z" /> 7 | </svg> 8 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: Build and run unit tests 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | types: [opened, synchronize, reopened] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | name: Build 13 | steps: 14 | - name: checkout 15 | uses: actions/checkout@v3 16 | with: 17 | fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis 18 | 19 | - name: install node 20 | uses: actions/setup-node@v3 21 | with: 22 | node-version: '16' 23 | registry-url: 'https://npm.pkg.github.com' 24 | 25 | - name: install dependencies 26 | env: 27 | GITHUB_PACKAGES_PAT: ${{ secrets.GITHUB_TOKEN }} 28 | run: yarn --immutable 29 | 30 | - name: run build 31 | run: yarn build 32 | 33 | - name: run eslint 34 | run: yarn lint 35 | 36 | - name: run tests 37 | run: yarn test --coverage 38 | 39 | - name: Codecov 40 | uses: codecov/codecov-action@v3.1.4 41 | with: 42 | directory: ./coverage 43 | fail_ci_if_error: true 44 | -------------------------------------------------------------------------------- /src/components/Pagination/Pagination.module.css: -------------------------------------------------------------------------------- 1 | .pagination-icon { 2 | fill: rgb(0, 0, 0); 3 | } 4 | 5 | .pagination-icon:not(:last-child) { 6 | margin-right: 20px; 7 | } 8 | 9 | .pagination-icon:hover { 10 | cursor: pointer; 11 | } 12 | 13 | .pagination-icon--disabled { 14 | fill: rgba(0, 0, 0, 0.4); 15 | cursor: default !important; 16 | } 17 | 18 | .pagination-wrapper { 19 | display: flex; 20 | align-items: center; 21 | justify-content: right; 22 | } 23 | 24 | .icon-button { 25 | background-color: transparent; 26 | margin: 5px; 27 | border: none; 28 | } 29 | 30 | .description-text { 31 | margin-right: 64px; 32 | } 33 | 34 | .select-pagination { 35 | margin-right: 25px; 36 | } 37 | 38 | /* breakpoints-sm */ 39 | @media only screen and (max-width: 540px) { 40 | .pagination-wrapper { 41 | display: flex; 42 | flex-direction: column; 43 | } 44 | .pagination-wrapper-row { 45 | display: flex; 46 | flex-direction: row; 47 | justify-content: center; 48 | align-items: center; 49 | padding: 5px; 50 | } 51 | .description-text { 52 | margin-top: 10px; 53 | margin-right: 30px; 54 | } 55 | .select-pagination { 56 | margin-top: 10px; 57 | margin-right: 25px; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/components/SearchField/SearchField.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { ComponentStory, ComponentMeta } from '@storybook/react'; 3 | import { useArgs } from '@storybook/client-api'; 4 | 5 | import { StoryPage } from '@sb/StoryPage'; 6 | 7 | import { SearchField } from './SearchField'; 8 | 9 | export default { 10 | component: SearchField, 11 | parameters: { 12 | docs: { 13 | page: () => ( 14 | <StoryPage 15 | description={`TODO: Add a description (supports markdown)`} 16 | /> 17 | ), 18 | }, 19 | }, 20 | args: { 21 | id: 'searchfield-story', 22 | disabled: false, 23 | label: 'Label', 24 | }, 25 | argTypes: { 26 | onChange: { action: 'Value changed, perform search' }, 27 | }, 28 | } as ComponentMeta<typeof SearchField>; 29 | 30 | const Template: ComponentStory<typeof SearchField> = (args) => { 31 | const [{ disabled }] = useArgs(); 32 | return ( 33 | <SearchField 34 | disabled={disabled} 35 | {...args} 36 | /> 37 | ); 38 | }; 39 | 40 | export const Example = Template.bind({}); 41 | Example.args = {}; 42 | 43 | Example.parameters = { 44 | docs: { 45 | description: { 46 | story: 'Search field.', 47 | }, 48 | }, 49 | }; 50 | -------------------------------------------------------------------------------- /src/components/List/List.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render as renderRtl, screen } from '@testing-library/react'; 3 | 4 | import type { ListProps } from './List'; 5 | import { List } from './List'; 6 | import { ListItem } from './ListItem'; 7 | 8 | const render = (props: Partial<ListProps> = {}) => { 9 | const allProps: ListProps = { 10 | children: ( 11 | <> 12 | <ListItem>Test</ListItem> 13 | </> 14 | ), 15 | ...props, 16 | }; 17 | return renderRtl(<List {...allProps} />); 18 | }; 19 | 20 | const borderStyles: ListProps['borderStyle'][] = [undefined, 'solid']; 21 | 22 | describe('List', () => { 23 | it.each(borderStyles)( 24 | 'Renders a list with solid border when "borderStyle" is %s', 25 | (borderStyle) => { 26 | render({ borderStyle }); 27 | const list = screen.getByRole('list'); 28 | expect(list).toHaveClass('solid'); 29 | expect(list).not.toHaveClass('dashed'); 30 | }, 31 | ); 32 | 33 | it('Renders a list with dashed border when "borderStyle" is dashed', () => { 34 | render({ borderStyle: 'dashed' }); 35 | const list = screen.getByRole('list'); 36 | expect(list).toHaveClass('dashed'); 37 | expect(list).not.toHaveClass('solid'); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 🐛 2 | description: File a bug report here 3 | labels: ["kind/bug", "status/triage"] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | Thanks for taking the time to fill out this bug report 🤗 9 | Make sure there aren't any open/closed issues for this topic 😃 10 | 11 | - type: textarea 12 | id: bug-description 13 | attributes: 14 | label: Description of the bug 15 | description: Give us a brief description of what happened and what should have happened 16 | validations: 17 | required: true 18 | 19 | - type: textarea 20 | id: steps-to-reproduce 21 | attributes: 22 | label: Steps To Reproduce 23 | description: Steps to reproduce the behavior. 24 | placeholder: | 25 | 1. Go to '...' 26 | 2. Click on '...' 27 | 3. Scroll down to '...' 28 | 4. See error 29 | validations: 30 | required: true 31 | - type: textarea 32 | id: additional-information 33 | attributes: 34 | label: Additional Information 35 | description: | 36 | Provide any additional information such as logs, screenshots, likes, scenarios in which the bug occurs so that it facilitates resolving the issue. -------------------------------------------------------------------------------- /docs/intro.mdx: -------------------------------------------------------------------------------- 1 | import { Meta } from '@storybook/addon-docs'; 2 | 3 | <Meta title='Introduksjon' /> 4 | 5 | # Altinns interne komponentbibliotek 6 | 7 | Dette er et internt komponentbibliotek med komponenter unike for Altinn sine løsninger. Disse komponentene er ikke tenkt å tas i bruk av andre utenfor Altinn. På [Felles Designsystem](https://www.designsystemet.no) kan du finne gjenbrukbare komponenter på tvers. 8 | 9 | ## Felles Designsystem 10 | 11 | Vi anbefaler å ta i bruk [Felles Designsystem](https://www.designsystemet.no) som består av grunnleggende designelementer, komponenter og mønstre du kan bruke når du utvikler tjenester. 12 | 13 | ## Hvordan installere 14 | 15 | For å legge til komponentbiblioteket i ditt prosjekt, naviger til mappen hvor `package.json`-filen befinner seg og kjør en av følgende kommandoer: 16 | 17 | ### NPM 18 | 19 | ``` 20 | npm install @altinn/altinn-design-system 21 | ``` 22 | 23 | ### Yarn 24 | 25 | ``` 26 | yarn add @altinn/altinn-design-system 27 | ``` 28 | 29 | ## Hvordan bruke Storybook 30 | 31 | På de fleste komponentene ligger det en meny øverst til venstre med valgene `Canvas` og `Docs`. 32 | 33 | - `Canvas`: Viser den spesifikke varianten av komponenten som er valgt. 34 | - `Docs`: Viser dokumentasjon generelt til komponenten og ulike måter den kan brukes på. 35 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | module.exports = { 3 | parserOptions: { 4 | ecmaFeatures: { jsx: true }, 5 | project: './tsconfig.json', 6 | }, 7 | extends: [ 8 | 'eslint:recommended', 9 | 'plugin:import/recommended', 10 | 'plugin:import/typescript', 11 | 'plugin:@typescript-eslint/recommended', 12 | 'plugin:jsx-a11y/recommended', 13 | 'plugin:react/recommended', 14 | 'plugin:react-hooks/recommended', 15 | 'plugin:prettier/recommended', 16 | ], 17 | parser: '@typescript-eslint/parser', 18 | rules: { 19 | 'react/jsx-no-bind': 'off', 20 | '@typescript-eslint/consistent-type-exports': 'warn', 21 | '@typescript-eslint/consistent-type-imports': 'warn', 22 | 'import/order': [ 23 | 'warn', 24 | { 25 | 'newlines-between': 'always', 26 | groups: [ 27 | 'builtin', 28 | 'external', 29 | 'internal', 30 | 'parent', 31 | 'sibling', 32 | 'index', 33 | ], 34 | }, 35 | ], 36 | }, 37 | settings: { 38 | react: { 39 | version: '18', 40 | }, 41 | 'import/parsers': { 42 | '@typescript-eslint/parser': ['.ts', '.tsx'], 43 | }, 44 | 'import/resolver': { 45 | typescript: { 46 | project: '.', 47 | }, 48 | }, 49 | }, 50 | }; 51 | -------------------------------------------------------------------------------- /src/hooks/useEventListener.test.ts: -------------------------------------------------------------------------------- 1 | import { renderHook } from '@testing-library/react'; 2 | import userEvent from '@testing-library/user-event'; 3 | import { act } from 'react-dom/test-utils'; 4 | 5 | import { useEventListener } from '@/hooks/useEventListener'; 6 | 7 | const user = userEvent.setup(); 8 | 9 | const renderUseEventListener = (eventType: string, action: () => void) => 10 | renderHook(() => useEventListener(eventType, action), { 11 | initialProps: { eventType, action }, 12 | }); 13 | 14 | describe('useEventListener', () => { 15 | it('Calls action when given event happens', async () => { 16 | const action = jest.fn(); 17 | renderUseEventListener('click', action); 18 | await act(() => user.click(document.body)); 19 | expect(action).toHaveBeenCalledTimes(1); 20 | }); 21 | 22 | it('Does not call action when another event is given', async () => { 23 | const action = jest.fn(); 24 | renderUseEventListener('click', action); 25 | await act(() => user.keyboard('{Enter}')); 26 | expect(action).not.toHaveBeenCalled(); 27 | }); 28 | 29 | it('Removes event listener on unmount', () => { 30 | const removeEventListenerSpy = jest.spyOn(document, 'removeEventListener'); 31 | const { unmount } = renderUseEventListener('click', jest.fn()); 32 | expect(removeEventListenerSpy).not.toHaveBeenCalled(); 33 | unmount(); 34 | expect(removeEventListenerSpy).toHaveBeenCalledTimes(1); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/chore.yml: -------------------------------------------------------------------------------- 1 | name: Chore ✅ 2 | description: Create a none user-story issue (chore, tech issue, backend issue) 3 | labels: ["kind/chore", "status/draft"] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | Please make sure this chore hasn't been already submitted by someone by looking through other open/closed chore issues. 9 | 10 | - type: textarea 11 | id: description 12 | attributes: 13 | label: Description 14 | description: Give us a brief description of the work that needs to be done. 15 | validations: 16 | required: true 17 | 18 | - type: textarea 19 | id: additional-information 20 | attributes: 21 | label: Additional Information 22 | description: Add more details as need like links, architecture sketches etc. 23 | 24 | - type: textarea 25 | id: tasks 26 | attributes: 27 | label: Tasks 28 | description: Add tasks to be done as part of this issue. 29 | 30 | - type: textarea 31 | id: acceptance-criterias 32 | attributes: 33 | label: Acceptance Criterias 34 | description: Define the acceptance criterias that this user story should testet against (if relevant). 35 | 36 | - type: markdown 37 | attributes: 38 | value: | 39 | * Check the [Definition of Ready](https://docs.altinn.studio/community/devops/definition-of-ready/) if you need hints on what to include. 40 | * Remember to add the correct labels (status/*, team/*, org/*) -------------------------------------------------------------------------------- /src/hooks/useKeyboardEventListener.test.ts: -------------------------------------------------------------------------------- 1 | import { renderHook } from '@testing-library/react'; 2 | import userEvent from '@testing-library/user-event'; 3 | import { act } from 'react-dom/test-utils'; 4 | 5 | import { useKeyboardEventListener } from '@/hooks/useKeyboardEventListener'; 6 | 7 | const user = userEvent.setup(); 8 | 9 | const renderUseKeyboardEventListener = (key: string, onKeyDown: () => void) => 10 | renderHook(() => useKeyboardEventListener(key, onKeyDown), { 11 | initialProps: { key, onKeyDown }, 12 | }); 13 | 14 | describe('useKeyboardEventListener', () => { 15 | it('Calls onKeyDown when given key is pressed', async () => { 16 | const onKeyDown = jest.fn(); 17 | renderUseKeyboardEventListener('Enter', onKeyDown); 18 | await act(() => user.keyboard('{Enter}')); 19 | expect(onKeyDown).toHaveBeenCalledTimes(1); 20 | }); 21 | 22 | it('Does not call onKeyDown when another key is pressed', async () => { 23 | const onKeyDown = jest.fn(); 24 | renderUseKeyboardEventListener('ArrowUp', onKeyDown); 25 | await act(() => user.keyboard('{Enter}')); 26 | expect(onKeyDown).not.toHaveBeenCalled(); 27 | }); 28 | 29 | it('Removes event listener on unmount', () => { 30 | const removeEventListenerSpy = jest.spyOn(document, 'removeEventListener'); 31 | const { unmount } = renderUseKeyboardEventListener('Enter', jest.fn()); 32 | expect(removeEventListenerSpy).not.toHaveBeenCalled(); 33 | unmount(); 34 | expect(removeEventListenerSpy).toHaveBeenCalledTimes(1); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | If you make changes to any drawio, and re-export to SVG, add the following content inside the `svg` element to support viewing the SVG in both light and dark theme 2 | 3 | ``` 4 | 5 | <style type="text/css"> 6 | @media (prefers-color-scheme: dark) 7 | { 8 | :root {--light-color: #c9d1d9; --dark-color: #0d1117; } 9 | svg[style^="background-color:"] { background-color: var(--dark-color) !important; } 10 | g[filter="url(#dropShadow)"] { filter: none !important; } 11 | [stroke="rgb(0, 0, 0)"] { stroke: var(--light-color); } 12 | [stroke="rgb(255, 255, 255)"] { stroke: var(--dark-color); } 13 | [fill="rgb(0, 0, 0)"] { fill: var(--light-color); } 14 | [fill="rgb(255, 255, 255)"] { fill: var(--dark-color); } 15 | g[fill="rgb(0, 0, 0)"] text { fill: var(--light-color); } 16 | div[data-drawio-colors*="color: rgb(0, 0, 0)"] 17 | div { color: var(--light-color) !important; } 18 | div[data-drawio-colors*="border-color: rgb(0, 0, 0)"] 19 | { border-color: var(--light-color) !important; } 20 | div[data-drawio-colors*="border-color: rgb(0, 0, 0)"] 21 | div { border-color: var(--light-color) !important; } 22 | div[data-drawio-colors*="background-color: rgb(255, 255, 255)"] 23 | { background-color: var(--dark-color) !important; } 24 | div[data-drawio-colors*="background-color: rgb(255, 255, 255)"] 25 | div { background-color: var(--dark-color) !important; } 26 | } 27 | </style> 28 | 29 | ``` 30 | -------------------------------------------------------------------------------- /src/utils/numberFormat.test.tsx: -------------------------------------------------------------------------------- 1 | import { render as renderRtl, screen } from '@testing-library/react'; 2 | import React from 'react'; 3 | import type { 4 | NumericFormatProps, 5 | PatternFormatProps, 6 | } from 'react-number-format'; 7 | 8 | import { formatNumericText } from './numberFormat'; 9 | 10 | describe('numberFormat', () => { 11 | it('should render as NumericFormat if format is of type NumericFormatProps', () => { 12 | render({ 13 | text: '12345.6789', 14 | format: { 15 | prefix: 'NOK ', 16 | thousandSeparator: ' ', 17 | decimalSeparator: ',', 18 | decimalScale: 2, 19 | }, 20 | }); 21 | 22 | expect(screen.getByText('NOK 12 345,67')).toBeInTheDocument(); 23 | }); 24 | 25 | it('should render as PatternFormat if format is of type PatternFormatProps', () => { 26 | render({ 27 | text: '98765432', 28 | format: { 29 | format: '+47 ### ## ###', 30 | }, 31 | }); 32 | 33 | expect(screen.getByText('+47 987 65 432')).toBeInTheDocument(); 34 | }); 35 | 36 | it('should render as plain text if format is undefined', () => { 37 | render({ 38 | text: '12345.6789', 39 | }); 40 | 41 | expect(screen.getByText('12345.6789')).toBeInTheDocument(); 42 | }); 43 | }); 44 | 45 | interface FormatNumericTextProps { 46 | text: string; 47 | format?: NumericFormatProps | PatternFormatProps; 48 | } 49 | 50 | const render = (props: FormatNumericTextProps) => { 51 | return renderRtl(<span>{formatNumericText(props.text, props.format)}</span>); 52 | }; 53 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Copyright (c) 2017, Altinn 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | * Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | * Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | * Neither the name of Altinn nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /src/components/List/List.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { ComponentStory, ComponentMeta } from '@storybook/react'; 3 | 4 | import { StoryPage } from '@sb/StoryPage'; 5 | 6 | import { List } from './List'; 7 | import { ListItem } from './ListItem'; 8 | 9 | export default { 10 | component: List, 11 | parameters: { 12 | docs: { 13 | page: () => ( 14 | <StoryPage 15 | description={`TODO: Add a description (supports markdown)`} 16 | deprecationWarning={`Use List from @digdir/design-system-react instead.`} 17 | /> 18 | ), 19 | }, 20 | }, 21 | args: { 22 | //TODO: Add default args 23 | }, 24 | } as ComponentMeta<typeof List>; 25 | 26 | const Template: ComponentStory<typeof List> = (args) => { 27 | return ( 28 | <div> 29 | <List borderStyle={args.borderStyle}> 30 | <ListItem>List Item 1</ListItem> 31 | <ListItem>List Item 2</ListItem> 32 | <ListItem>List Item 3</ListItem> 33 | </List> 34 | </div> 35 | ); 36 | }; 37 | 38 | export const Solid = Template.bind({}); 39 | Solid.args = { 40 | borderStyle: 'solid', 41 | }; 42 | Solid.parameters = { 43 | docs: { 44 | description: { 45 | story: '', // TODO: add story description, supports markdown 46 | }, 47 | }, 48 | }; 49 | 50 | export const Dashed = Template.bind({}); 51 | Dashed.args = { 52 | borderStyle: 'dashed', 53 | }; 54 | Dashed.parameters = { 55 | docs: { 56 | description: { 57 | story: '', // TODO: add story description, supports markdown 58 | }, 59 | }, 60 | }; 61 | -------------------------------------------------------------------------------- /src/components/Panel/Panel.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { ComponentStory, ComponentMeta } from '@storybook/react'; 3 | 4 | import { StoryPage } from '@sb/StoryPage'; 5 | 6 | import { Panel, PanelVariant } from './Panel'; 7 | 8 | export default { 9 | component: Panel, 10 | parameters: { 11 | docs: { 12 | page: () => ( 13 | <StoryPage 14 | description={`TODO: Add a description (supports markdown)`} 15 | /> 16 | ), 17 | }, 18 | }, 19 | args: { 20 | title: 'Paneltittel', 21 | children: <div>Her kommer litt informasjon</div>, 22 | }, 23 | } as ComponentMeta<typeof Panel>; 24 | 25 | const Template: ComponentStory<typeof Panel> = (args) => <Panel {...args} />; 26 | 27 | export const Success = Template.bind({}); 28 | Success.args = { 29 | variant: PanelVariant.Success, 30 | }; 31 | Success.parameters = { 32 | docs: { 33 | description: { 34 | story: 'Suksessbeskrivelse', 35 | }, 36 | }, 37 | }; 38 | 39 | export const Info = Template.bind({}); 40 | Info.parameters = { 41 | docs: { 42 | description: { 43 | story: 'Infobeskrivelse', 44 | }, 45 | }, 46 | }; 47 | 48 | export const Warning = Template.bind({}); 49 | Warning.args = { 50 | variant: PanelVariant.Warning, 51 | }; 52 | Warning.parameters = { 53 | docs: { 54 | description: { 55 | story: 'Advarselsbeskrivelse', 56 | }, 57 | }, 58 | }; 59 | 60 | export const Error = Template.bind({}); 61 | Error.args = { 62 | variant: PanelVariant.Error, 63 | }; 64 | Error.parameters = { 65 | docs: { 66 | description: { 67 | story: 'Feilbeskrivelse', 68 | }, 69 | }, 70 | }; 71 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | const Path = require('node:path'); 4 | const NodePolyfillPlugin = require('node-polyfill-webpack-plugin'); 5 | const { 6 | logger 7 | } = require('@storybook/node-logger'); 8 | const AppSourceDir = Path.join(__dirname, '..', 'src'); 9 | const StorybookSourceDir = Path.join(__dirname); 10 | module.exports = { 11 | stories: ['../docs/**/*.mdx', '../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'], 12 | addons: ['@storybook/addon-links', '@storybook/addon-essentials', '@storybook/addon-interactions', '@storybook/addon-a11y', 'storybook-addon-turbo-build', 'storybook-dark-mode'], 13 | core: { 14 | disableTelemetry: true 15 | }, 16 | framework: { 17 | name: '@storybook/react-webpack5', 18 | options: {} 19 | }, 20 | webpackFinal: async config => { 21 | const svgRule = config.module.rules.find(rule => { 22 | return rule.test && rule.test.test('test.svg'); 23 | }); 24 | svgRule.exclude = [AppSourceDir]; 25 | config.module.rules.push({ 26 | test: /\.svg$/i, 27 | include: [AppSourceDir], 28 | use: [{ 29 | loader: '@svgr/webpack', 30 | options: { 31 | exportType: 'named' 32 | } 33 | }] 34 | }); 35 | config.resolve.alias = { 36 | ...config.resolve.alias, 37 | '@': AppSourceDir, 38 | '@sb': StorybookSourceDir 39 | }; 40 | config.plugins = [...config.plugins, new NodePolyfillPlugin()]; 41 | return config; 42 | }, 43 | managerWebpack: async config => { 44 | config.plugins = [...config.plugins, new NodePolyfillPlugin()]; 45 | return config; 46 | }, 47 | docs: { 48 | autodocs: true 49 | } 50 | }; 51 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import resolve from '@rollup/plugin-node-resolve'; 2 | import commonjs from '@rollup/plugin-commonjs'; 3 | import typescript from '@rollup/plugin-typescript'; 4 | import json from '@rollup/plugin-json'; 5 | import dts from 'rollup-plugin-dts'; 6 | import postcss from 'rollup-plugin-postcss'; 7 | import peerDepsExternal from 'rollup-plugin-peer-deps-external'; 8 | import svgr from '@svgr/rollup'; 9 | 10 | import terser from './rollup-terser.mjs'; 11 | import packageJson from './package.json' assert { type: 'json' }; 12 | 13 | // css files needs to be bundled 14 | const altinnFigmaTokensExceptCss = /@altinn\/figma-design-tokens.*(?<!css)$/; 15 | 16 | export default [ 17 | { 18 | input: 'src/index.ts', 19 | output: [ 20 | { 21 | file: packageJson.main, 22 | format: 'cjs', 23 | }, 24 | { 25 | file: packageJson.module, 26 | format: 'esm', 27 | }, 28 | ], 29 | external: [ 30 | altinnFigmaTokensExceptCss, 31 | /@react-hookz\/web/, 32 | /@radix-ui\/react-popover$/, 33 | /react-number-format/, 34 | /react-leaflet/, 35 | /leaflet/, 36 | /@navikt\/ds-icons/, 37 | ], 38 | plugins: [ 39 | peerDepsExternal(), 40 | resolve(), 41 | commonjs(), 42 | json(), 43 | typescript({ tsconfig: './tsconfig.json' }), 44 | svgr({ exportType: 'named' }), 45 | postcss(), 46 | terser(), 47 | ], 48 | }, 49 | { 50 | input: 'dist/types/src/index.d.ts', 51 | output: [{ file: 'dist/index.d.ts', format: 'esm' }], 52 | plugins: [dts()], 53 | external: [/@altinn\/figma-design-tokens/, /\.css$/], 54 | }, 55 | ]; 56 | -------------------------------------------------------------------------------- /src/components/_InputWrapper/utils.ts: -------------------------------------------------------------------------------- 1 | export enum InputVariant { 2 | Default = 'default', 3 | Error = 'error', 4 | Disabled = 'disabled', 5 | ReadOnlyInfo = 'readonly-info', 6 | ReadOnlyConfirm = 'readonly-confirm', 7 | } 8 | 9 | export enum ReadOnlyVariant { 10 | ReadOnlyInfo = 'readonly-info', 11 | ReadOnlyConfirm = 'readonly-confirm', 12 | } 13 | 14 | export enum IconVariant { 15 | None = 'none', 16 | Error = 'error', 17 | Search = 'search', 18 | } 19 | 20 | interface GetVariantProps { 21 | readOnly?: boolean | ReadOnlyVariant; 22 | disabled?: boolean; 23 | isValid?: boolean; 24 | isSearch?: boolean; 25 | } 26 | 27 | export const getVariant = ({ 28 | readOnly = false, 29 | disabled = false, 30 | isValid = true, 31 | isSearch = false, 32 | }: GetVariantProps = {}) => { 33 | let iconVar = IconVariant.None; 34 | 35 | if (isSearch) { 36 | iconVar = IconVariant.Search; 37 | } 38 | 39 | if (disabled) { 40 | return { 41 | variant: InputVariant.Disabled, 42 | iconVariant: iconVar, 43 | }; 44 | } else if (readOnly === true || readOnly === ReadOnlyVariant.ReadOnlyInfo) { 45 | return { 46 | variant: InputVariant.ReadOnlyInfo, 47 | iconVariant: iconVar, 48 | }; 49 | } else if (readOnly === ReadOnlyVariant.ReadOnlyConfirm) { 50 | return { 51 | variant: InputVariant.ReadOnlyConfirm, 52 | iconVariant: iconVar, 53 | }; 54 | } else if (isValid === false) { 55 | return { 56 | variant: InputVariant.Error, 57 | iconVariant: IconVariant.Error, 58 | }; 59 | } 60 | 61 | return { 62 | variant: InputVariant.Default, 63 | iconVariant: iconVar, 64 | }; 65 | }; 66 | -------------------------------------------------------------------------------- /src/components/SvgIcon/Stories/SvgIcon.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { ComponentStory, ComponentMeta } from '@storybook/react'; 3 | import { Success as SuccessIconNAV } from '@navikt/ds-icons'; 4 | 5 | import { StoryPage } from '@sb/StoryPage'; 6 | 7 | import { SvgIcon } from '..'; 8 | 9 | import { ReactComponent as SuccessIcon } from './success.svg'; 10 | 11 | export default { 12 | component: SvgIcon, 13 | parameters: { 14 | docs: { 15 | page: () => ( 16 | <StoryPage 17 | description={`TODO: Add a description (supports markdown)`} 18 | /> 19 | ), 20 | }, 21 | }, 22 | args: {}, 23 | } as ComponentMeta<typeof SvgIcon>; 24 | 25 | const Template: ComponentStory<typeof SvgIcon> = (args) => ( 26 | <SvgIcon {...args} /> 27 | ); 28 | 29 | export const IconFromNavIconLibrary = Template.bind({}); 30 | IconFromNavIconLibrary.args = { 31 | svgIconComponent: <SuccessIconNAV />, 32 | }; 33 | IconFromNavIconLibrary.parameters = { 34 | docs: { 35 | description: { 36 | story: '`<SvgIcon svgIconComponent: <SuccessIconNAV />`', 37 | }, 38 | }, 39 | }; 40 | 41 | export const ImportedCustomIcon = Template.bind({}); 42 | ImportedCustomIcon.args = { 43 | svgIconComponent: ( 44 | <SuccessIcon 45 | height='2rem' 46 | width='2rem' 47 | /> 48 | ), 49 | }; 50 | ImportedCustomIcon.parameters = { 51 | docs: { 52 | description: { 53 | story: 54 | 'Import a single SVG file as a react component and pass the component to the `svgIconComponent` prop.' + 55 | "`import { ReactComponent as SuccessIcon } from './success.svg';` " + 56 | "`<SvgIcon svgIconComponent={<SuccessIcon height='2rem' width='2rem' />} />`", 57 | }, 58 | }, 59 | }; 60 | -------------------------------------------------------------------------------- /src/components/CircularProgress/CircularProgress.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen } from '@testing-library/react'; 3 | 4 | import { CircularProgress } from './CircularProgress'; 5 | 6 | describe('CircularProgress', () => { 7 | it('should render progressbar', () => { 8 | render( 9 | <CircularProgress 10 | id='progress' 11 | value={25} 12 | />, 13 | ); 14 | screen.getByRole('progressbar'); 15 | }); 16 | it('should render progressbar with ariaLabel', () => { 17 | render( 18 | <CircularProgress 19 | id='progress' 20 | value={25} 21 | ariaLabel={'Test label'} 22 | />, 23 | ); 24 | const progressbar = screen.getByRole('progressbar', { 25 | name: /Test label/i, 26 | }); 27 | expect(progressbar).toHaveAccessibleName('Test label'); 28 | }); 29 | it('should render progressbar with name from label', () => { 30 | render( 31 | <CircularProgress 32 | id='progress' 33 | value={25} 34 | label={'Test label'} 35 | />, 36 | ); 37 | const progressbar = screen.getByRole('progressbar', { 38 | name: /Test label/i, 39 | }); 40 | expect(progressbar).toHaveAccessibleName('Test label'); 41 | }); 42 | it('should render progressbar with name from aria when label and aria is defined', () => { 43 | render( 44 | <CircularProgress 45 | id='progress' 46 | value={25} 47 | ariaLabel={'Test label from aria'} 48 | label={'Test label from label'} 49 | />, 50 | ); 51 | const progressbar = screen.getByRole('progressbar', { 52 | name: /Test label from aria/i, 53 | }); 54 | expect(progressbar).toHaveAccessibleName('Test label from aria'); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /src/components/Page/Page.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { ComponentStory, ComponentMeta } from '@storybook/react'; 3 | 4 | import { StoryPage } from '@sb/StoryPage'; 5 | import { ReactComponent as DataIcon } from '@/assets/Data.svg'; 6 | 7 | import { PageColor, PageSize } from './Context'; 8 | import { PageContent } from './PageContent'; 9 | import { PageHeader } from './PageHeader'; 10 | import { Page } from './Page'; 11 | 12 | export default { 13 | component: Page, 14 | parameters: { 15 | docs: { 16 | page: () => ( 17 | <StoryPage 18 | description={`TODO: Add a description (supports markdown)`} 19 | /> 20 | ), 21 | }, 22 | }, 23 | args: { 24 | //TODO: Add default args 25 | }, 26 | } as ComponentMeta<typeof Page>; 27 | 28 | const Template: ComponentStory<typeof Page> = (args) => { 29 | return ( 30 | <div style={{ width: '600px' }}> 31 | <Page 32 | color={args.color} 33 | size={args.size} 34 | > 35 | <PageHeader icon={<DataIcon />}>PageHeader</PageHeader> 36 | <PageContent>PageContent</PageContent> 37 | </Page> 38 | </div> 39 | ); 40 | }; 41 | 42 | export const Primary = Template.bind({}); 43 | Primary.args = { 44 | color: PageColor.Primary, 45 | size: PageSize.Medium, 46 | }; 47 | Primary.parameters = { 48 | docs: { 49 | description: { 50 | story: '', // TODO: add story description, supports markdown 51 | }, 52 | }, 53 | }; 54 | 55 | export const Success = Template.bind({}); 56 | Success.args = { 57 | color: PageColor.Success, 58 | size: PageSize.Medium, 59 | }; 60 | Success.parameters = { 61 | docs: { 62 | description: { 63 | story: '', // TODO: add story description, supports markdown 64 | }, 65 | }, 66 | }; 67 | -------------------------------------------------------------------------------- /src/components/CircularProgress/CircularProgress.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { tokens } from '@/DesignTokens'; 4 | 5 | import classes from './CircularProgress.module.css'; 6 | 7 | export interface CircularProgressProps { 8 | id: string; 9 | value: number; 10 | width?: number; 11 | strokeWidth?: number; 12 | ariaLabel?: string; 13 | label?: string; 14 | } 15 | 16 | export const CircularProgress = ({ 17 | value, 18 | width = 70, 19 | strokeWidth = 2.5, 20 | ariaLabel, 21 | label, 22 | id, 23 | }: CircularProgressProps) => { 24 | const labelId = `${id}-label`; 25 | const ariaLabelledby = !ariaLabel && label ? labelId : undefined; 26 | return ( 27 | <div 28 | id={id} 29 | className={classes.container} 30 | style={{ width: `${width}px` }} 31 | role='progressbar' 32 | aria-labelledby={ariaLabelledby} 33 | aria-label={ariaLabel} 34 | > 35 | {label && ( 36 | <div 37 | id={labelId} 38 | className={classes.label} 39 | > 40 | {label} 41 | </div> 42 | )} 43 | <svg 44 | className={classes.svg} 45 | viewBox='0 0 35.8 35.8' 46 | aria-hidden={true} 47 | > 48 | <Circle 49 | stroke={tokens.ColorsBlue200} 50 | strokeWidth={strokeWidth} 51 | /> 52 | <Circle 53 | strokeWidth={strokeWidth} 54 | stroke={tokens.ColorsBlue900} 55 | strokeDashoffset={100 - value} 56 | strokeDasharray={'100 100'} 57 | className={classes.circleTransition} 58 | /> 59 | </svg> 60 | </div> 61 | ); 62 | }; 63 | 64 | const Circle = (props: React.SVGProps<SVGCircleElement>) => { 65 | return ( 66 | <circle 67 | cx='50%' 68 | cy='50%' 69 | fill='none' 70 | r='15.9155' 71 | {...props} 72 | /> 73 | ); 74 | }; 75 | -------------------------------------------------------------------------------- /src/components/Panel/PopoverPanel.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import * as RadixPopover from '@radix-ui/react-popover'; 3 | import cn from 'classnames'; 4 | 5 | import { Panel, PanelVariant } from './Panel'; 6 | import type { PanelProps, RenderArrowProps } from './Panel'; 7 | import classes from './PopoverPanel.module.css'; 8 | 9 | export interface PopoverPanelProps 10 | extends PanelProps, 11 | Required<Pick<RadixPopover.PopoverProps, 'open' | 'onOpenChange'>>, 12 | Pick<RadixPopover.PopoverContentProps, 'side'> { 13 | children: React.ReactNode; 14 | trigger: React.ReactNode; 15 | } 16 | 17 | const renderArrow = ({ children }: RenderArrowProps) => { 18 | return ( 19 | <RadixPopover.Arrow 20 | className={cn(classes['popover-panel__arrow'])} 21 | asChild 22 | > 23 | {children} 24 | </RadixPopover.Arrow> 25 | ); 26 | }; 27 | 28 | export const PopoverPanel = ({ 29 | children, 30 | variant = PanelVariant.Warning, 31 | trigger, 32 | side = 'top', 33 | title, 34 | showIcon, 35 | forceMobileLayout, 36 | showPointer = true, 37 | onOpenChange, 38 | open = false, 39 | }: PopoverPanelProps) => { 40 | return ( 41 | <RadixPopover.Root 42 | open={open} 43 | onOpenChange={onOpenChange} 44 | > 45 | <RadixPopover.Trigger 46 | asChild 47 | role='button' 48 | > 49 | {trigger} 50 | </RadixPopover.Trigger> 51 | <RadixPopover.Content 52 | side={side} 53 | className={classes['popover-panel']} 54 | > 55 | <Panel 56 | title={title} 57 | showIcon={showIcon} 58 | forceMobileLayout={forceMobileLayout} 59 | showPointer={showPointer} 60 | variant={variant} 61 | renderArrow={renderArrow} 62 | > 63 | {children} 64 | </Panel> 65 | </RadixPopover.Content> 66 | </RadixPopover.Root> 67 | ); 68 | }; 69 | 70 | export default PopoverPanel; 71 | -------------------------------------------------------------------------------- /src/hooks/useMediaQuery.test.ts: -------------------------------------------------------------------------------- 1 | import { renderHook } from '@testing-library/react'; 2 | 3 | import { useMediaQuery } from '@/hooks/useMediaQuery'; 4 | 5 | // Test data: 6 | const query = '(min-width: 600px)'; 7 | 8 | describe('useMediaQuery', () => { 9 | afterEach(() => jest.resetAllMocks()); 10 | 11 | it.each([true, false])( 12 | 'Returns value from window.matchMedia.matches when it is %s', 13 | (matches) => { 14 | const matchMediaValue = matchMediaValueMock({ matches }); 15 | const { result } = renderHook(() => useMediaQuery(query)); 16 | expect(matchMediaValue).toHaveBeenCalledWith(query); 17 | expect(result.current).toBe(matches); 18 | }, 19 | ); 20 | 21 | it('Adds event listener', () => { 22 | const addEventListener = jest.fn(); 23 | matchMediaValueMock({ addEventListener }); 24 | renderHook(() => useMediaQuery(query)); 25 | expect(addEventListener).toHaveBeenCalledTimes(1); 26 | }); 27 | 28 | it('Removes the event listener on unmount', () => { 29 | const removeEventListener = jest.fn(); 30 | matchMediaValueMock({ removeEventListener }); 31 | const { unmount } = renderHook(() => useMediaQuery(query)); 32 | expect(removeEventListener).not.toHaveBeenCalled(); 33 | unmount(); 34 | expect(removeEventListener).toHaveBeenCalledTimes(1); 35 | }); 36 | }); 37 | 38 | const matchMediaValueMock = ({ 39 | matches, 40 | addEventListener, 41 | removeEventListener, 42 | }: Partial<{ 43 | matches: boolean; 44 | addEventListener: jest.Mock; 45 | removeEventListener: jest.Mock; 46 | }>) => { 47 | const value = jest.fn().mockImplementation((query) => ({ 48 | matches: matches ?? false, 49 | media: query, 50 | onchange: null, 51 | addEventListener: addEventListener ?? jest.fn(), 52 | removeEventListener: removeEventListener ?? jest.fn(), 53 | dispatchEvent: jest.fn(), 54 | })); 55 | Object.defineProperty(window, 'matchMedia', { writable: true, value }); 56 | return value; 57 | }; 58 | -------------------------------------------------------------------------------- /src/components/SearchField/SearchField.test.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | render as renderRtl, 3 | screen, 4 | createEvent, 5 | fireEvent, 6 | } from '@testing-library/react'; 7 | import React from 'react'; 8 | import userEvent from '@testing-library/user-event'; 9 | 10 | import type { SearchFieldProps } from './SearchField'; 11 | import { SearchField } from './SearchField'; 12 | 13 | const user = userEvent.setup(); 14 | 15 | describe('SearchField', () => { 16 | it('should trigger onPaste when pasting into input', () => { 17 | const handlePaste = jest.fn(); 18 | render({ 19 | onPaste: handlePaste, 20 | }); 21 | 22 | const element = screen.getByRole('textbox'); 23 | const paste = createEvent.paste(element, { 24 | clipboardData: { 25 | getData: () => 'hello world', 26 | }, 27 | }); 28 | 29 | fireEvent(element, paste); 30 | 31 | expect(handlePaste).toHaveBeenCalledTimes(1); 32 | }); 33 | 34 | it('should trigger onBlur event when field loses focus', async () => { 35 | const handleChange = jest.fn(); 36 | render({ onBlur: handleChange }); 37 | 38 | const element = screen.getByRole('textbox'); 39 | await user.click(element); 40 | expect(element).toHaveFocus(); 41 | await user.tab(); 42 | 43 | expect(handleChange).toHaveBeenCalledTimes(1); 44 | }); 45 | 46 | it('should trigger onChange event for each keystroke', async () => { 47 | const handleChange = jest.fn(); 48 | render({ onChange: handleChange }); 49 | 50 | const element = screen.getByRole('textbox'); 51 | await user.click(element); 52 | expect(element).toHaveFocus(); 53 | await user.keyboard('test'); 54 | 55 | expect(handleChange).toHaveBeenCalledTimes(4); 56 | }); 57 | }); 58 | 59 | const render = (props: Partial<SearchFieldProps> = {}) => { 60 | const allProps = { 61 | id: 'id', 62 | onChange: jest.fn(), 63 | ...props, 64 | } as SearchFieldProps; 65 | 66 | return renderRtl(<SearchField {...allProps} />); 67 | }; 68 | -------------------------------------------------------------------------------- /src/components/Page/Page.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render as renderRtl, screen } from '@testing-library/react'; 3 | 4 | import { ReactComponent as DataIcon } from '@/assets/Data.svg'; 5 | 6 | import type { PageProps } from './Page'; 7 | import { Page } from './Page'; 8 | import { PageHeader } from './PageHeader'; 9 | import { PageContent } from './PageContent'; 10 | import { PageColor, PageSize } from './Context'; 11 | 12 | const render = (props: Partial<PageProps> = {}) => { 13 | const allProps = { 14 | title: 'Panel title', 15 | children: ( 16 | <> 17 | <PageHeader icon={<DataIcon />}>PageHeader</PageHeader> 18 | <PageContent> PageContent</PageContent> 19 | </> 20 | ), 21 | ...props, 22 | }; 23 | 24 | renderRtl(<Page {...allProps} />); 25 | }; 26 | 27 | describe('Page', () => { 28 | Object.values(PageColor).forEach((color) => { 29 | it(`should render a PageHeader and PageContent with ${color} classname when color is ${color}`, () => { 30 | render({ color }); 31 | const otherColors = Object.values(PageColor).filter((c) => c !== color); 32 | 33 | const pageHeader = screen.getByRole('banner'); 34 | 35 | expect(pageHeader.classList.contains(`page-header--${color}`)).toBe(true); 36 | otherColors.forEach((c) => { 37 | expect(pageHeader.classList.contains(`page-header--${c}`)).toBe(false); 38 | }); 39 | }); 40 | }); 41 | 42 | Object.values(PageSize).forEach((size) => { 43 | it(`should render a PageHeader with ${size} classname when size is ${size}`, () => { 44 | render({ size }); 45 | const otherSizes = Object.values(PageSize).filter((s) => s !== size); 46 | 47 | const pageHeader = screen.getByRole('banner'); 48 | 49 | expect(pageHeader.classList.contains(`page-header--${size}`)).toBe(true); 50 | otherSizes.forEach((s) => { 51 | expect(pageHeader.classList.contains(`page-header--${s}`)).toBe(false); 52 | }); 53 | }); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /src/components/Pagination/Pagination.stories.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import type { ComponentStory, ComponentMeta } from '@storybook/react'; 3 | 4 | import { StoryPage } from '@sb/StoryPage'; 5 | 6 | import type { DescriptionText } from './Pagination'; 7 | import { Pagination } from './Pagination'; 8 | 9 | /** 10 | * Do not use these directly. They are exported here for re-use in Storyboard, but you should supply your own 11 | * when working with Pagination. 12 | */ 13 | export const descriptionTexts: DescriptionText = { 14 | rowsPerPage: 'Rader per side', 15 | of: 'av', 16 | navigateFirstPage: 'Naviger til første side i tabell', 17 | previousPage: 'Forrige side i tabell', 18 | nextPage: 'Neste side i tabell', 19 | navigateLastPage: 'Naviger til siste side i tabell', 20 | }; 21 | 22 | export default { 23 | component: Pagination, 24 | parameters: { 25 | docs: { 26 | page: () => ( 27 | <StoryPage 28 | description={`TODO: Add a description (supports markdown)`} 29 | /> 30 | ), 31 | }, 32 | }, 33 | args: { 34 | //TODO: Add default args 35 | }, 36 | } as ComponentMeta<typeof Pagination>; 37 | 38 | const Template: ComponentStory<typeof Pagination> = (args) => { 39 | const [rowsPerPage, setRowsPerPage] = useState(5); 40 | const [page, setPage] = useState(0); 41 | 42 | const handleChangeRowsPerPage = ( 43 | event: React.ChangeEvent<HTMLSelectElement>, 44 | ) => { 45 | setRowsPerPage(parseInt(event.target.value, 10)); 46 | setPage(0); 47 | }; 48 | 49 | return ( 50 | <Pagination 51 | {...args} 52 | rowsPerPage={rowsPerPage} 53 | currentPage={page} 54 | setCurrentPage={setPage} 55 | onRowsPerPageChange={handleChangeRowsPerPage} 56 | descriptionTexts={descriptionTexts} 57 | /> 58 | ); 59 | }; 60 | 61 | export const Example = Template.bind({}); 62 | Example.args = { 63 | numberOfRows: 200, 64 | rowsPerPageOptions: [5, 10, 15, 20], 65 | }; 66 | Example.parameters = { 67 | docs: { 68 | description: { 69 | story: '', // TODO: add story description, supports markdown 70 | }, 71 | }, 72 | }; 73 | -------------------------------------------------------------------------------- /src/components/_InputWrapper/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getVariant, 3 | InputVariant, 4 | IconVariant, 5 | ReadOnlyVariant, 6 | } from './utils'; 7 | 8 | describe('Textfield utils', () => { 9 | describe('getVariant', () => { 10 | it('should return Default inputvariant and no icon by default', () => { 11 | const result = getVariant(); 12 | expect(result).toEqual({ 13 | variant: InputVariant.Default, 14 | iconVariant: IconVariant.None, 15 | }); 16 | }); 17 | 18 | it('should return disabled inputvariant and no icon when disabled is true', () => { 19 | const result = getVariant({ disabled: true }); 20 | expect(result).toEqual({ 21 | variant: InputVariant.Disabled, 22 | iconVariant: IconVariant.None, 23 | }); 24 | }); 25 | 26 | it('should return ReadOnlyInfo inputvariant and no icon when readonly is true', () => { 27 | const result = getVariant({ readOnly: true }); 28 | expect(result).toEqual({ 29 | variant: InputVariant.ReadOnlyInfo, 30 | iconVariant: IconVariant.None, 31 | }); 32 | }); 33 | 34 | it('should return ReadOnlyInfo inputvariant and no icon when readonly is ReadOnlyVariant.ReadOnlyInfo', () => { 35 | const result = getVariant({ readOnly: ReadOnlyVariant.ReadOnlyInfo }); 36 | expect(result).toEqual({ 37 | variant: InputVariant.ReadOnlyInfo, 38 | iconVariant: IconVariant.None, 39 | }); 40 | }); 41 | 42 | it('should return ReadOnlyConfirm inputvariant and no icon when readonly is ReadOnlyVariant.ReadOnlyConfirm', () => { 43 | const result = getVariant({ readOnly: ReadOnlyVariant.ReadOnlyConfirm }); 44 | expect(result).toEqual({ 45 | variant: InputVariant.ReadOnlyConfirm, 46 | iconVariant: IconVariant.None, 47 | }); 48 | }); 49 | 50 | it('should return ReadOnlyConfirm inputvariant and Error icon when isValid is false', () => { 51 | const result = getVariant({ isValid: false }); 52 | expect(result).toEqual({ 53 | variant: InputVariant.Error, 54 | iconVariant: IconVariant.Error, 55 | }); 56 | }); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /src/components/_InputWrapper/InputWrapper.tsx: -------------------------------------------------------------------------------- 1 | import React, { useId } from 'react'; 2 | import type { ReactNode } from 'react'; 3 | import cn from 'classnames'; 4 | 5 | import type { ReadOnlyVariant, InputVariant } from './utils'; 6 | import { getVariant } from './utils'; 7 | import { Icon } from './Icon'; 8 | import classes from './InputWrapper.module.css'; 9 | 10 | type InputRendererProps = { 11 | className: string; 12 | inputId: string; 13 | variant: InputVariant; 14 | }; 15 | 16 | export interface InputWrapperProps { 17 | isValid?: boolean; 18 | disabled?: boolean; 19 | readOnly?: boolean | ReadOnlyVariant; 20 | isSearch?: boolean; 21 | label?: string; 22 | noFocusEffect?: boolean; 23 | noPadding?: boolean; 24 | inputId?: string; 25 | inputRenderer: (props: InputRendererProps) => ReactNode; 26 | } 27 | 28 | export const InputWrapper = ({ 29 | isValid = true, 30 | disabled = false, 31 | readOnly = false, 32 | isSearch = false, 33 | label, 34 | inputId, 35 | inputRenderer, 36 | noFocusEffect, 37 | noPadding, 38 | }: InputWrapperProps) => { 39 | const randomInputId = useId(); 40 | const givenOrRandomInputId = inputId ?? randomInputId; 41 | 42 | const { variant, iconVariant } = getVariant({ 43 | readOnly, 44 | disabled, 45 | isValid, 46 | isSearch, 47 | }); 48 | 49 | return ( 50 | <> 51 | {label && ( 52 | <label 53 | data-testid='InputWrapper-label' 54 | className={cn(classes['InputWrapper__label'])} 55 | htmlFor={givenOrRandomInputId} 56 | > 57 | {label} 58 | </label> 59 | )} 60 | <div 61 | data-testid='InputWrapper' 62 | className={cn( 63 | classes['InputWrapper'], 64 | classes[`InputWrapper--${variant}`], 65 | { 66 | [classes[`InputWrapper--search`]]: isSearch, 67 | [classes[`InputWrapper--with-focus-effect`]]: !noFocusEffect, 68 | [classes[`InputWrapper--with-padding`]]: !noPadding, 69 | }, 70 | )} 71 | > 72 | <Icon 73 | variant={iconVariant} 74 | disabled={disabled} 75 | /> 76 | {inputRenderer({ 77 | className: classes['InputWrapper__field'], 78 | inputId: givenOrRandomInputId, 79 | variant, 80 | })} 81 | </div> 82 | </> 83 | ); 84 | }; 85 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | release_type: 6 | description: "The type of release (one of 'patch' or 'minor')" 7 | required: true 8 | default: patch 9 | type: choice 10 | options: 11 | - patch 12 | - minor 13 | 14 | jobs: 15 | build_release: 16 | name: Build & release 17 | runs-on: ubuntu-20.04 18 | permissions: 19 | contents: write 20 | packages: write 21 | steps: 22 | - name: Checkout 23 | uses: actions/checkout@v3 24 | with: 25 | ref: ${{ github.head_ref }} 26 | 27 | - name: Set up Node 28 | uses: actions/setup-node@v3 29 | with: 30 | node-version: "16.x" 31 | registry-url: "https://npm.pkg.github.com" 32 | 33 | - name: Install dependencies 34 | env: 35 | GITHUB_PACKAGES_PAT: ${{ secrets.GITHUB_TOKEN }} 36 | run: yarn install --immutable 37 | 38 | - name: Build 39 | run: yarn run build 40 | 41 | - name: Prepare release version 42 | # This step runs if the trigger is run manually; the version is created by calling `yarn release` 43 | if: ${{ ! github.event.after }} 44 | run: | 45 | git config --global user.name 'github-actions[bot]' 46 | git config --global user.email 'github-actions[bot]@users.noreply.github.com' 47 | npm version ${{ github.event.inputs.release_type }} 48 | echo "release_version=`git describe`" >> $GITHUB_ENV 49 | git push --follow-tags 50 | 51 | - name: Publish npm package 52 | run: yarn npm publish --access public 53 | env: 54 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 55 | 56 | - name: Create GitHub release 57 | uses: actions/create-release@v1 58 | env: 59 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 60 | with: 61 | tag_name: ${{ env.release_version }} 62 | release_name: Release ${{ env.release_version }} 63 | draft: false 64 | prerelease: false 65 | 66 | - name: Build Storybook 67 | run: yarn build-storybook 68 | 69 | - name: Deploy Storybook 70 | uses: JamesIves/github-pages-deploy-action@v4.4.2 71 | with: 72 | branch: gh-pages 73 | folder: storybook-static # Source folder (output from build step) 74 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/new_component.yml: -------------------------------------------------------------------------------- 1 | name: New component 💠 2 | description: I need design to specify and create tokens for a new component 3 | labels: ["ux", "status/ready-for-specification"] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | Please check if we have an issue for the component you are about to add already! 9 | 10 | - type: textarea 11 | id: description 12 | attributes: 13 | label: Describe the component 14 | description: If applicable link to relevant components in other design systems and include screenshots 15 | validations: 16 | required: true 17 | 18 | - type: markdown 19 | attributes: 20 | value: | 21 | Check if any tasks beneath here are not relevant, if so remove them or add tasks if something is missing 22 | 23 | - type: textarea 24 | id: figma 25 | attributes: 26 | label: Figma 27 | value: | 28 | _Link to correct placement in our Figma component library._ 29 | - [ ] Design is fully specified in Figma 30 | - [ ] Design component with all relevant states (And think ) 31 | - [ ] Do a WCAG test of the sketches 32 | - [ ] Functionality/animation is described and prototyped if relevant 33 | 34 | - type: textarea 35 | id: tokens 36 | attributes: 37 | label: Tokens 38 | value: | 39 | - [ ] Tokens are published and ready for use 40 | - [ ] The size is described with tokens 41 | - [ ] spacing is described with tokens 42 | - [ ] The color of all states are described with tokens 43 | - [ ] Border width or styling is described with tokens 44 | - [ ] Text size are described with tokens 45 | 46 | ``` 47 | Replace with size tokens 48 | 49 | Replace with padding 50 | 51 | Replace with colors 52 | 53 | Replace with border width 54 | 55 | Replace with text 56 | ``` 57 | 58 | - type: textarea 59 | id: guidelines 60 | attributes: 61 | label: Guidelines 62 | value: | 63 | _Not necessarry for development to start_ 64 | 65 | - [ ] Guidelines are ready 66 | - [ ] Write/update guidelines 67 | - [ ] Do's and don'ts 68 | - [ ] Examples 69 | 70 | - type: textarea 71 | id: svg 72 | attributes: 73 | label: SVG 74 | value: | 75 | - [ ] If there are used any svgs in this component is it available for the developer? 76 | 77 | -------------------------------------------------------------------------------- /scripts/add-component.mjs: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | import path from 'node:path'; 3 | import fs from 'node:fs/promises'; 4 | import { fileURLToPath } from 'node:url'; 5 | 6 | import yargs from 'yargs'; 7 | import { hideBin } from 'yargs/helpers'; 8 | 9 | const resolvePath = (...args) => { 10 | const __filename = fileURLToPath(import.meta.url); 11 | const __dirname = path.dirname(__filename); 12 | 13 | return path.join(__dirname, '..', ...args); 14 | }; 15 | 16 | const ensureFirstLetterIsUpperCase = (str) => 17 | str[0].toUpperCase() + str.slice(1); 18 | 19 | const init = async () => { 20 | const componentName = parseArgs(); 21 | 22 | await bootstrapComponent({ 23 | componentName: ensureFirstLetterIsUpperCase(componentName), 24 | }); 25 | }; 26 | 27 | const generateFiles = async ({ componentName, src, dest }) => { 28 | const replace = /_COMPONENT_/g; 29 | const replaceWith = componentName; 30 | 31 | const filesToCopy = await fs.readdir(src); 32 | 33 | for (const file of filesToCopy) { 34 | const templateFile = (await fs.readFile(path.join(src, file))).toString(); 35 | const result = templateFile.replace(replace, replaceWith); 36 | 37 | await fs.writeFile( 38 | path.join(dest, file.replace(replace, replaceWith)), 39 | result, 40 | ); 41 | } 42 | }; 43 | 44 | const updateIndex = async ({ componentName }) => { 45 | const indexFile = resolvePath('/src/components/index.ts'); 46 | 47 | const entry = `export { ${componentName} } from './${componentName}';\n`; 48 | 49 | await fs.appendFile(indexFile, entry); 50 | }; 51 | 52 | const bootstrapComponent = async ({ componentName }) => { 53 | const src = resolvePath('/scripts/templates/Component'); 54 | const dest = resolvePath(`/src/components/${componentName}`); 55 | 56 | try { 57 | await fs.access(dest); 58 | console.log(`Component "${componentName}" already exists.`); 59 | process.exit(-1); 60 | } catch (error) { 61 | if (error.code === 'ENOENT') { 62 | await fs.mkdir(dest); 63 | await generateFiles({ componentName, src, dest }); 64 | await updateIndex({ componentName }); 65 | console.log(`Component "${componentName}" created.`); 66 | } else { 67 | console.log(error); 68 | process.exit(-1); 69 | } 70 | } 71 | }; 72 | 73 | const parseArgs = () => { 74 | const { _: commands } = yargs(hideBin(process.argv)) 75 | .wrap(null) 76 | .usage( 77 | `yarn add-component <name> 78 | 79 | Create a new component with the given <name>. The name should use PascalCase. 80 | `, 81 | ) 82 | .demandCommand(1, 1).argv; 83 | 84 | return commands[0]; 85 | }; 86 | 87 | init(); 88 | -------------------------------------------------------------------------------- /src/components/_InputWrapper/InputWrapper.module.css: -------------------------------------------------------------------------------- 1 | .InputWrapper { 2 | --background: var(--component-input-color-background-default); 3 | --border-color: var(--component-input-color-border-default); 4 | --outline-color: var(--component-input-color-outline-focus); 5 | --icon-background: transparent; 6 | --paddingY: 0; 7 | --paddingX: 0; 8 | background: var(--background); 9 | border-width: var(--component-input-border_width-default); 10 | border-radius: var(--interactive_components-border_radius-normal); 11 | border-style: solid; 12 | box-sizing: border-box; 13 | display: flex; 14 | align-items: stretch; 15 | border-color: var(--border-color); 16 | } 17 | 18 | .InputWrapper--with-focus-effect:focus-within { 19 | outline: var(--outline-color) auto var(--border_width-thin); 20 | outline-offset: calc(var(--border_width-thin) + var(--border_width-standard)); 21 | } 22 | 23 | .InputWrapper--default:hover { 24 | --border-color: var(--component-input-color-border-hover); 25 | } 26 | 27 | .InputWrapper--error { 28 | --icon-background: var(--component-input-error-color-border-default); 29 | --border-color: var(--component-input-error-color-border-default); 30 | } 31 | 32 | .InputWrapper--error:hover { 33 | --icon-background: var(--component-input-error-color-border-hover); 34 | --border-color: var(--component-input-error-color-border-hover); 35 | } 36 | 37 | .InputWrapper--disabled { 38 | --background: repeating-linear-gradient( 39 | 135deg, 40 | #efefef, 41 | #efefef 2px, 42 | #fff 3px, 43 | #fff 5px 44 | ); 45 | --border-color: var(--component-input-disabled-color-border-default); 46 | } 47 | 48 | .InputWrapper--readonly-info { 49 | --background: var(--component-input-read_only_info-color-background-default); 50 | --border-color: var(--component-input-read_only_info-color-border-default); 51 | } 52 | 53 | .InputWrapper--readonly-confirm { 54 | --background: var( 55 | --component-input-read_only_confirm-color-background-default 56 | ); 57 | --border-color: var(--component-input-read_only_confirm-color-border-default); 58 | } 59 | 60 | .InputWrapper--search { 61 | flex-direction: row-reverse; 62 | } 63 | 64 | .InputWrapper--with-padding { 65 | /* Subtract size of border on padding-y, because border is on outer element. Without this, height of entire component will be too big */ 66 | --paddingY: calc( 67 | var(--component-input-space-padding-y) - 68 | var(--component-input-border_width-default) 69 | ); 70 | --paddingX: var(--component-input-space-padding-x); 71 | } 72 | 73 | .InputWrapper__field { 74 | box-sizing: border-box; 75 | border: none; 76 | outline: none; 77 | padding: var(--paddingY) var(--paddingX); 78 | width: 100%; 79 | background: var(--background); 80 | } 81 | 82 | .InputWrapper__icon { 83 | background: var(--icon-background); 84 | padding-right: var(--component-input-border_width-focus); 85 | padding-left: var(--component-input-border_width-default); 86 | display: flex; 87 | align-items: center; 88 | flex: none; 89 | } 90 | 91 | .InputWrapper__icon--disabled { 92 | opacity: 60%; 93 | } 94 | 95 | .InputWrapper__label { 96 | font-weight: var(--component-label-font_weight-default); 97 | padding: 0; 98 | } 99 | -------------------------------------------------------------------------------- /src/components/Pagination/Pagination.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render as renderRtl, screen } from '@testing-library/react'; 3 | import userEvent from '@testing-library/user-event'; 4 | 5 | import type { PaginationProps } from './Pagination'; 6 | import { Pagination } from './Pagination'; 7 | 8 | const user = userEvent.setup(); 9 | 10 | const render = (props: Partial<PaginationProps> = {}) => { 11 | renderRtl( 12 | <Pagination 13 | {...defaultProps} 14 | {...props} 15 | />, 16 | ); 17 | }; 18 | const descriptionText = { 19 | rowsPerPage: 'Rader per side', 20 | of: 'av', 21 | navigateFirstPage: 'Naviger til første side i tabell', 22 | previousPage: 'Forrige side i tabell', 23 | nextPage: 'Neste side i tabell', 24 | navigateLastPage: 'Naviger til siste side i tabell', 25 | }; 26 | 27 | const defaultProps: PaginationProps = { 28 | numberOfRows: 20, 29 | rowsPerPageOptions: [5, 10, 15, 20], 30 | rowsPerPage: 5, 31 | onRowsPerPageChange: jest.fn(), 32 | currentPage: 1, 33 | setCurrentPage: jest.fn(), 34 | descriptionTexts: descriptionText, 35 | }; 36 | 37 | describe('Pagination', () => { 38 | it('should call onRowsPerPageChange when option in select is clicked', async () => { 39 | const onRowsPerPageChange = jest.fn(); 40 | 41 | render({ onRowsPerPageChange }); 42 | 43 | await user.selectOptions( 44 | screen.getByRole('combobox'), 45 | screen.getByRole('option', { name: '10' }), 46 | ); 47 | 48 | expect(onRowsPerPageChange).toHaveBeenCalledTimes(1); 49 | }); 50 | 51 | it('should call onRowsPerPageChange when option in select is clicked', async () => { 52 | const onRowsPerPageChange = jest.fn(); 53 | 54 | render({ onRowsPerPageChange }); 55 | 56 | await user.selectOptions( 57 | screen.getByRole('combobox'), 58 | screen.getByRole('option', { name: '10' }), 59 | ); 60 | 61 | expect(onRowsPerPageChange).toHaveBeenCalledTimes(1); 62 | }); 63 | 64 | it('setCurrentPage should be updated when firstPageIcon is clicked', async () => { 65 | const setCurrentPage = jest.fn(); 66 | 67 | render({ setCurrentPage }); 68 | 69 | await user.click(screen.getByTestId('first-page-icon')); 70 | 71 | expect(setCurrentPage).toHaveBeenCalledTimes(1); 72 | }); 73 | 74 | it('setCurrentPage should be updated when firstPageIcon is clicked using key press enter', async () => { 75 | const setCurrentPage = jest.fn(); 76 | 77 | render({ setCurrentPage }); 78 | 79 | await user.type(screen.getByTestId('first-page-icon'), '{Space}'); 80 | 81 | expect(setCurrentPage).toHaveBeenCalledTimes(1); 82 | }); 83 | 84 | it('setCurrentPage should be updated when firstPageIcon is clicked using key press tab and enter', async () => { 85 | const setCurrentPage = jest.fn(); 86 | 87 | render({ setCurrentPage }); 88 | 89 | await user.keyboard('{Tab}'); 90 | await user.keyboard('{Tab}'); 91 | await user.keyboard('{Enter}'); 92 | 93 | expect(setCurrentPage).toHaveBeenCalledTimes(1); 94 | }); 95 | 96 | it('Description text should be rendered correctly', () => { 97 | const descriptionTexts = descriptionText; 98 | const currentPage = 2; 99 | const rowsPerPage = 5; 100 | const numberOfRows = 100; 101 | 102 | render({ 103 | rowsPerPage, 104 | descriptionTexts, 105 | currentPage, 106 | numberOfRows, 107 | }); 108 | 109 | const text = screen.getByTestId('description-text'); 110 | 111 | expect(text).toHaveTextContent('11-15 av 100'); 112 | }); 113 | }); 114 | -------------------------------------------------------------------------------- /src/utils/arrayUtils.test.ts: -------------------------------------------------------------------------------- 1 | import { areItemsUnique, arraysEqual, lastItem } from '@/utils/arrayUtils'; 2 | 3 | describe('arrayUtils', () => { 4 | describe('arraysEqual', () => { 5 | it('Returns true if arrays are the same', () => { 6 | const array = [1, 2, 3]; 7 | expect(arraysEqual(array, array)).toBe(true); 8 | }); 9 | 10 | it('Returns true if arrays have equal content', () => { 11 | expect(arraysEqual([1, 2, 3], [1, 2, 3])).toBe(true); 12 | expect(arraysEqual(['a', 'b', 'c'], ['a', 'b', 'c'])).toBe(true); 13 | expect(arraysEqual([true, false], [true, false])).toBe(true); 14 | expect(arraysEqual([1, 'b', true], [1, 'b', true])).toBe(true); 15 | }); 16 | 17 | it('Returns true if both arrays are undefined', () => { 18 | expect(arraysEqual(undefined, undefined)).toBe(true); 19 | }); 20 | 21 | it('Returns false if one of the arrays is undefined', () => { 22 | expect(arraysEqual([1, 2, 3], undefined)).toBe(false); 23 | expect(arraysEqual(undefined, [1, 2, 3])).toBe(false); 24 | }); 25 | 26 | it('Returns false if the arrays have different length', () => { 27 | expect(arraysEqual([1, 2, 3], [1, 2, 3, 4])).toBe(false); 28 | expect(arraysEqual([1, 2, 3, 4], [1, 2, 3])).toBe(false); 29 | }); 30 | 31 | it('Returns false if the arrays have different order', () => { 32 | expect(arraysEqual([1, 2, 3], [1, 3, 2])).toBe(false); 33 | expect(arraysEqual([3, 2, 1], [2, 3, 1])).toBe(false); 34 | expect(arraysEqual(['a', 'b', 'c'], ['c', 'a', 'b'])).toBe(false); 35 | expect(arraysEqual([true, false], [false, true])).toBe(false); 36 | }); 37 | 38 | it('Returns false if the arrays have different content', () => { 39 | expect(arraysEqual([1, 2, 3], [1, 2, 4])).toBe(false); 40 | expect(arraysEqual<string | number>([1, 2, 3], ['a', 'b', 'c'])).toBe( 41 | false, 42 | ); 43 | expect(arraysEqual<string | number>([1, 2, 3], ['1', '2', '3'])).toBe( 44 | false, 45 | ); 46 | expect(arraysEqual(['a', 'b', 'c'], ['å', 'b', 'c'])).toBe(false); 47 | expect(arraysEqual([true, false], [true, true])).toBe(false); 48 | expect(arraysEqual([1, 'b', true], [0, 'b', true])).toBe(false); 49 | }); 50 | }); 51 | 52 | describe('lastItem', () => { 53 | it('Returns last element of given array', () => { 54 | expect(lastItem([1, 2, 3])).toBe(3); 55 | expect(lastItem([true, false])).toBe(false); 56 | expect(lastItem(['test'])).toBe('test'); 57 | }); 58 | 59 | it('Returns undefined if given array is empty', () => { 60 | expect(lastItem([])).toBeUndefined(); 61 | }); 62 | }); 63 | 64 | describe('areItemsUnique', () => { 65 | it('Returns true if all items are unique', () => { 66 | expect(areItemsUnique([1, 2, 3])).toBe(true); 67 | expect(areItemsUnique(['a', 'b', 'c'])).toBe(true); 68 | expect(areItemsUnique(['abc', 'bcd', 'cde'])).toBe(true); 69 | expect(areItemsUnique([true, false])).toBe(true); 70 | expect(areItemsUnique([1, 'b', true])).toBe(true); 71 | expect(areItemsUnique([0, '', false, null, undefined])).toBe(true); 72 | }); 73 | 74 | it('Returns true if array is empty', () => { 75 | expect(areItemsUnique([])).toBe(true); 76 | }); 77 | 78 | it('Returns false if there is at least one duplicated item', () => { 79 | expect(areItemsUnique([1, 2, 1])).toBe(false); 80 | expect(areItemsUnique(['a', 'a', 'c'])).toBe(false); 81 | expect(areItemsUnique(['abc', 'bcd', 'bcd'])).toBe(false); 82 | expect(areItemsUnique([true, false, true])).toBe(false); 83 | expect(areItemsUnique([1, 'b', false, 1])).toBe(false); 84 | expect(areItemsUnique([null, null])).toBe(false); 85 | expect(areItemsUnique([undefined, undefined])).toBe(false); 86 | }); 87 | }); 88 | }); 89 | -------------------------------------------------------------------------------- /src/components/Panel/Panel.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import cn from 'classnames'; 3 | 4 | import { tokens } from '@/DesignTokens'; 5 | import { useMediaQuery } from '@/hooks/useMediaQuery'; 6 | 7 | import { ReactComponent as InfoIcon } from './info.svg'; 8 | import { ReactComponent as SuccessIcon } from './success.svg'; 9 | import classes from './Panel.module.css'; 10 | 11 | export enum PanelVariant { 12 | Success = 'success', 13 | Info = 'info', 14 | Warning = 'warning', 15 | Error = 'error', 16 | } 17 | 18 | interface RenderIconProps { 19 | size: string; 20 | variant: PanelVariant; 21 | } 22 | 23 | interface UseMobileLayoutProps { 24 | forceMobileLayout?: boolean; 25 | } 26 | 27 | export interface PanelProps extends UseMobileLayoutProps { 28 | title?: React.ReactNode; 29 | children: React.ReactNode; 30 | renderIcon?: ({ size, variant }: RenderIconProps) => React.ReactNode; 31 | variant?: PanelVariant; 32 | showPointer?: boolean; 33 | showIcon?: boolean; 34 | renderArrow?: ({ children }: RenderArrowProps) => React.ReactNode; 35 | } 36 | 37 | const defaultRenderIcon = ({ size, variant }: RenderIconProps) => { 38 | switch (variant) { 39 | case PanelVariant.Info: 40 | case PanelVariant.Error: 41 | case PanelVariant.Warning: 42 | return ( 43 | <InfoIcon 44 | style={{ width: size, height: size }} 45 | data-testid='panel-icon-info' 46 | /> 47 | ); 48 | case PanelVariant.Success: 49 | return ( 50 | <SuccessIcon 51 | style={{ width: size, height: size }} 52 | data-testid='panel-icon-success' 53 | /> 54 | ); 55 | } 56 | }; 57 | 58 | const useMobileLayout = ({ forceMobileLayout }: UseMobileLayoutProps) => { 59 | const matchesMobileQuery = useMediaQuery( 60 | `(max-width: ${tokens.BreakpointsSm})`, 61 | ); 62 | 63 | if (forceMobileLayout) { 64 | return true; 65 | } 66 | 67 | return matchesMobileQuery; 68 | }; 69 | 70 | export interface RenderArrowProps { 71 | children: React.ReactNode; 72 | } 73 | 74 | const defaultRenderArrow = ({ children }: RenderArrowProps) => { 75 | return ( 76 | <div className={cn(classes['panel__pointer-position'])}>{children}</div> 77 | ); 78 | }; 79 | 80 | export const Panel = ({ 81 | renderIcon = defaultRenderIcon, 82 | title, 83 | children, 84 | variant = PanelVariant.Info, 85 | showPointer = false, 86 | showIcon = true, 87 | forceMobileLayout = false, 88 | renderArrow = defaultRenderArrow, 89 | }: PanelProps) => { 90 | const isMobileLayout = useMobileLayout({ forceMobileLayout }); 91 | 92 | const iconSize = isMobileLayout 93 | ? tokens.ComponentPanelSizeIconXs 94 | : tokens.ComponentPanelSizeIconMd; 95 | 96 | return ( 97 | <div 98 | className={cn(classes.panel, { 99 | [classes['panel--mobile-layout']]: isMobileLayout, 100 | })} 101 | > 102 | {showPointer && 103 | renderArrow({ 104 | children: ( 105 | <div 106 | data-testid='panel-pointer' 107 | className={cn( 108 | classes.panel__pointer, 109 | classes[`panel__pointer--${variant}`], 110 | )} 111 | ></div> 112 | ), 113 | })} 114 | <div 115 | data-testid='panel-content-wrapper' 116 | className={cn( 117 | classes['panel__content-wrapper'], 118 | classes[`panel__content-wrapper--${variant}`], 119 | )} 120 | > 121 | {showIcon && ( 122 | <div 123 | data-testid='panel-icon-wrapper' 124 | className={classes['panel__icon-wrapper']} 125 | > 126 | {renderIcon({ size: iconSize, variant })} 127 | </div> 128 | )} 129 | <div className={classes.panel__content}> 130 | {title && <h2 className={classes.panel__header}>{title}</h2>} 131 | <div className={classes.panel__body}>{children}</div> 132 | </div> 133 | </div> 134 | </div> 135 | ); 136 | }; 137 | -------------------------------------------------------------------------------- /src/components/Panel/PopoverPanel.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { ComponentStory, ComponentMeta } from '@storybook/react'; 3 | import { useState } from '@storybook/addons'; 4 | import { 5 | Button, 6 | ButtonVariant, 7 | ButtonColor, 8 | ButtonSize, 9 | } from '@digdir/design-system-react'; 10 | 11 | import { StoryPage } from '@sb/StoryPage'; 12 | 13 | import { PanelVariant } from './Panel'; 14 | import { PopoverPanel } from './PopoverPanel'; 15 | 16 | export default { 17 | component: PopoverPanel, 18 | parameters: { 19 | docs: { 20 | page: () => ( 21 | <StoryPage 22 | description={`TODO: Add a description (supports markdown)`} 23 | /> 24 | ), 25 | }, 26 | }, 27 | args: { 28 | title: 'Tittel', 29 | variant: PanelVariant.Warning, 30 | trigger: <button>Åpne</button>, 31 | side: 'top', 32 | }, 33 | } as ComponentMeta<typeof PopoverPanel>; 34 | 35 | const Template: ComponentStory<typeof PopoverPanel> = (args) => { 36 | const [open, setOpen] = useState(false); 37 | 38 | const handleOnOpenChange = () => { 39 | setOpen(!open); 40 | }; 41 | 42 | return ( 43 | <div> 44 | <PopoverPanel 45 | variant={args.variant} 46 | side={args.side} 47 | title={args.title} 48 | open={open} 49 | trigger={ 50 | <Button 51 | variant={ButtonVariant.Filled} 52 | color={ButtonColor.Primary} 53 | > 54 | Åpne 55 | </Button> 56 | } 57 | onOpenChange={setOpen} 58 | showPointer={args.showPointer} 59 | showIcon={args.showIcon} 60 | forceMobileLayout={args.forceMobileLayout} 61 | > 62 | <div>Her kommer litt informasjon</div> 63 | <Button 64 | variant={ButtonVariant.Filled} 65 | color={ButtonColor.Primary} 66 | size={ButtonSize.Small} 67 | onClick={handleOnOpenChange} 68 | > 69 | Lukk 70 | </Button> 71 | </PopoverPanel> 72 | </div> 73 | ); 74 | }; 75 | export const Success = Template.bind({}); 76 | Success.args = { 77 | variant: PanelVariant.Success, 78 | side: 'top', 79 | showPointer: false, 80 | showIcon: false, 81 | forceMobileLayout: false, 82 | title: 'Tittel', 83 | }; 84 | Success.argTypes = { 85 | trigger: { 86 | control: false, 87 | }, 88 | }; 89 | 90 | Success.parameters = { 91 | docs: { 92 | description: { 93 | story: '', // TODO: add story description, supports markdown 94 | }, 95 | }, 96 | }; 97 | 98 | export const Info = Template.bind({}); 99 | Info.args = { 100 | variant: PanelVariant.Info, 101 | side: 'top', 102 | showPointer: false, 103 | showIcon: false, 104 | forceMobileLayout: false, 105 | title: 'Tittel', 106 | }; 107 | Info.argTypes = { 108 | trigger: { 109 | control: false, 110 | }, 111 | }; 112 | Info.parameters = { 113 | docs: { 114 | description: { 115 | story: '', // TODO: add story description, supports markdown 116 | }, 117 | }, 118 | }; 119 | 120 | export const Warning = Template.bind({}); 121 | Warning.args = { 122 | variant: PanelVariant.Warning, 123 | side: 'top', 124 | showPointer: false, 125 | showIcon: false, 126 | forceMobileLayout: false, 127 | title: 'Tittel', 128 | }; 129 | Warning.argTypes = { 130 | trigger: { 131 | control: false, 132 | }, 133 | }; 134 | Warning.parameters = { 135 | docs: { 136 | description: { 137 | story: '', // TODO: add story description, supports markdown 138 | }, 139 | }, 140 | }; 141 | 142 | export const Error = Template.bind({}); 143 | Error.args = { 144 | variant: PanelVariant.Error, 145 | side: 'top', 146 | showPointer: false, 147 | showIcon: false, 148 | forceMobileLayout: false, 149 | title: 'Tittel', 150 | }; 151 | Error.argTypes = { 152 | trigger: { 153 | control: false, 154 | }, 155 | }; 156 | Error.parameters = { 157 | docs: { 158 | description: { 159 | story: '', // TODO: add story description, supports markdown 160 | }, 161 | }, 162 | }; 163 | -------------------------------------------------------------------------------- /src/components/Map/Map.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { ComponentStory, ComponentMeta } from '@storybook/react'; 3 | import { useArgs } from '@storybook/client-api'; 4 | import Marker from 'leaflet/dist/images/marker-icon.png'; 5 | import RetinaMarker from 'leaflet/dist/images/marker-icon-2x.png'; 6 | import MarkerShadow from 'leaflet/dist/images/marker-shadow.png'; 7 | 8 | import { StoryPage } from '@sb/StoryPage'; 9 | 10 | import type { Location } from './Map'; 11 | import { Map } from './Map'; 12 | 13 | export default { 14 | component: Map, 15 | parameters: { 16 | layout: 'fullscreen', 17 | docs: { 18 | page: () => <StoryPage description={`Map component`} />, 19 | }, 20 | }, 21 | args: { 22 | // 23 | }, 24 | } as ComponentMeta<typeof Map>; 25 | 26 | const Template: ComponentStory<typeof Map> = (args) => { 27 | const [, updateArgs] = useArgs(); 28 | 29 | const mapClicked = (location: Location) => { 30 | updateArgs({ ...args, markerLocation: location }); 31 | console.log(`Map clicked at [${location.latitude},${location.longitude}]`); 32 | }; 33 | 34 | return ( 35 | <Map 36 | {...args} 37 | markerIcon={{ 38 | iconUrl: Marker, 39 | iconRetinaUrl: RetinaMarker, 40 | shadowUrl: MarkerShadow, 41 | iconSize: [25, 41], 42 | iconAnchor: [12, 41], 43 | }} 44 | onClick={mapClicked} 45 | /> 46 | ); 47 | }; 48 | 49 | export const Default = Template.bind({}); 50 | Default.args = {}; 51 | Default.parameters = { 52 | docs: { 53 | description: { 54 | story: 55 | 'This is the default map you get if you do not specify any map layers. Kartverket with layers "europa_forenklet" and "norgeskart_bakgrunn2"', 56 | }, 57 | }, 58 | }; 59 | 60 | export const MapWithMarkerZoomAndCenter = Template.bind({}); 61 | MapWithMarkerZoomAndCenter.args = { 62 | markerLocation: { 63 | latitude: 59.2641592, 64 | longitude: 10.4036248, 65 | }, 66 | zoom: 16, 67 | centerLocation: { 68 | latitude: 59.2641592, 69 | longitude: 10.4036248, 70 | }, 71 | }; 72 | MapWithMarkerZoomAndCenter.parameters = { 73 | docs: { 74 | description: { 75 | story: 'Default map with marker location and center location set', 76 | }, 77 | }, 78 | }; 79 | 80 | export const OpenStreetMap = Template.bind({}); 81 | OpenStreetMap.args = { 82 | layers: [ 83 | { 84 | url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', 85 | subdomains: ['a', 'b', 'c'], 86 | attribution: 87 | '© <a href="https://openstreetmap.org/copyright">OpenStreetMap contributors</a>', 88 | }, 89 | ], 90 | }; 91 | OpenStreetMap.parameters = { 92 | docs: { 93 | description: { 94 | story: 'OpenStreetMap', 95 | }, 96 | }, 97 | }; 98 | 99 | export const KartverketTerrain = Template.bind({}); 100 | KartverketTerrain.args = { 101 | layers: [ 102 | { 103 | url: 'https://opencache.statkart.no/gatekeeper/gk/gk.open_gmaps?layers=terreng_norgeskart&zoom={z}&x={x}&y={y}', 104 | attribution: 'Data © <a href="https://www.kartverket.no/">Kartverket</a>', 105 | }, 106 | ], 107 | }; 108 | KartverketTerrain.parameters = { 109 | docs: { 110 | description: { 111 | story: 'Kartverket terrain map', 112 | }, 113 | }, 114 | }; 115 | 116 | export const KartverketSea = Template.bind({}); 117 | KartverketSea.args = { 118 | layers: [ 119 | { 120 | url: 'https://opencache.statkart.no/gatekeeper/gk/gk.open_gmaps?layers=sjokartraster&zoom={z}&x={x}&y={y}', 121 | attribution: 'Data © <a href="https://www.kartverket.no/">Kartverket</a>', 122 | }, 123 | ], 124 | }; 125 | KartverketSea.parameters = { 126 | docs: { 127 | description: { 128 | story: 'Kartverket sea map', 129 | }, 130 | }, 131 | }; 132 | 133 | export const GoogleMaps = Template.bind({}); 134 | GoogleMaps.args = { 135 | layers: [ 136 | { 137 | url: 'https://{s}.google.com/vt/lyrs=m&x={x}&y={y}&z={z}', 138 | subdomains: ['mt0', 'mt1', 'mt2', 'mt3'], 139 | attribution: '© Google Maps', 140 | }, 141 | ], 142 | }; 143 | GoogleMaps.parameters = { 144 | docs: { 145 | description: { 146 | story: 'Google Maps', 147 | }, 148 | }, 149 | }; 150 | -------------------------------------------------------------------------------- /src/components/Panel/PopoverPanel.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import userEvent from '@testing-library/user-event'; 3 | import { act, render as renderRtl, screen } from '@testing-library/react'; 4 | 5 | import { PanelVariant } from '../Panel'; 6 | 7 | import type { PopoverPanelProps } from './PopoverPanel'; 8 | import { PopoverPanel } from './PopoverPanel'; 9 | 10 | const render = (props: Partial<PopoverPanelProps> = {}) => { 11 | const allProps = { 12 | children: <div data-testid='popover-content'>Popover text</div>, 13 | trigger: <button>Open</button>, 14 | variant: PanelVariant.Warning, 15 | open: false, 16 | onOpenChange: jest.fn(), 17 | ...props, 18 | }; 19 | renderRtl(<PopoverPanel {...allProps} />); 20 | }; 21 | 22 | const user = userEvent.setup(); 23 | 24 | describe('Panel', () => { 25 | describe('onOpenChange', () => { 26 | it('should call onOpenChange with true when popover trigger is clicked', async () => { 27 | const onOpenChange = jest.fn(); 28 | render({ onOpenChange }); 29 | const popoverTrigger = screen.getByRole('button', { name: 'Open' }); 30 | 31 | await user.click(popoverTrigger); 32 | expect(onOpenChange).toHaveBeenCalledWith(true); 33 | expect(onOpenChange).toHaveBeenCalledTimes(1); 34 | }); 35 | 36 | it('should call onOpenChange with false when trigger is clicked and open is true', async () => { 37 | const onOpenChange = jest.fn(); 38 | await act(async () => { 39 | render({ onOpenChange, open: true }); 40 | }); 41 | const popoverTrigger = screen.getByRole('button', { name: 'Open' }); 42 | 43 | await user.click(popoverTrigger); 44 | expect(onOpenChange).toHaveBeenCalledWith(false); 45 | expect(onOpenChange).toHaveBeenCalledTimes(1); 46 | }); 47 | 48 | it('should call onOpenChange with true when trigger is clicked by space', async () => { 49 | const onOpenChange = jest.fn(); 50 | render({ onOpenChange }); 51 | const popoverTrigger = screen.getByRole('button', { name: 'Open' }); 52 | popoverTrigger.focus(); 53 | await user.keyboard('[Space]'); 54 | expect(onOpenChange).toHaveBeenCalledWith(true); 55 | expect(onOpenChange).toHaveBeenCalledTimes(1); 56 | }); 57 | 58 | it('should call onOpenChange with false when trigger is clicked by space and open is true', async () => { 59 | const onOpenChange = jest.fn(); 60 | await act(async () => { 61 | render({ onOpenChange, open: true }); 62 | }); 63 | const popoverTrigger = screen.getByRole('button', { name: 'Open' }); 64 | popoverTrigger.focus(); 65 | await user.keyboard('[Space]'); 66 | expect(onOpenChange).toHaveBeenCalledWith(false); 67 | expect(onOpenChange).toHaveBeenCalledTimes(1); 68 | }); 69 | 70 | it('should call onOpenChange with true when trigger is clicked by enter', async () => { 71 | const onOpenChange = jest.fn(); 72 | render({ onOpenChange }); 73 | const popoverTrigger = screen.getByRole('button', { name: 'Open' }); 74 | popoverTrigger.focus(); 75 | await user.keyboard('[Enter]'); 76 | expect(onOpenChange).toHaveBeenCalledWith(true); 77 | expect(onOpenChange).toHaveBeenCalledTimes(1); 78 | }); 79 | 80 | it('should call onOpenChange with false when trigger is clicked by enter and open is true', async () => { 81 | const onOpenChange = jest.fn(); 82 | await act(async () => { 83 | render({ onOpenChange, open: true }); 84 | }); 85 | const popoverTrigger = screen.getByRole('button', { name: 'Open' }); 86 | popoverTrigger.focus(); 87 | await user.keyboard('[Enter]'); 88 | expect(onOpenChange).toHaveBeenCalledWith(false); 89 | expect(onOpenChange).toHaveBeenCalledTimes(1); 90 | }); 91 | }); 92 | 93 | it('should show popover content when isOpen=true', async () => { 94 | await act(async () => { 95 | render({ open: true }); 96 | }); 97 | expect(screen.getByTestId('popover-content')).toBeInTheDocument(); 98 | }); 99 | 100 | it('should not show popover content when isOpen=false', () => { 101 | render({ open: false }); 102 | 103 | expect(screen.queryByTestId('popover-content')).not.toBeInTheDocument(); 104 | }); 105 | }); 106 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@altinn/altinn-design-system", 3 | "version": "0.30.2", 4 | "packageManager": "yarn@3.6.0", 5 | "description": "", 6 | "author": "Altinn", 7 | "license": "BSD-3-Clause", 8 | "main": "dist/cjs/index.js", 9 | "module": "dist/esm/index.js", 10 | "types": "dist/index.d.ts", 11 | "files": [ 12 | "dist" 13 | ], 14 | "scripts": { 15 | "start": "storybook dev -p 6006", 16 | "test": "jest --runInBand", 17 | "lint": "eslint . --ext .js,.jsx,.ts,.tsx,.mjs,.cjs", 18 | "build": "tsc && rollup -c", 19 | "build-storybook": "storybook build --output-dir ./storybook-static", 20 | "postinstall": "husky install", 21 | "prepack": "pinst --disable", 22 | "postpack": "pinst --enable", 23 | "add-component": "node scripts/add-component.mjs" 24 | }, 25 | "dependencies": { 26 | "@altinn/figma-design-tokens": "^6.0.1", 27 | "@digdir/design-system-react": "0.16.0", 28 | "@navikt/ds-icons": "^3.0.0", 29 | "@radix-ui/react-popover": "^1.0.0", 30 | "leaflet": "^1.9.4", 31 | "react-leaflet": "^4.2.1" 32 | }, 33 | "resolutions": { 34 | "@storybook/react/webpack": "^5", 35 | "@storybook/react-docgen-typescript-plugin": "1.0.6--canary.9.f9c48c0.0" 36 | }, 37 | "devDependencies": { 38 | "@babel/core": "7.22.5", 39 | "@babel/preset-env": "^7.22.5", 40 | "@babel/preset-react": "^7.22.5", 41 | "@babel/preset-typescript": "^7.22.5", 42 | "@mdx-js/react": "2.3.0", 43 | "@rollup/plugin-commonjs": "25.0.1", 44 | "@rollup/plugin-json": "6.0.0", 45 | "@rollup/plugin-node-resolve": "15.1.0", 46 | "@rollup/plugin-typescript": "11.1.1", 47 | "@storybook/addon-a11y": "7.0.22", 48 | "@storybook/addon-actions": "7.0.22", 49 | "@storybook/addon-docs": "7.0.22", 50 | "@storybook/addon-essentials": "7.0.22", 51 | "@storybook/addon-interactions": "7.0.22", 52 | "@storybook/addon-links": "7.0.22", 53 | "@storybook/addons": "7.0.22", 54 | "@storybook/api": "7.0.22", 55 | "@storybook/client-api": "7.0.22", 56 | "@storybook/components": "7.0.22", 57 | "@storybook/core-common": "7.0.22", 58 | "@storybook/core-events": "7.0.22", 59 | "@storybook/node-logger": "7.0.22", 60 | "@storybook/react": "7.0.22", 61 | "@storybook/react-webpack5": "7.0.22", 62 | "@storybook/theming": "7.0.22", 63 | "@svgr/rollup": "7.0.0", 64 | "@svgr/webpack": "6.5.1", 65 | "@testing-library/dom": "9.3.1", 66 | "@testing-library/jest-dom": "5.16.5", 67 | "@testing-library/react": "14.0.0", 68 | "@testing-library/user-event": "14.4.3", 69 | "@types/jest": "29.5.2", 70 | "@types/leaflet": "1.9.3", 71 | "@types/node": "18.16.18", 72 | "@types/react": "18.2.13", 73 | "@types/responselike": "1.0.0", 74 | "@types/testing-library__jest-dom": "5.14.6", 75 | "@typescript-eslint/eslint-plugin": "5.59.11", 76 | "@typescript-eslint/parser": "5.59.11", 77 | "@yarnpkg/sdks": "2.7.1", 78 | "babel-loader": "9.1.2", 79 | "classnames": "2.3.2", 80 | "core-js": "3.31.0", 81 | "css-loader": "6.8.1", 82 | "eslint": "8.43.0", 83 | "eslint-config-prettier": "8.8.0", 84 | "eslint-import-resolver-typescript": "3.5.5", 85 | "eslint-plugin-import": "2.27.5", 86 | "eslint-plugin-jsx-a11y": "6.7.1", 87 | "eslint-plugin-prettier": "4.2.1", 88 | "eslint-plugin-react": "7.32.2", 89 | "eslint-plugin-react-hooks": "4.6.0", 90 | "eslint-plugin-storybook": "0.6.12", 91 | "husky": "8.0.3", 92 | "identity-obj-proxy": "3.0.0", 93 | "jest": "29.5.0", 94 | "jest-environment-jsdom": "29.5.0", 95 | "lint-staged": "13.2.2", 96 | "node-polyfill-webpack-plugin": "2.0.1", 97 | "pinst": "3.0.0", 98 | "prettier": "2.8.8", 99 | "react": "18.2.0", 100 | "react-dom": "18.2.0", 101 | "rollup": "3.25.1", 102 | "rollup-plugin-dts": "5.3.0", 103 | "rollup-plugin-peer-deps-external": "2.2.4", 104 | "rollup-plugin-postcss": "4.0.2", 105 | "semver": "7.5.2", 106 | "storybook": "7.0.22", 107 | "storybook-addon-turbo-build": "2.0.1", 108 | "storybook-dark-mode": "3.0.0", 109 | "terser": "5.18.0", 110 | "ts-jest": "29.1.0", 111 | "tslib": "2.5.3", 112 | "typescript": "5.1.3", 113 | "webpack": "5.87.0", 114 | "yargs": "17.7.2" 115 | }, 116 | "peerDependencies": { 117 | "react": "^18.0.0", 118 | "react-dom": "^18.0.0" 119 | }, 120 | "lint-staged": { 121 | "*.{js,jsx,ts,tsx,cjs,mjs}": "yarn lint --cache --fix", 122 | "*.{css,md,mdx,json}": "yarn prettier --write" 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/components/Panel/Panel.module.css: -------------------------------------------------------------------------------- 1 | /* breakpoints-xs */ 2 | @media only screen and (min-width: 0) { 3 | .panel { 4 | --panel-y-padding: var(--component-panel-space-padding-y-xs); 5 | --panel-x-padding: var(--component-panel-space-padding-x-xs); 6 | --panel-content-gap: var(--component-panel-space-gap-xs); 7 | --panel-pointer-width: calc(var(--component-panel-size-icon-xs) / 2); 8 | --panel-pointer-height: calc(var(--panel-pointer-width) / 2); 9 | --panel-body-font_size: var(--component-panel-font_size-body-breakpoint_sm); 10 | --panel-header-font_size: var( 11 | --component-panel-font_size-header-breakpoint_sm 12 | ); 13 | } 14 | } 15 | 16 | /* breakpoints-sm */ 17 | @media only screen and (min-width: 540px) { 18 | .panel:not(.panel--mobile-layout) { 19 | --panel-y-padding: var(--component-panel-space-padding-y-md); 20 | --panel-x-padding: var(--component-panel-space-padding-x-md); 21 | --panel-content-gap: var(--component-panel-space-gap-md); 22 | --panel-pointer-width: calc(var(--component-panel-size-icon-md) / 2); 23 | --panel-pointer-height: calc(var(--panel-pointer-width) / 2); 24 | --panel-body-font_size: var(--component-panel-font_size-body-breakpoint_sm); 25 | --panel-header-font_size: var( 26 | --component-panel-font_size-header-breakpoint_sm 27 | ); 28 | } 29 | } 30 | 31 | /* breakpoints-md */ 32 | @media only screen and (min-width: 768px) { 33 | .panel:not(.panel--mobile-layout) { 34 | --panel-body-font_size: var(--component-panel-font_size-body-breakpoint_md); 35 | --panel-header-font_size: var( 36 | --component-panel-font_size-header-breakpoint_md 37 | ); 38 | } 39 | } 40 | 41 | /* breakpoints-lg */ 42 | @media only screen and (min-width: 960px) { 43 | .panel:not(.panel--mobile-layout) { 44 | --panel-body-font_size: var(--component-panel-font_size-body-breakpoint_md); 45 | --panel-header-font_size: var( 46 | --component-panel-font_size-header-breakpoint_lg 47 | ); 48 | } 49 | } 50 | 51 | /* breakpoints-xl */ 52 | @media only screen and (min-width: 1200px) { 53 | .panel:not(.panel--mobile-layout) { 54 | --panel-body-font_size: var(--component-panel-font_size-body-breakpoint_md); 55 | --panel-header-font_size: var( 56 | --component-panel-font_size-header-breakpoint_lg 57 | ); 58 | } 59 | } 60 | 61 | /* print style */ 62 | @media print { 63 | .panel { 64 | --panel-y-padding: var(--component-panel-space-padding-y-xs); 65 | --panel-x-padding: var(--component-panel-space-padding-x-xs); 66 | --panel-content-gap: var(--component-panel-space-gap-xs); 67 | --panel-pointer-width: calc(var(--component-panel-size-icon-xs) / 2); 68 | --panel-pointer-height: calc(var(--panel-pointer-width) / 2); 69 | --panel-body-font_size: var(--component-panel-font_size-body-breakpoint_sm); 70 | --panel-header-font_size: var( 71 | --component-panel-font_size-header-breakpoint_sm 72 | ); 73 | } 74 | } 75 | 76 | .panel { 77 | width: 100%; 78 | } 79 | 80 | .panel__pointer { 81 | width: var(--panel-pointer-width); 82 | height: var(--panel-pointer-height); 83 | clip-path: polygon(50% 0, 100% 100%, 0 100%); 84 | } 85 | 86 | .panel__pointer-position { 87 | top: 1px; 88 | position: relative; 89 | left: calc(var(--panel-x-padding) + (var(--panel-pointer-width) / 2)); 90 | } 91 | 92 | .panel__content-wrapper--info, 93 | .panel__pointer--info { 94 | background-color: var(--component-panel-color-background-default); 95 | } 96 | 97 | .panel__content-wrapper--success, 98 | .panel__pointer--success { 99 | background-color: var(--component-panel-color-background-success); 100 | } 101 | 102 | .panel__content-wrapper--warning, 103 | .panel__pointer--warning { 104 | background-color: var(--component-panel-color-background-warning); 105 | } 106 | 107 | .panel__content-wrapper--error, 108 | .panel__pointer--error { 109 | background-color: var(--colors-red-200); 110 | } 111 | 112 | .panel__content-wrapper { 113 | display: flex; 114 | gap: var(--panel-content-gap); 115 | padding: var(--panel-y-padding) var(--panel-x-padding); 116 | } 117 | 118 | .panel__icon-wrapper { 119 | display: flex; 120 | flex: none; 121 | } 122 | 123 | .panel__content { 124 | display: flex; 125 | flex-direction: column; 126 | gap: var(--component-panel-space-text_group-gap-xs); 127 | } 128 | 129 | .panel__header { 130 | margin: 0; 131 | font-size: var(--panel-header-font_size); 132 | font-weight: var(--component-panel-font_weight-heading); 133 | } 134 | 135 | .panel__body { 136 | font-size: var(--panel-body-font_size); 137 | } 138 | -------------------------------------------------------------------------------- /src/components/Map/Map.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render as renderRtl, screen } from '@testing-library/react'; 3 | import userEvent from '@testing-library/user-event'; 4 | 5 | import type { MapProps, Location } from './Map'; 6 | import { Map } from './Map'; 7 | 8 | const user = userEvent.setup(); 9 | 10 | window.scrollTo = jest.fn(); 11 | 12 | describe('Map', () => { 13 | describe('Marker', () => { 14 | it('should show marker when marker is set', () => { 15 | render({ 16 | markerLocation: { latitude: 59.2641592, longitude: 10.4036248 }, 17 | }); 18 | expect(locationMarker()).toBeInTheDocument(); 19 | }); 20 | 21 | it('should not show marker when marker is not set', () => { 22 | render({ markerLocation: undefined }); 23 | expect(locationMarker()).not.toBeInTheDocument(); 24 | }); 25 | }); 26 | 27 | describe('Click map', () => { 28 | it('should call onClick with correct coordinates when map is clicked', async () => { 29 | const onClick = jest.fn(); 30 | render({ onClick }); 31 | await clickMap(); 32 | expect(onClick).toHaveBeenCalledWith({ 33 | latitude: 59.26415891525691, 34 | longitude: 10.403623580932617, 35 | } satisfies Location); 36 | }); 37 | 38 | it('should call onClick with longitude between 180 and -180 even when map is wrapped', async () => { 39 | const onClick = jest.fn(); 40 | render({ flyToZoomLevel: 4, onClick }); 41 | await clickMap(2500, 0); // Click so that the world is wrapped 42 | expect(onClick).toHaveBeenCalledWith({ 43 | latitude: 59.265880628258095, 44 | longitude: -129.90234375, 45 | } satisfies Location); 46 | }); 47 | 48 | it('should not call onClick when readOnly is true and map is clicked', async () => { 49 | const onClick = jest.fn(); 50 | render({ onClick, readOnly: true }); 51 | await clickMap(); 52 | expect(onClick).not.toHaveBeenCalled(); 53 | }); 54 | 55 | it('should get different coordinates when map is clicked at different location', async () => { 56 | const onClick = jest.fn(); 57 | render({ onClick }); 58 | 59 | // First click 60 | await clickMap(); 61 | expect(onClick).toHaveBeenCalledTimes(1); 62 | const firstLocation: Location = onClick.mock.calls[0][0]; 63 | 64 | // Second click at different location 65 | await clickMap(50, 50); 66 | expect(onClick).toHaveBeenCalledTimes(2); 67 | const secondLocation: Location = onClick.mock.calls[1][0]; 68 | 69 | expect(firstLocation.latitude).not.toBe(secondLocation.latitude); 70 | expect(firstLocation.longitude).not.toBe(secondLocation.longitude); 71 | }); 72 | }); 73 | 74 | it('should display attribution link', () => { 75 | render({ 76 | layers: [ 77 | { 78 | url: 'dummy', 79 | attribution: '<a href="https://dummylink.invalid">Dummy link</a>', 80 | }, 81 | ], 82 | }); 83 | 84 | expect(getLink('Dummy link')).toBeInTheDocument(); 85 | }); 86 | 87 | it('should show map with zoom buttons when readonly is false', () => { 88 | render({ 89 | readOnly: false, 90 | }); 91 | 92 | expect(getButton('Zoom in')).toBeInTheDocument(); 93 | expect(getButton('Zoom out')).toBeInTheDocument(); 94 | }); 95 | 96 | it('should show map without zoom buttons when readonly is true', () => { 97 | render({ 98 | readOnly: true, 99 | }); 100 | 101 | expect(getButton('Zoom in')).not.toBeInTheDocument(); 102 | expect(getButton('Zoom out')).not.toBeInTheDocument(); 103 | }); 104 | }); 105 | 106 | function locationMarker() { 107 | return screen.queryByRole('button', { name: 'Marker' }); 108 | } 109 | 110 | function getButton(name: string) { 111 | return screen.queryByRole('button', { name: name }); 112 | } 113 | 114 | function getLink(name: string) { 115 | return screen.queryByRole('link', { name: name }); 116 | } 117 | 118 | async function clickMap(clientX = 0, clientY = 0) { 119 | const firstMapLayer = screen.getAllByRole('img')[0]; 120 | await user.pointer([ 121 | { 122 | pointerName: 'mouse', 123 | target: firstMapLayer, 124 | coords: { 125 | clientX, 126 | clientY, 127 | }, 128 | keys: '[MouseLeft]', 129 | }, 130 | ]); 131 | } 132 | 133 | const render = (props: Partial<MapProps> = {}) => { 134 | const allProps: MapProps = { 135 | readOnly: false, 136 | layers: undefined, 137 | centerLocation: { 138 | latitude: 59.2641592, 139 | longitude: 10.4036248, 140 | } as Location, 141 | zoom: 4, 142 | markerLocation: { 143 | latitude: 59.2641592, 144 | longitude: 10.4036248, 145 | } as Location, 146 | markerIcon: { 147 | iconUrl: 'marker.png', 148 | }, 149 | onClick: jest.fn(), 150 | ...props, 151 | }; 152 | 153 | return renderRtl(<Map {...allProps} />); 154 | }; 155 | -------------------------------------------------------------------------------- /src/components/Map/Map.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | AttributionControl, 3 | MapContainer, 4 | Marker, 5 | TileLayer, 6 | useMapEvents, 7 | } from 'react-leaflet'; 8 | import React, { useEffect, useMemo, useState } from 'react'; 9 | import { icon } from 'leaflet'; 10 | import type { Map as LeafletMap } from 'leaflet'; 11 | 12 | import classes from './Map.module.css'; 13 | 14 | import 'leaflet/dist/leaflet.css'; 15 | 16 | // Default is center of Norway 17 | const DefaultCenterLocation: Location = { 18 | latitude: 64.888996, 19 | longitude: 12.8186054, 20 | }; 21 | const DefaultZoom = 4; 22 | 23 | // Default zoom level that should be used when flying to the new markerLocation 24 | const DefaultFlyToZoomLevel = 16; 25 | 26 | // Default map layer from Kartverket 27 | const DefaultMapLayers: MapLayer[] = [ 28 | { 29 | url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', 30 | attribution: 31 | '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap contributors</a>', 32 | }, 33 | { 34 | url: 'https://cache.kartverket.no/v1/wmts/1.0.0/topo/default/webmercator/{z}/{y}/{x}.png', 35 | attribution: '© <a href="https://www.kartverket.no/">Kartverket</a>', 36 | }, 37 | ]; 38 | 39 | export interface Location { 40 | latitude: number; 41 | longitude: number; 42 | } 43 | 44 | export interface MapLayer { 45 | url: string; 46 | attribution?: string; 47 | subdomains?: string[]; 48 | } 49 | 50 | /** 51 | * It is expected you pass in the default icons when using the Map component, unless you want to provide your own icon. 52 | * 53 | * Example: 54 | * import Marker from 'leaflet/dist/images/marker-icon.png'; 55 | * import RetinaMarker from 'leaflet/dist/images/marker-icon-2x.png'; 56 | * import MarkerShadow from 'leaflet/dist/images/marker-shadow.png'; 57 | * 58 | * <Map ... markerIcon={{ 59 | * iconUrl: Marker, 60 | * iconRetinaUrl: RetinaMarker, 61 | * shadowUrl: MarkerShadow, 62 | * iconSize: [25, 41], 63 | * iconAnchor: [12, 41], 64 | * }} /> 65 | * 66 | * This example requires your build system to support image loading (but you can provide the URLs for static icon files 67 | * if your build system doesn't support image loading). 68 | */ 69 | export interface MapIconOptions { 70 | iconUrl: string; 71 | iconRetinaUrl?: string | undefined; 72 | iconSize?: [number, number] | undefined; 73 | iconAnchor?: [number, number] | undefined; 74 | shadowUrl?: string | undefined; 75 | shadowRetinaUrl?: string | undefined; 76 | shadowSize?: [number, number] | undefined; 77 | shadowAnchor?: [number, number] | undefined; 78 | } 79 | 80 | export interface MapProps { 81 | readOnly?: boolean; 82 | layers?: MapLayer[]; 83 | centerLocation?: Location; 84 | zoom?: number; 85 | flyToZoomLevel?: number; 86 | markerLocation?: Location; 87 | onClick?: (location: Location) => void; 88 | markerIcon: MapIconOptions; 89 | } 90 | 91 | export const Map = ({ 92 | readOnly = false, 93 | layers = DefaultMapLayers, 94 | centerLocation = DefaultCenterLocation, 95 | zoom = DefaultZoom, 96 | flyToZoomLevel = DefaultFlyToZoomLevel, 97 | markerLocation, 98 | markerIcon, 99 | onClick, 100 | }: MapProps) => { 101 | const [map, setMap] = useState<LeafletMap | null>(null); 102 | 103 | const validMarkerLocation = useMemo(() => { 104 | if (!markerLocation?.latitude || !markerLocation?.longitude) 105 | return undefined; 106 | return markerLocation; 107 | }, [markerLocation]); 108 | 109 | useEffect(() => { 110 | if (map && validMarkerLocation && flyToZoomLevel) { 111 | map.flyTo( 112 | { 113 | lat: validMarkerLocation.latitude, 114 | lng: validMarkerLocation.longitude, 115 | }, 116 | flyToZoomLevel, 117 | ); 118 | } 119 | }, [map, validMarkerLocation, flyToZoomLevel]); 120 | 121 | return ( 122 | <MapContainer 123 | className={classes.map} 124 | center={locationToTuple(centerLocation)} 125 | zoom={zoom} 126 | zoomControl={!readOnly} 127 | dragging={!readOnly} 128 | touchZoom={!readOnly} 129 | doubleClickZoom={!readOnly} 130 | scrollWheelZoom={!readOnly} 131 | attributionControl={false} 132 | ref={setMap} 133 | > 134 | {layers.map((layer, i) => ( 135 | <TileLayer 136 | key={i} 137 | url={layer.url} 138 | attribution={layer.attribution} 139 | subdomains={layer.subdomains ? layer.subdomains : []} 140 | opacity={readOnly ? 0.5 : 1.0} 141 | /> 142 | ))} 143 | <AttributionControl prefix={false} /> 144 | {validMarkerLocation ? ( 145 | <Marker 146 | position={locationToTuple(validMarkerLocation)} 147 | icon={icon(markerIcon)} 148 | /> 149 | ) : null} 150 | <MapClickHandler 151 | readOnly={readOnly} 152 | onClick={onClick} 153 | /> 154 | </MapContainer> 155 | ); 156 | }; 157 | 158 | function locationToTuple(location: Location): [number, number] { 159 | return [location.latitude, location.longitude]; 160 | } 161 | 162 | type MapClickHandlerProps = Pick<MapProps, 'readOnly' | 'onClick'>; 163 | const MapClickHandler = ({ onClick, readOnly }: MapClickHandlerProps) => { 164 | useMapEvents({ 165 | click: (map) => { 166 | if (onClick && !readOnly) { 167 | const wrappedLatLng = map.latlng.wrap(); 168 | onClick({ 169 | latitude: wrappedLatLng.lat, 170 | longitude: wrappedLatLng.lng, 171 | }); 172 | } 173 | }, 174 | }); 175 | 176 | return null; 177 | }; 178 | -------------------------------------------------------------------------------- /src/components/Pagination/Pagination.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import cn from 'classnames'; 3 | 4 | import { useMediaQuery } from '@/hooks/useMediaQuery'; 5 | import { tokens } from '@/DesignTokens'; 6 | 7 | import classes from './Pagination.module.css'; 8 | import { ReactComponent as NavigateNextIcon } from './navigate_next.svg'; 9 | import { ReactComponent as NavigateBeforeIcon } from './navigate_before.svg'; 10 | import { ReactComponent as FirstPageIcon } from './first_page.svg'; 11 | import { ReactComponent as LastPageIcon } from './last_page.svg'; 12 | 13 | export interface PaginationProps { 14 | numberOfRows: number; 15 | rowsPerPageOptions: number[]; 16 | rowsPerPage: number; 17 | onRowsPerPageChange: (event: React.ChangeEvent<HTMLSelectElement>) => void; 18 | currentPage: number; 19 | setCurrentPage: (page: number) => void; 20 | descriptionTexts: DescriptionText; 21 | } 22 | export interface DescriptionText { 23 | rowsPerPage: string; 24 | of: string; 25 | navigateFirstPage: string; 26 | previousPage: string; 27 | nextPage: string; 28 | navigateLastPage: string; 29 | } 30 | 31 | export const Pagination = ({ 32 | numberOfRows, 33 | rowsPerPageOptions, 34 | rowsPerPage, 35 | onRowsPerPageChange, 36 | currentPage, 37 | setCurrentPage, 38 | descriptionTexts, 39 | }: PaginationProps) => { 40 | const isMobile = useMediaQuery(`(max-width: ${tokens.BreakpointsSm})`); 41 | const [numberOfPages, setNumberOfPages] = useState(1); 42 | 43 | useEffect(() => { 44 | if (numberOfRows < rowsPerPage) { 45 | setNumberOfPages(1); 46 | } else { 47 | setNumberOfPages(Math.ceil(numberOfRows / rowsPerPage)); 48 | } 49 | }, [rowsPerPage, numberOfRows]); 50 | 51 | const increaseCurrentPage = () => { 52 | if (currentPage < numberOfPages - 1) { 53 | setCurrentPage(currentPage + 1); 54 | } 55 | }; 56 | 57 | const decreaseCurrentPage = () => { 58 | if (currentPage > 0) { 59 | setCurrentPage(currentPage - 1); 60 | } 61 | }; 62 | 63 | const renderPaginationNumbers = () => { 64 | const firstRowNumber = 1 + currentPage * rowsPerPage; 65 | const lastRowNumber = 66 | rowsPerPage * (currentPage + 1) > numberOfRows 67 | ? numberOfRows 68 | : rowsPerPage * (currentPage + 1); 69 | return ( 70 | <span 71 | className={cn(classes['description-text'])} 72 | data-testid='description-text' 73 | > 74 | {`${firstRowNumber}-${lastRowNumber} ${descriptionTexts['of']} ${numberOfRows}`} 75 | </span> 76 | ); 77 | }; 78 | 79 | return ( 80 | <div className={cn(classes['pagination-wrapper'])}> 81 | <div className={cn(classes['pagination-wrapper-row'])}> 82 | <span 83 | style={{ marginRight: '26px' }} 84 | id='number-of-rows-select' 85 | aria-hidden='true' 86 | > 87 | {!isMobile && descriptionTexts['rowsPerPage']} 88 | </span> 89 | <select 90 | className={cn(classes['select-pagination'])} 91 | value={rowsPerPage} 92 | onChange={(event) => onRowsPerPageChange(event)} 93 | aria-labelledby='number-of-rows-select' 94 | > 95 | {rowsPerPageOptions.map((optionValue: number) => ( 96 | <option 97 | key={optionValue} 98 | value={optionValue} 99 | > 100 | {optionValue} 101 | </option> 102 | ))} 103 | </select> 104 | {renderPaginationNumbers()} 105 | </div> 106 | <div className={cn(classes['pagination-wrapper-row'])}> 107 | <button 108 | className={cn(classes['icon-button'])} 109 | onClick={() => setCurrentPage(0)} 110 | disabled={currentPage === 0} 111 | aria-label={descriptionTexts['navigateFirstPage']} 112 | data-testid='first-page-icon' 113 | > 114 | <FirstPageIcon 115 | className={cn(classes['pagination-icon'], { 116 | [classes['pagination-icon--disabled']]: currentPage === 0, 117 | })} 118 | /> 119 | </button> 120 | <button 121 | className={cn(classes['icon-button'])} 122 | onClick={() => decreaseCurrentPage()} 123 | disabled={currentPage === 0} 124 | aria-label={descriptionTexts['previousPage']} 125 | data-testid='pagination-previous-icon' 126 | > 127 | <NavigateBeforeIcon 128 | className={cn(classes['pagination-icon'], { 129 | [classes['pagination-icon--disabled']]: currentPage === 0, 130 | })} 131 | /> 132 | </button> 133 | <button 134 | className={cn(classes['icon-button'])} 135 | onClick={() => increaseCurrentPage()} 136 | disabled={currentPage === numberOfPages - 1} 137 | aria-label={descriptionTexts['nextPage']} 138 | data-testid='pagination-next-icon' 139 | > 140 | <NavigateNextIcon 141 | className={cn(classes['pagination-icon'], { 142 | [classes['pagination-icon--disabled']]: 143 | currentPage === numberOfPages - 1, 144 | })} 145 | /> 146 | </button> 147 | <button 148 | className={cn(classes['icon-button'])} 149 | onClick={() => setCurrentPage(numberOfPages - 1)} 150 | disabled={currentPage === numberOfPages - 1} 151 | aria-label={descriptionTexts['navigateLastPage']} 152 | > 153 | <LastPageIcon 154 | className={cn(classes['pagination-icon'], { 155 | [classes['pagination-icon--disabled']]: 156 | currentPage === numberOfPages - 1, 157 | })} 158 | /> 159 | </button> 160 | </div> 161 | </div> 162 | ); 163 | }; 164 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | > [!WARNING] 3 | > This design system is DEPRECATED and not maintained! 4 | > Use [designsystemet.no](https://designsystemet.no/en) instead. 5 | 6 | ## How to install 7 | 8 | To add the design system to your project, navigate to the directory where your `package.json` file is located and run one of the following commands: 9 | 10 | ### NPM 11 | 12 | ``` 13 | npm install @altinn/altinn-design-system 14 | ``` 15 | 16 | ### Yarn 17 | 18 | ``` 19 | yarn add @altinn/altinn-design-system 20 | ``` 21 | 22 | ### Prerequisites 23 | 24 | As of version **0.27**, this design system is no longer compatible with [the old design system](https://github.com/Altinn/DesignSystem). 25 | This is because the old design system sets the `rem` unit to `10px`, while the default is `16px`, and this is also the unit expected by the Figma tokens. 26 | In older versions, these tokens are converted to match the `rem` unit of the old design system. 27 | 28 | ### Linking 29 | 30 | If you plan on making any contributions to the design system and develop features/test changes with a more rapid feedback loop, you'll most likely want to link your project to a local clone of `altinn-design-system` instead. 31 | 32 | If using `npm link` or `yarn link` works for your project, that'll be the easiest solution. Most likely however, you'll probably want to create local packages for `altinn-design-system` and refer to those in your project `package.json`: 33 | 34 | First, in your local `altinn-design-system`: 35 | 36 | 1. Run `yarn --immutable`, if you haven't already 37 | 2. Run `yarn build` 38 | 3. Run `yarn pack` 39 | 40 | Then, in your project: 41 | 42 | 1. Update the version number for `@altinn/altinn-design-system` to instead point to a path for the `package.tgz` file generated in the steps above. For example: `"@altinn/altinn-design-system": "../altinn-design-system/package.tgz",` 43 | 2. Run `yarn install` (or `npm install`, depending on your project) 44 | 3. Build and/or run your project 45 | 46 | Tip: Whenever you re-build and re-pack in `altinn-design-system`, the output filename stays the same (`package.tgz`). This might cause caching issues where your project uses an older `package.tgz`. To force using a newer package, rename it to something else after running `yarn pack` (for example `package2.tgz`). Be sure to run your package manager after every time you pack the design system library. 47 | 48 | ## Getting started 49 | 50 | ### Node and Corepack 51 | 52 | We are using the latest LTS release of node, but minimum version 16.9.0, since we are using [corepack](https://nodejs.org/api/corepack.html). To enable corepack, execute `corepack enable` from the terminal. 53 | 54 | ### Start Storybook 55 | 56 | Execute `yarn start` to start Storybook. It should open a browser automatically when it is ready. If you prefer to not automatically open a browser, you can execute `yarn start --no-open`. 57 | 58 | ### Tests 59 | 60 | - `yarn test` to run unit tests 61 | - `yarn lint` to run lint checks 62 | 63 | Lint checks and auto-fixes will be run automatically on commit. 64 | 65 | ### Adding new components 66 | 67 | New components can be added by executing `yarn add-component <ComponentName>`. The name of the component should be written using PascalCase. This will generate all important files for you in the correct location, and also update the index file for exporting the component. The generated code includes some `TODO` statements that you should fix. 68 | 69 | #### Adding new dependencies 70 | 71 | When adding new dependencies, you should also add that dependency to the `external` array in `rollup.config.mjs`. This is done to avoid having the dependency being part of the bundle. 72 | 73 | ### Styling 74 | 75 | Styling should primarily be done in css files using css variables. The css files should end with `.module.css`, so unique classnames will be generated. This ensures we will not run into naming collision issues with classnames. 76 | 77 | We are using Figma as our design tool, and we are extracting tokens directly from Figma that can be used in code. These tokens are defined in the [figma-design-tokens repository](https://github.com/Altinn/figma-design-tokens). New components should ideally be using design tokens from there to define their layout. Before work is started on the component, you should discuss with the UX group first, because they need to define the tokens for the components. 78 | 79 | #### Classname naming conventions 80 | 81 | Using [BEM naming convention](http://getbem.com/naming/) gives a pretty clear view of what parts are the "root" and what parts are the "children", and is preferred. This also helps you think about when a component grows too big, and should be split into smaller isolated parts. 82 | 83 | ## Good to know 84 | 85 | ### How are the Figma design tokens connected to this repo? 86 | 87 | The design tokens live in a [separate repository](https://github.com/Altinn/figma-design-tokens), which is used in Figma with a plugin. The UX group will set/use these tokens in their design, and in the end it will be synced to the `tokens.json` in the `figma-design-tokens` repository. These tokens are publised to NPM as a package `@altinn/figma-design-tokens`. During this process, the `tokens.json` is transformed to `tokens.esm.js` and `tokens.css` files, so the values can be used directly from JS or as CSS variables. 88 | 89 | ![figma tokens usage diagam](./docs/figma-tokens-diagram.svg) 90 | 91 | When using these tokens in this project, we are also transforming the values a bit, to stay compatible with our old design system. The long term goal is to get rid of the dependency to the old design system, so we no longer have to do this transformation. For more information about this, see the below explaination `rem` values. 92 | 93 | ## Code style 94 | 95 | We use [eslint](https://eslint.org/) and [prettier](https://prettier.io/), and automatically set up git hooks to enforce 96 | these on commits. To avoid confusion, it is recommended to set this up your IDE. 97 | 98 | ### Visual Studio Code 99 | 100 | Install the [eslint extension from the marketplace](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint). 101 | 102 | ### WebStorm and IntelliJ IDEA 103 | 104 | Configure your IDE to run `eslint --fix` on save (prettier will also reformat your code when doing this). It is also recommended to 105 | [set up Prettier as the default formatter](https://www.jetbrains.com/help/webstorm/prettier.html#ws_prettier_default_formatter). 106 | 107 | ## Creating a new release 108 | 109 | Go to Github Actions, and select the Release pipeline. Run the workflow, and select the appropriate version (major, minor or patch). We use [semver](https://semver.org/) spec. 110 | 111 | ### Release notes 112 | 113 | Currently release notes is semi-automatic. After the release is done, go to the releases page, and edit the release that was just created. Click the "Generate release notes" button to get release notes, and update the release. 114 | -------------------------------------------------------------------------------- /src/components/Panel/Panel.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render as renderRtl, screen } from '@testing-library/react'; 3 | 4 | import { mockMediaQuery } from '@test/testUtils'; 5 | import { tokens } from '@/DesignTokens'; 6 | 7 | import type { PanelProps } from './Panel'; 8 | import { Panel, PanelVariant } from './Panel'; 9 | 10 | const mediaQueryBreakPoint = 500; 11 | const { setScreenWidth } = mockMediaQuery(mediaQueryBreakPoint); 12 | 13 | describe('Panel', () => { 14 | beforeEach(() => { 15 | setScreenWidth(mediaQueryBreakPoint + 100); 16 | }); 17 | 18 | Object.values(PanelVariant).forEach((variant) => { 19 | it(`should render panel with correct classname when variant is ${variant}`, () => { 20 | render({ variant }); 21 | const otherVariants = Object.values(PanelVariant).filter( 22 | (v) => v !== variant, 23 | ); 24 | 25 | const pointer = screen.getByTestId('panel-content-wrapper'); 26 | 27 | expect( 28 | pointer.classList.contains(`panel__content-wrapper--${variant}`), 29 | ).toBe(true); 30 | otherVariants.forEach((v) => { 31 | expect(pointer.classList.contains(`panel__content-wrapper--${v}`)).toBe( 32 | false, 33 | ); 34 | }); 35 | }); 36 | }); 37 | 38 | describe('title', () => { 39 | it('should show title when "title" prop is set', () => { 40 | render({ title: 'Hello from Panel' }); 41 | 42 | expect( 43 | screen.getByRole('heading', { 44 | name: /hello from panel/i, 45 | }), 46 | ).toBeInTheDocument(); 47 | }); 48 | 49 | it('should not show title when "title" prop is not set', () => { 50 | render({ title: undefined }); 51 | 52 | expect(screen.queryByRole('heading')).not.toBeInTheDocument(); 53 | }); 54 | }); 55 | 56 | describe('Pointer', () => { 57 | it('should show pointer when "showPointer" is true', () => { 58 | render({ showPointer: true }); 59 | expect(screen.getByTestId('panel-pointer')).toBeInTheDocument(); 60 | }); 61 | 62 | it('should not show pointer when "showPointer" is not set', () => { 63 | render(); 64 | expect(screen.queryByTestId('panel-pointer')).not.toBeInTheDocument(); 65 | }); 66 | 67 | it('should not show pointer when "showPointer" is false', () => { 68 | render({ showPointer: false }); 69 | expect(screen.queryByTestId('panel-pointer')).not.toBeInTheDocument(); 70 | }); 71 | 72 | Object.values(PanelVariant).forEach((variant) => { 73 | it(`should render pointer with correct classname when variant is ${variant}`, () => { 74 | render({ showPointer: true, variant }); 75 | const otherVariants = Object.values(PanelVariant).filter( 76 | (v) => v !== variant, 77 | ); 78 | 79 | const pointer = screen.getByTestId('panel-pointer'); 80 | 81 | expect(pointer.classList.contains(`panel__pointer--${variant}`)).toBe( 82 | true, 83 | ); 84 | otherVariants.forEach((v) => { 85 | expect(pointer.classList.contains(`panel__pointer--${v}`)).toBe( 86 | false, 87 | ); 88 | }); 89 | }); 90 | }); 91 | }); 92 | 93 | describe('Icon', () => { 94 | it('should show icon when "showIcon" is true', () => { 95 | render({ showIcon: true }); 96 | expect(screen.getByTestId('panel-icon-wrapper')).toBeInTheDocument(); 97 | }); 98 | 99 | it('should show icon when "showIcon" is not set', () => { 100 | render(); 101 | expect(screen.getByTestId('panel-icon-wrapper')).toBeInTheDocument(); 102 | }); 103 | 104 | it('should not show icon when "showIcon" is false', () => { 105 | render({ showIcon: false }); 106 | expect( 107 | screen.queryByTestId('panel-icon-wrapper'), 108 | ).not.toBeInTheDocument(); 109 | }); 110 | 111 | it('should show info icon when variant is not set', () => { 112 | render(); 113 | expect(screen.getByTestId('panel-icon-wrapper')).toBeInTheDocument(); 114 | expect(screen.getByTestId('panel-icon-info')).toBeInTheDocument(); 115 | expect( 116 | screen.queryByTestId('panel-icon-success'), 117 | ).not.toBeInTheDocument(); 118 | }); 119 | 120 | it('should show info icon when variant is info', () => { 121 | render({ variant: PanelVariant.Info }); 122 | expect(screen.getByTestId('panel-icon-wrapper')).toBeInTheDocument(); 123 | expect(screen.getByTestId('panel-icon-info')).toBeInTheDocument(); 124 | expect( 125 | screen.queryByTestId('panel-icon-success'), 126 | ).not.toBeInTheDocument(); 127 | }); 128 | 129 | it('should show info icon when variant is warning', () => { 130 | render({ variant: PanelVariant.Warning }); 131 | expect(screen.getByTestId('panel-icon-wrapper')).toBeInTheDocument(); 132 | expect(screen.getByTestId('panel-icon-info')).toBeInTheDocument(); 133 | expect( 134 | screen.queryByTestId('panel-icon-success'), 135 | ).not.toBeInTheDocument(); 136 | }); 137 | 138 | it('should show info icon when variant is error', () => { 139 | render({ variant: PanelVariant.Error }); 140 | expect(screen.getByTestId('panel-icon-wrapper')).toBeInTheDocument(); 141 | expect(screen.getByTestId('panel-icon-info')).toBeInTheDocument(); 142 | expect( 143 | screen.queryByTestId('panel-icon-success'), 144 | ).not.toBeInTheDocument(); 145 | }); 146 | 147 | it('should show success icon when variant is success', () => { 148 | render({ variant: PanelVariant.Success }); 149 | expect(screen.getByTestId('panel-icon-wrapper')).toBeInTheDocument(); 150 | expect(screen.getByTestId('panel-icon-success')).toBeInTheDocument(); 151 | expect(screen.queryByTestId('panel-icon-info')).not.toBeInTheDocument(); 152 | }); 153 | 154 | it('should allow overriding icon with renderIcon callback', () => { 155 | const renderIcon = () => <div data-testid='panel-custom-icon' />; 156 | render({ renderIcon }); 157 | 158 | expect(screen.getByTestId('panel-icon-wrapper')).toBeInTheDocument(); 159 | expect(screen.getByTestId('panel-custom-icon')).toBeInTheDocument(); 160 | expect(screen.queryByTestId('panel-icon-info')).not.toBeInTheDocument(); 161 | expect( 162 | screen.queryByTestId('panel-icon-success'), 163 | ).not.toBeInTheDocument(); 164 | }); 165 | 166 | it('should pass size and variant to renderIcon callback', () => { 167 | const renderIcon = jest.fn(); 168 | render({ renderIcon }); 169 | 170 | expect(renderIcon).toHaveBeenCalledWith({ 171 | size: tokens.ComponentPanelSizeIconMd, 172 | variant: PanelVariant.Info, 173 | }); 174 | }); 175 | 176 | it('should pass smaller size and variant to renderIcon callback when viewport is small', () => { 177 | setScreenWidth(mediaQueryBreakPoint - 100); 178 | 179 | const renderIcon = jest.fn(); 180 | render({ renderIcon }); 181 | 182 | expect(renderIcon).toHaveBeenCalledWith({ 183 | size: tokens.ComponentPanelSizeIconXs, 184 | variant: PanelVariant.Info, 185 | }); 186 | }); 187 | 188 | it('should pass smaller size and variant to renderIcon callback when viewport is big and forceMobileLayout is true', () => { 189 | const renderIcon = jest.fn(); 190 | render({ renderIcon, forceMobileLayout: true }); 191 | 192 | expect(renderIcon).toHaveBeenCalledWith({ 193 | size: tokens.ComponentPanelSizeIconXs, 194 | variant: PanelVariant.Info, 195 | }); 196 | }); 197 | 198 | it('should allow overriding panel pointer with renderArrow callback', () => { 199 | const renderArrow = () => <div data-testid='panel-arrow' />; 200 | render({ renderArrow, showPointer: true }); 201 | 202 | expect(screen.getByTestId('panel-arrow')).toBeInTheDocument(); 203 | expect(screen.queryByTestId('panel-pointer')).not.toBeInTheDocument(); 204 | }); 205 | }); 206 | }); 207 | 208 | const render = (props: Partial<PanelProps> = {}) => { 209 | const allProps = { 210 | title: 'Panel title', 211 | children: <div>Panel content</div>, 212 | ...props, 213 | }; 214 | 215 | renderRtl(<Panel {...allProps} />); 216 | }; 217 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Built files 2 | /dist/ 3 | /storybook-static/ 4 | 5 | # Yarn stuff; we're not using PnP/Zero installs 6 | .pnp.* 7 | 8 | # Really don't want that 9 | /node_modules/ 10 | 11 | # Local env variables should stay local 12 | .env 13 | 14 | coverage/ 15 | .eslintcache 16 | 17 | # Folder metadata 18 | .DS_Store 19 | Thumbs.db 20 | 21 | 22 | 23 | ## Ignore Visual Studio temporary files, build results, and 24 | ## files generated by popular Visual Studio add-ons. 25 | ## 26 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 27 | 28 | # User-specific files 29 | *.rsuser 30 | *.suo 31 | *.user 32 | *.userosscache 33 | *.sln.docstates 34 | 35 | # User-specific files (MonoDevelop/Xamarin Studio) 36 | *.userprefs 37 | 38 | # Mono auto generated files 39 | mono_crash.* 40 | 41 | # Build results 42 | [Dd]ebug/ 43 | [Dd]ebugPublic/ 44 | [Rr]elease/ 45 | [Rr]eleases/ 46 | x64/ 47 | x86/ 48 | [Aa][Rr][Mm]/ 49 | [Aa][Rr][Mm]64/ 50 | bld/ 51 | [Bb]in/ 52 | [Oo]bj/ 53 | [Ll]og/ 54 | [Ll]ogs/ 55 | 56 | # Visual Studio 2015/2017 cache/options directory 57 | .vs/ 58 | 59 | # Uncomment if you have tasks that create the project's static files in wwwroot 60 | #wwwroot/ 61 | 62 | # Ignore intellij files 63 | .idea 64 | 65 | # Visual Studio 2017 auto generated files 66 | Generated\ Files/ 67 | 68 | # MSTest test Results 69 | [Tt]est[Rr]esult*/ 70 | [Bb]uild[Ll]og.* 71 | 72 | # NUnit 73 | *.VisualState.xml 74 | TestResult.xml 75 | nunit-*.xml 76 | 77 | # Build Results of an ATL Project 78 | [Dd]ebugPS/ 79 | [Rr]eleasePS/ 80 | dlldata.c 81 | 82 | # Benchmark Results 83 | BenchmarkDotNet.Artifacts/ 84 | 85 | # .NET Core 86 | project.lock.json 87 | project.fragment.lock.json 88 | artifacts/ 89 | 90 | # StyleCop 91 | StyleCopReport.xml 92 | 93 | # Files built by Visual Studio 94 | *_i.c 95 | *_p.c 96 | *_h.h 97 | *.ilk 98 | *.meta 99 | *.obj 100 | *.iobj 101 | *.pch 102 | *.pdb 103 | *.ipdb 104 | *.pgc 105 | *.pgd 106 | *.rsp 107 | *.sbr 108 | *.tlb 109 | *.tli 110 | *.tlh 111 | *.tmp 112 | *.tmp_proj 113 | *_wpftmp.csproj 114 | *.log 115 | *.vspscc 116 | *.vssscc 117 | .builds 118 | *.pidb 119 | *.svclog 120 | *.scc 121 | 122 | # Chutzpah Test files 123 | _Chutzpah* 124 | 125 | # Visual C++ cache files 126 | ipch/ 127 | *.aps 128 | *.ncb 129 | *.opendb 130 | *.opensdf 131 | *.sdf 132 | *.cachefile 133 | *.VC.db 134 | *.VC.VC.opendb 135 | 136 | # Visual Studio profiler 137 | *.psess 138 | *.vsp 139 | *.vspx 140 | *.sap 141 | 142 | # Visual Studio Trace Files 143 | *.e2e 144 | 145 | # TFS 2012 Local Workspace 146 | $tf/ 147 | 148 | # Guidance Automation Toolkit 149 | *.gpState 150 | 151 | # ReSharper is a .NET coding add-in 152 | _ReSharper*/ 153 | *.[Rr]e[Ss]harper 154 | *.DotSettings.user 155 | 156 | # TeamCity is a build add-in 157 | _TeamCity* 158 | 159 | # DotCover is a Code Coverage Tool 160 | *.dotCover 161 | 162 | # AxoCover is a Code Coverage Tool 163 | .axoCover/* 164 | !.axoCover/settings.json 165 | 166 | # Visual Studio code coverage results 167 | *.coverage 168 | *.coveragexml 169 | 170 | # NCrunch 171 | _NCrunch_* 172 | .*crunch*.local.xml 173 | nCrunchTemp_* 174 | 175 | # MightyMoose 176 | *.mm.* 177 | AutoTest.Net/ 178 | 179 | # Web workbench (sass) 180 | .sass-cache/ 181 | 182 | # Installshield output folder 183 | [Ee]xpress/ 184 | 185 | # DocProject is a documentation generator add-in 186 | DocProject/buildhelp/ 187 | DocProject/Help/*.HxT 188 | DocProject/Help/*.HxC 189 | DocProject/Help/*.hhc 190 | DocProject/Help/*.hhk 191 | DocProject/Help/*.hhp 192 | DocProject/Help/Html2 193 | DocProject/Help/html 194 | 195 | # Click-Once directory 196 | publish/ 197 | 198 | # Publish Web Output 199 | *.[Pp]ublish.xml 200 | *.azurePubxml 201 | # Note: Comment the next line if you want to checkin your web deploy settings, 202 | # but database connection strings (with potential passwords) will be unencrypted 203 | *.pubxml 204 | *.publishproj 205 | 206 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 207 | # checkin your Azure Web App publish settings, but sensitive information contained 208 | # in these scripts will be unencrypted 209 | PublishScripts/ 210 | 211 | # NuGet Packages 212 | *.nupkg 213 | # NuGet Symbol Packages 214 | *.snupkg 215 | # The packages folder can be ignored because of Package Restore 216 | **/[Pp]ackages/* 217 | # except build/, which is used as an MSBuild target. 218 | !**/[Pp]ackages/build/ 219 | # Uncomment if necessary however generally it will be regenerated when needed 220 | #!**/[Pp]ackages/repositories.config 221 | # NuGet v3's project.json files produces more ignorable files 222 | *.nuget.props 223 | *.nuget.targets 224 | 225 | # Microsoft Azure Build Output 226 | csx/ 227 | *.build.csdef 228 | 229 | # Microsoft Azure Emulator 230 | ecf/ 231 | rcf/ 232 | 233 | # Windows Store app package directories and files 234 | AppPackages/ 235 | BundleArtifacts/ 236 | Package.StoreAssociation.xml 237 | _pkginfo.txt 238 | *.appx 239 | *.appxbundle 240 | *.appxupload 241 | 242 | # Visual Studio cache files 243 | # files ending in .cache can be ignored 244 | *.[Cc]ache 245 | # but keep track of directories ending in .cache 246 | !?*.[Cc]ache/ 247 | 248 | # Others 249 | ClientBin/ 250 | ~$* 251 | *~ 252 | *.dbmdl 253 | *.dbproj.schemaview 254 | *.jfm 255 | *.pfx 256 | *.publishsettings 257 | orleans.codegen.cs 258 | 259 | # Including strong name files can present a security risk 260 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 261 | #*.snk 262 | 263 | # Since there are multiple workflows, uncomment next line to ignore bower_components 264 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 265 | #bower_components/ 266 | 267 | # RIA/Silverlight projects 268 | Generated_Code/ 269 | 270 | # Backup & report files from converting an old project file 271 | # to a newer Visual Studio version. Backup files are not needed, 272 | # because we have git ;-) 273 | _UpgradeReport_Files/ 274 | Backup*/ 275 | UpgradeLog*.XML 276 | UpgradeLog*.htm 277 | ServiceFabricBackup/ 278 | *.rptproj.bak 279 | 280 | # SQL Server files 281 | *.mdf 282 | *.ldf 283 | *.ndf 284 | 285 | # Business Intelligence projects 286 | *.rdl.data 287 | *.bim.layout 288 | *.bim_*.settings 289 | *.rptproj.rsuser 290 | *- [Bb]ackup.rdl 291 | *- [Bb]ackup ([0-9]).rdl 292 | *- [Bb]ackup ([0-9][0-9]).rdl 293 | 294 | # Microsoft Fakes 295 | FakesAssemblies/ 296 | 297 | # GhostDoc plugin setting file 298 | *.GhostDoc.xml 299 | 300 | # Node.js Tools for Visual Studio 301 | .ntvs_analysis.dat 302 | node_modules/ 303 | 304 | # Visual Studio 6 build log 305 | *.plg 306 | 307 | # Visual Studio 6 workspace options file 308 | *.opt 309 | 310 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 311 | *.vbw 312 | 313 | # Visual Studio LightSwitch build output 314 | **/*.HTMLClient/GeneratedArtifacts 315 | **/*.DesktopClient/GeneratedArtifacts 316 | **/*.DesktopClient/ModelManifest.xml 317 | **/*.Server/GeneratedArtifacts 318 | **/*.Server/ModelManifest.xml 319 | _Pvt_Extensions 320 | 321 | # Paket dependency manager 322 | .paket/paket.exe 323 | paket-files/ 324 | 325 | # FAKE - F# Make 326 | .fake/ 327 | 328 | # CodeRush personal settings 329 | .cr/personal 330 | 331 | # Python Tools for Visual Studio (PTVS) 332 | __pycache__/ 333 | *.pyc 334 | 335 | # Cake - Uncomment if you are using it 336 | # tools/** 337 | # !tools/packages.config 338 | 339 | # Tabs Studio 340 | *.tss 341 | 342 | # Telerik's JustMock configuration file 343 | *.jmconfig 344 | 345 | # BizTalk build output 346 | *.btp.cs 347 | *.btm.cs 348 | *.odx.cs 349 | *.xsd.cs 350 | 351 | # OpenCover UI analysis results 352 | OpenCover/ 353 | 354 | # Azure Stream Analytics local run output 355 | ASALocalRun/ 356 | 357 | # MSBuild Binary and Structured Log 358 | *.binlog 359 | 360 | # NVidia Nsight GPU debugger configuration file 361 | *.nvuser 362 | 363 | # MFractors (Xamarin productivity tool) working folder 364 | .mfractor/ 365 | 366 | # Local History for Visual Studio 367 | .localhistory/ 368 | 369 | # BeatPulse healthcheck temp database 370 | healthchecksdb 371 | 372 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 373 | MigrationBackup/ 374 | 375 | # Ionide (cross platform F# VS Code tools) working folder 376 | .ionide/ 377 | 378 | # JetBrains IntelliJ IDEA 379 | .idea/ 380 | .vscode/settings.json 381 | -------------------------------------------------------------------------------- /.yarn/sdks/typescript/lib/tsserver.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire, createRequireFromPath} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = (createRequire || createRequireFromPath)(absPnpApiPath); 11 | 12 | const moduleWrapper = tsserver => { 13 | if (!process.versions.pnp) { 14 | return tsserver; 15 | } 16 | 17 | const {isAbsolute} = require(`path`); 18 | const pnpApi = require(`pnpapi`); 19 | 20 | const isVirtual = str => str.match(/\/(\$\$virtual|__virtual__)\//); 21 | const isPortal = str => str.startsWith("portal:/"); 22 | const normalize = str => str.replace(/\\/g, `/`).replace(/^\/?/, `/`); 23 | 24 | const dependencyTreeRoots = new Set(pnpApi.getDependencyTreeRoots().map(locator => { 25 | return `${locator.name}@${locator.reference}`; 26 | })); 27 | 28 | // VSCode sends the zip paths to TS using the "zip://" prefix, that TS 29 | // doesn't understand. This layer makes sure to remove the protocol 30 | // before forwarding it to TS, and to add it back on all returned paths. 31 | 32 | function toEditorPath(str) { 33 | // We add the `zip:` prefix to both `.zip/` paths and virtual paths 34 | if (isAbsolute(str) && !str.match(/^\^?(zip:|\/zip\/)/) && (str.match(/\.zip\//) || isVirtual(str))) { 35 | // We also take the opportunity to turn virtual paths into physical ones; 36 | // this makes it much easier to work with workspaces that list peer 37 | // dependencies, since otherwise Ctrl+Click would bring us to the virtual 38 | // file instances instead of the real ones. 39 | // 40 | // We only do this to modules owned by the the dependency tree roots. 41 | // This avoids breaking the resolution when jumping inside a vendor 42 | // with peer dep (otherwise jumping into react-dom would show resolution 43 | // errors on react). 44 | // 45 | const resolved = isVirtual(str) ? pnpApi.resolveVirtual(str) : str; 46 | if (resolved) { 47 | const locator = pnpApi.findPackageLocator(resolved); 48 | if (locator && (dependencyTreeRoots.has(`${locator.name}@${locator.reference}`) || isPortal(locator.reference))) { 49 | str = resolved; 50 | } 51 | } 52 | 53 | str = normalize(str); 54 | 55 | if (str.match(/\.zip\//)) { 56 | switch (hostInfo) { 57 | // Absolute VSCode `Uri.fsPath`s need to start with a slash. 58 | // VSCode only adds it automatically for supported schemes, 59 | // so we have to do it manually for the `zip` scheme. 60 | // The path needs to start with a caret otherwise VSCode doesn't handle the protocol 61 | // 62 | // Ref: https://github.com/microsoft/vscode/issues/105014#issuecomment-686760910 63 | // 64 | // Update Oct 8 2021: VSCode changed their format in 1.61. 65 | // Before | ^zip:/c:/foo/bar.zip/package.json 66 | // After | ^/zip//c:/foo/bar.zip/package.json 67 | // 68 | case `vscode <1.61`: { 69 | str = `^zip:${str}`; 70 | } break; 71 | 72 | case `vscode`: { 73 | str = `^/zip/${str}`; 74 | } break; 75 | 76 | // To make "go to definition" work, 77 | // We have to resolve the actual file system path from virtual path 78 | // and convert scheme to supported by [vim-rzip](https://github.com/lbrayner/vim-rzip) 79 | case `coc-nvim`: { 80 | str = normalize(resolved).replace(/\.zip\//, `.zip::`); 81 | str = resolve(`zipfile:${str}`); 82 | } break; 83 | 84 | // Support neovim native LSP and [typescript-language-server](https://github.com/theia-ide/typescript-language-server) 85 | // We have to resolve the actual file system path from virtual path, 86 | // everything else is up to neovim 87 | case `neovim`: { 88 | str = normalize(resolved).replace(/\.zip\//, `.zip::`); 89 | str = `zipfile://${str}`; 90 | } break; 91 | 92 | default: { 93 | str = `zip:${str}`; 94 | } break; 95 | } 96 | } 97 | } 98 | 99 | return str; 100 | } 101 | 102 | function fromEditorPath(str) { 103 | switch (hostInfo) { 104 | case `coc-nvim`: { 105 | str = str.replace(/\.zip::/, `.zip/`); 106 | // The path for coc-nvim is in format of /<pwd>/zipfile:/<pwd>/.yarn/... 107 | // So in order to convert it back, we use .* to match all the thing 108 | // before `zipfile:` 109 | return process.platform === `win32` 110 | ? str.replace(/^.*zipfile:\//, ``) 111 | : str.replace(/^.*zipfile:/, ``); 112 | } break; 113 | 114 | case `neovim`: { 115 | str = str.replace(/\.zip::/, `.zip/`); 116 | // The path for neovim is in format of zipfile:///<pwd>/.yarn/... 117 | return str.replace(/^zipfile:\/\//, ``); 118 | } break; 119 | 120 | case `vscode`: 121 | default: { 122 | return process.platform === `win32` 123 | ? str.replace(/^\^?(zip:|\/zip)\/+/, ``) 124 | : str.replace(/^\^?(zip:|\/zip)\/+/, `/`); 125 | } break; 126 | } 127 | } 128 | 129 | // Force enable 'allowLocalPluginLoads' 130 | // TypeScript tries to resolve plugins using a path relative to itself 131 | // which doesn't work when using the global cache 132 | // https://github.com/microsoft/TypeScript/blob/1b57a0395e0bff191581c9606aab92832001de62/src/server/project.ts#L2238 133 | // VSCode doesn't want to enable 'allowLocalPluginLoads' due to security concerns but 134 | // TypeScript already does local loads and if this code is running the user trusts the workspace 135 | // https://github.com/microsoft/vscode/issues/45856 136 | const ConfiguredProject = tsserver.server.ConfiguredProject; 137 | const {enablePluginsWithOptions: originalEnablePluginsWithOptions} = ConfiguredProject.prototype; 138 | ConfiguredProject.prototype.enablePluginsWithOptions = function() { 139 | this.projectService.allowLocalPluginLoads = true; 140 | return originalEnablePluginsWithOptions.apply(this, arguments); 141 | }; 142 | 143 | // And here is the point where we hijack the VSCode <-> TS communications 144 | // by adding ourselves in the middle. We locate everything that looks 145 | // like an absolute path of ours and normalize it. 146 | 147 | const Session = tsserver.server.Session; 148 | const {onMessage: originalOnMessage, send: originalSend} = Session.prototype; 149 | let hostInfo = `unknown`; 150 | 151 | Object.assign(Session.prototype, { 152 | onMessage(/** @type {string | object} */ message) { 153 | const isStringMessage = typeof message === 'string'; 154 | const parsedMessage = isStringMessage ? JSON.parse(message) : message; 155 | 156 | if ( 157 | parsedMessage != null && 158 | typeof parsedMessage === `object` && 159 | parsedMessage.arguments && 160 | typeof parsedMessage.arguments.hostInfo === `string` 161 | ) { 162 | hostInfo = parsedMessage.arguments.hostInfo; 163 | if (hostInfo === `vscode` && process.env.VSCODE_IPC_HOOK && process.env.VSCODE_IPC_HOOK.match(/Code\/1\.([1-5][0-9]|60)\./)) { 164 | hostInfo += ` <1.61`; 165 | } 166 | } 167 | 168 | const processedMessageJSON = JSON.stringify(parsedMessage, (key, value) => { 169 | return typeof value === 'string' ? fromEditorPath(value) : value; 170 | }); 171 | 172 | return originalOnMessage.call( 173 | this, 174 | isStringMessage ? processedMessageJSON : JSON.parse(processedMessageJSON) 175 | ); 176 | }, 177 | 178 | send(/** @type {any} */ msg) { 179 | return originalSend.call(this, JSON.parse(JSON.stringify(msg, (key, value) => { 180 | return typeof value === `string` ? toEditorPath(value) : value; 181 | }))); 182 | } 183 | }); 184 | 185 | return tsserver; 186 | }; 187 | 188 | if (existsSync(absPnpApiPath)) { 189 | if (!process.versions.pnp) { 190 | // Setup the environment to be able to require typescript/lib/tsserver.js 191 | require(absPnpApiPath).setup(); 192 | } 193 | } 194 | 195 | // Defer to the real typescript/lib/tsserver.js your application uses 196 | module.exports = moduleWrapper(absRequire(`typescript/lib/tsserver.js`)); 197 | -------------------------------------------------------------------------------- /.yarn/sdks/typescript/lib/tsserverlibrary.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const {existsSync} = require(`fs`); 4 | const {createRequire, createRequireFromPath} = require(`module`); 5 | const {resolve} = require(`path`); 6 | 7 | const relPnpApiPath = "../../../../.pnp.cjs"; 8 | 9 | const absPnpApiPath = resolve(__dirname, relPnpApiPath); 10 | const absRequire = (createRequire || createRequireFromPath)(absPnpApiPath); 11 | 12 | const moduleWrapper = tsserver => { 13 | if (!process.versions.pnp) { 14 | return tsserver; 15 | } 16 | 17 | const {isAbsolute} = require(`path`); 18 | const pnpApi = require(`pnpapi`); 19 | 20 | const isVirtual = str => str.match(/\/(\$\$virtual|__virtual__)\//); 21 | const isPortal = str => str.startsWith("portal:/"); 22 | const normalize = str => str.replace(/\\/g, `/`).replace(/^\/?/, `/`); 23 | 24 | const dependencyTreeRoots = new Set(pnpApi.getDependencyTreeRoots().map(locator => { 25 | return `${locator.name}@${locator.reference}`; 26 | })); 27 | 28 | // VSCode sends the zip paths to TS using the "zip://" prefix, that TS 29 | // doesn't understand. This layer makes sure to remove the protocol 30 | // before forwarding it to TS, and to add it back on all returned paths. 31 | 32 | function toEditorPath(str) { 33 | // We add the `zip:` prefix to both `.zip/` paths and virtual paths 34 | if (isAbsolute(str) && !str.match(/^\^?(zip:|\/zip\/)/) && (str.match(/\.zip\//) || isVirtual(str))) { 35 | // We also take the opportunity to turn virtual paths into physical ones; 36 | // this makes it much easier to work with workspaces that list peer 37 | // dependencies, since otherwise Ctrl+Click would bring us to the virtual 38 | // file instances instead of the real ones. 39 | // 40 | // We only do this to modules owned by the the dependency tree roots. 41 | // This avoids breaking the resolution when jumping inside a vendor 42 | // with peer dep (otherwise jumping into react-dom would show resolution 43 | // errors on react). 44 | // 45 | const resolved = isVirtual(str) ? pnpApi.resolveVirtual(str) : str; 46 | if (resolved) { 47 | const locator = pnpApi.findPackageLocator(resolved); 48 | if (locator && (dependencyTreeRoots.has(`${locator.name}@${locator.reference}`) || isPortal(locator.reference))) { 49 | str = resolved; 50 | } 51 | } 52 | 53 | str = normalize(str); 54 | 55 | if (str.match(/\.zip\//)) { 56 | switch (hostInfo) { 57 | // Absolute VSCode `Uri.fsPath`s need to start with a slash. 58 | // VSCode only adds it automatically for supported schemes, 59 | // so we have to do it manually for the `zip` scheme. 60 | // The path needs to start with a caret otherwise VSCode doesn't handle the protocol 61 | // 62 | // Ref: https://github.com/microsoft/vscode/issues/105014#issuecomment-686760910 63 | // 64 | // Update Oct 8 2021: VSCode changed their format in 1.61. 65 | // Before | ^zip:/c:/foo/bar.zip/package.json 66 | // After | ^/zip//c:/foo/bar.zip/package.json 67 | // 68 | case `vscode <1.61`: { 69 | str = `^zip:${str}`; 70 | } break; 71 | 72 | case `vscode`: { 73 | str = `^/zip/${str}`; 74 | } break; 75 | 76 | // To make "go to definition" work, 77 | // We have to resolve the actual file system path from virtual path 78 | // and convert scheme to supported by [vim-rzip](https://github.com/lbrayner/vim-rzip) 79 | case `coc-nvim`: { 80 | str = normalize(resolved).replace(/\.zip\//, `.zip::`); 81 | str = resolve(`zipfile:${str}`); 82 | } break; 83 | 84 | // Support neovim native LSP and [typescript-language-server](https://github.com/theia-ide/typescript-language-server) 85 | // We have to resolve the actual file system path from virtual path, 86 | // everything else is up to neovim 87 | case `neovim`: { 88 | str = normalize(resolved).replace(/\.zip\//, `.zip::`); 89 | str = `zipfile://${str}`; 90 | } break; 91 | 92 | default: { 93 | str = `zip:${str}`; 94 | } break; 95 | } 96 | } 97 | } 98 | 99 | return str; 100 | } 101 | 102 | function fromEditorPath(str) { 103 | switch (hostInfo) { 104 | case `coc-nvim`: { 105 | str = str.replace(/\.zip::/, `.zip/`); 106 | // The path for coc-nvim is in format of /<pwd>/zipfile:/<pwd>/.yarn/... 107 | // So in order to convert it back, we use .* to match all the thing 108 | // before `zipfile:` 109 | return process.platform === `win32` 110 | ? str.replace(/^.*zipfile:\//, ``) 111 | : str.replace(/^.*zipfile:/, ``); 112 | } break; 113 | 114 | case `neovim`: { 115 | str = str.replace(/\.zip::/, `.zip/`); 116 | // The path for neovim is in format of zipfile:///<pwd>/.yarn/... 117 | return str.replace(/^zipfile:\/\//, ``); 118 | } break; 119 | 120 | case `vscode`: 121 | default: { 122 | return process.platform === `win32` 123 | ? str.replace(/^\^?(zip:|\/zip)\/+/, ``) 124 | : str.replace(/^\^?(zip:|\/zip)\/+/, `/`); 125 | } break; 126 | } 127 | } 128 | 129 | // Force enable 'allowLocalPluginLoads' 130 | // TypeScript tries to resolve plugins using a path relative to itself 131 | // which doesn't work when using the global cache 132 | // https://github.com/microsoft/TypeScript/blob/1b57a0395e0bff191581c9606aab92832001de62/src/server/project.ts#L2238 133 | // VSCode doesn't want to enable 'allowLocalPluginLoads' due to security concerns but 134 | // TypeScript already does local loads and if this code is running the user trusts the workspace 135 | // https://github.com/microsoft/vscode/issues/45856 136 | const ConfiguredProject = tsserver.server.ConfiguredProject; 137 | const {enablePluginsWithOptions: originalEnablePluginsWithOptions} = ConfiguredProject.prototype; 138 | ConfiguredProject.prototype.enablePluginsWithOptions = function() { 139 | this.projectService.allowLocalPluginLoads = true; 140 | return originalEnablePluginsWithOptions.apply(this, arguments); 141 | }; 142 | 143 | // And here is the point where we hijack the VSCode <-> TS communications 144 | // by adding ourselves in the middle. We locate everything that looks 145 | // like an absolute path of ours and normalize it. 146 | 147 | const Session = tsserver.server.Session; 148 | const {onMessage: originalOnMessage, send: originalSend} = Session.prototype; 149 | let hostInfo = `unknown`; 150 | 151 | Object.assign(Session.prototype, { 152 | onMessage(/** @type {string | object} */ message) { 153 | const isStringMessage = typeof message === 'string'; 154 | const parsedMessage = isStringMessage ? JSON.parse(message) : message; 155 | 156 | if ( 157 | parsedMessage != null && 158 | typeof parsedMessage === `object` && 159 | parsedMessage.arguments && 160 | typeof parsedMessage.arguments.hostInfo === `string` 161 | ) { 162 | hostInfo = parsedMessage.arguments.hostInfo; 163 | if (hostInfo === `vscode` && process.env.VSCODE_IPC_HOOK && process.env.VSCODE_IPC_HOOK.match(/Code\/1\.([1-5][0-9]|60)\./)) { 164 | hostInfo += ` <1.61`; 165 | } 166 | } 167 | 168 | const processedMessageJSON = JSON.stringify(parsedMessage, (key, value) => { 169 | return typeof value === 'string' ? fromEditorPath(value) : value; 170 | }); 171 | 172 | return originalOnMessage.call( 173 | this, 174 | isStringMessage ? processedMessageJSON : JSON.parse(processedMessageJSON) 175 | ); 176 | }, 177 | 178 | send(/** @type {any} */ msg) { 179 | return originalSend.call(this, JSON.parse(JSON.stringify(msg, (key, value) => { 180 | return typeof value === `string` ? toEditorPath(value) : value; 181 | }))); 182 | } 183 | }); 184 | 185 | return tsserver; 186 | }; 187 | 188 | if (existsSync(absPnpApiPath)) { 189 | if (!process.versions.pnp) { 190 | // Setup the environment to be able to require typescript/lib/tsserverlibrary.js 191 | require(absPnpApiPath).setup(); 192 | } 193 | } 194 | 195 | // Defer to the real typescript/lib/tsserverlibrary.js your application uses 196 | module.exports = moduleWrapper(absRequire(`typescript/lib/tsserverlibrary.js`)); 197 | -------------------------------------------------------------------------------- /src/components/_InputWrapper/InputWrapper.test.tsx: -------------------------------------------------------------------------------- 1 | import { render as renderRtl, screen } from '@testing-library/react'; 2 | import React from 'react'; 3 | 4 | import type { InputWrapperProps } from './InputWrapper'; 5 | import { InputWrapper } from './InputWrapper'; 6 | import { ReadOnlyVariant, InputVariant } from './utils'; 7 | 8 | const getClassNames = (expectedClassName: string) => { 9 | const otherClassNames = Object.values(InputVariant).filter( 10 | (v) => v !== expectedClassName, 11 | ); 12 | 13 | return { expectedClassName, otherClassNames }; 14 | }; 15 | 16 | describe('InputWrapper', () => { 17 | describe('error-icon', () => { 18 | it('should not show error-icon when isValid is true', () => { 19 | render({ isValid: true }); 20 | expect(screen.queryByTestId('input-icon-error')).not.toBeInTheDocument(); 21 | }); 22 | 23 | it('should not show error-icon when isValid is true and readOnly is true', () => { 24 | render({ isValid: true, readOnly: true }); 25 | expect(screen.queryByTestId('input-icon-error')).not.toBeInTheDocument(); 26 | }); 27 | 28 | it('should not show error-icon when isValid is true and disabled is true', () => { 29 | render({ isValid: true, disabled: true }); 30 | expect(screen.queryByTestId('input-icon-error')).not.toBeInTheDocument(); 31 | }); 32 | 33 | it('should show error-icon when isValid is false', () => { 34 | render({ isValid: false }); 35 | expect(screen.queryByTestId('input-icon-error')).toBeInTheDocument(); 36 | }); 37 | }); 38 | 39 | describe('search-icon', () => { 40 | it('should not show search-icon when search is false', () => { 41 | render({ isSearch: false }); 42 | expect(screen.queryByTestId('input-icon-search')).not.toBeInTheDocument(); 43 | }); 44 | 45 | it('should not show search-icon when search is false', () => { 46 | render({ isSearch: true }); 47 | expect(screen.queryByTestId('input-icon-search')).toBeInTheDocument(); 48 | }); 49 | 50 | it('should not show search-icon when isValid is false', () => { 51 | render({ isValid: false }); 52 | expect(screen.queryByTestId('input-icon-search')).not.toBeInTheDocument(); 53 | }); 54 | }); 55 | 56 | describe('input-variant', () => { 57 | it('should render with correct classname when when isValid is false and readOnly or disabled is not specified', () => { 58 | render({ isValid: false }); 59 | const { expectedClassName, otherClassNames } = getClassNames( 60 | InputVariant.Error, 61 | ); 62 | 63 | const textField = screen.getByTestId('InputWrapper'); 64 | 65 | expect( 66 | textField.classList.contains(`InputWrapper--${expectedClassName}`), 67 | ).toBe(true); 68 | otherClassNames.forEach((v) => { 69 | expect(textField.classList.contains(`InputWrapper--${v}`)).toBe(false); 70 | }); 71 | }); 72 | 73 | it('should render with correct classname when isValid is true and readOnly or disabled is not specified', () => { 74 | render({ isValid: true }); 75 | const { expectedClassName, otherClassNames } = getClassNames( 76 | InputVariant.Default, 77 | ); 78 | 79 | const textField = screen.getByTestId('InputWrapper'); 80 | 81 | expect( 82 | textField.classList.contains(`InputWrapper--${expectedClassName}`), 83 | ).toBe(true); 84 | otherClassNames.forEach((v) => { 85 | expect(textField.classList.contains(`InputWrapper--${v}`)).toBe(false); 86 | }); 87 | }); 88 | 89 | it('should render with correct classname when readOnly is true and disabled is not specified', () => { 90 | render({ readOnly: true }); 91 | const { expectedClassName, otherClassNames } = getClassNames( 92 | InputVariant.ReadOnlyInfo, 93 | ); 94 | 95 | const textField = screen.getByTestId('InputWrapper'); 96 | 97 | expect( 98 | textField.classList.contains(`InputWrapper--${expectedClassName}`), 99 | ).toBe(true); 100 | otherClassNames.forEach((v) => { 101 | expect(textField.classList.contains(`InputWrapper--${v}`)).toBe(false); 102 | }); 103 | }); 104 | 105 | it('should render with correct classname when readOnly is <readonly-confirm> and disabled is not specified', () => { 106 | render({ readOnly: ReadOnlyVariant.ReadOnlyConfirm }); 107 | const { expectedClassName, otherClassNames } = getClassNames( 108 | InputVariant.ReadOnlyConfirm, 109 | ); 110 | 111 | const textField = screen.getByTestId('InputWrapper'); 112 | 113 | expect( 114 | textField.classList.contains(`InputWrapper--${expectedClassName}`), 115 | ).toBe(true); 116 | otherClassNames.forEach((v) => { 117 | expect(textField.classList.contains(`InputWrapper--${v}`)).toBe(false); 118 | }); 119 | }); 120 | 121 | it('should render with correct classname when readOnly is <readonly-info> and disabled is not specified', () => { 122 | render({ readOnly: ReadOnlyVariant.ReadOnlyInfo }); 123 | const { expectedClassName, otherClassNames } = getClassNames( 124 | InputVariant.ReadOnlyInfo, 125 | ); 126 | 127 | const textField = screen.getByTestId('InputWrapper'); 128 | 129 | expect( 130 | textField.classList.contains(`InputWrapper--${expectedClassName}`), 131 | ).toBe(true); 132 | otherClassNames.forEach((v) => { 133 | expect(textField.classList.contains(`InputWrapper--${v}`)).toBe(false); 134 | }); 135 | }); 136 | 137 | it('should render with correct classname when disabled is true', () => { 138 | render({ disabled: true }); 139 | const { expectedClassName, otherClassNames } = getClassNames( 140 | InputVariant.Disabled, 141 | ); 142 | 143 | const textField = screen.getByTestId('InputWrapper'); 144 | 145 | expect( 146 | textField.classList.contains(`InputWrapper--${expectedClassName}`), 147 | ).toBe(true); 148 | otherClassNames.forEach((v) => { 149 | expect(textField.classList.contains(`InputWrapper--${v}`)).toBe(false); 150 | }); 151 | }); 152 | 153 | it('Renders with padding class by default', () => { 154 | render(); 155 | const { classList } = screen.getByTestId('InputWrapper'); 156 | expect(classList).toContain('InputWrapper--with-padding'); 157 | }); 158 | 159 | it('Renders without padding class when "noPadding" property is true', () => { 160 | render({ noPadding: true }); 161 | const { classList } = screen.getByTestId('InputWrapper'); 162 | expect(classList).not.toContain('InputWrapper--with-padding'); 163 | }); 164 | 165 | it('Renders with focus-effect class by default', () => { 166 | render(); 167 | const { classList } = screen.getByTestId('InputWrapper'); 168 | expect(classList).toContain('InputWrapper--with-focus-effect'); 169 | }); 170 | 171 | it('Renders without focus-effect class when "noFocusEffect" property is true', () => { 172 | render({ noFocusEffect: true }); 173 | const { classList } = screen.getByTestId('InputWrapper'); 174 | expect(classList).not.toContain('InputWrapper--with-focus-effect'); 175 | }); 176 | }); 177 | 178 | describe('Label', () => { 179 | it('should show label when label is set', () => { 180 | const label = 'Label is here'; 181 | render({ label }); 182 | expect(screen.queryByText(label)).toBeInTheDocument(); 183 | }); 184 | 185 | it('should not show label when label is not set', () => { 186 | render({ label: undefined }); 187 | expect( 188 | screen.queryByTestId('InputWrapper-label'), 189 | ).not.toBeInTheDocument(); 190 | }); 191 | 192 | it('Attaches label to input element when inputId is not set', () => { 193 | const label = 'Label is here'; 194 | render({ label }); 195 | expect(screen.getByLabelText(label)).toHaveAttribute('id'); 196 | }); 197 | 198 | it('Attaches label to input element when inputId is set', () => { 199 | const inputId = 'some-unique-id'; 200 | const label = 'Label is here'; 201 | render({ inputId, label }); 202 | expect(screen.getByLabelText(label)).toHaveAttribute('id', inputId); 203 | }); 204 | }); 205 | }); 206 | 207 | const render = (props: Partial<InputWrapperProps> = {}) => { 208 | const allProps = { 209 | inputRenderer: ({ className, inputId }) => ( 210 | <input 211 | className={className} 212 | id={inputId} 213 | /> 214 | ), 215 | ...props, 216 | } as InputWrapperProps; 217 | 218 | return renderRtl(<InputWrapper {...allProps} />); 219 | }; 220 | --------------------------------------------------------------------------------