diff --git a/lib/components/Attachment/AttachmentLink.stories.ts b/lib/components/Attachment/AttachmentLink.stories.ts new file mode 100644 index 0000000..6b46164 --- /dev/null +++ b/lib/components/Attachment/AttachmentLink.stories.ts @@ -0,0 +1,21 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { fn } from '@storybook/test'; + +import { AttachmentLink } from './AttachmentLink'; + +const meta = { + title: 'Attachment/AttachmentLink', + component: AttachmentLink, + tags: ['autodocs'], + parameters: {}, + args: { + label: 'Document.pdf', + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; diff --git a/lib/components/Attachment/AttachmentLink.tsx b/lib/components/Attachment/AttachmentLink.tsx new file mode 100644 index 0000000..b36d9fc --- /dev/null +++ b/lib/components/Attachment/AttachmentLink.tsx @@ -0,0 +1,20 @@ +import { Icon, type IconName } from '../Icon'; +import styles from './attachmentLink.module.css'; + +export interface AttachmentLinkProps { + /** Link url */ + href: string; + /** Label (filename) */ + label: string; + /** Icon */ + icon?: IconName; +} + +export const AttachmentLink = ({ icon = 'file', href, label }: AttachmentLinkProps) => { + return ( + + + {label} + + ); +}; diff --git a/lib/components/Attachment/AttachmentList.stories.ts b/lib/components/Attachment/AttachmentList.stories.ts new file mode 100644 index 0000000..6e2b1b4 --- /dev/null +++ b/lib/components/Attachment/AttachmentList.stories.ts @@ -0,0 +1,39 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { fn } from '@storybook/test'; + +import { AttachmentList } from './AttachmentList'; + +const meta = { + title: 'Attachment/AttachmentList', + component: AttachmentList, + tags: ['autodocs'], + parameters: { + layout: 'fullscreen', + }, + args: { + items: [ + { + label: '1-0 Castro.pdf', + }, + { + label: '2-0 Kornvig.pdf', + }, + { + label: '3-0 Kartum.pdf', + }, + { + label: '3-1 Zinkernagel.pdf', + }, + { + label: '4-1 Castro.pdf', + }, + ], + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; diff --git a/lib/components/Attachment/AttachmentList.tsx b/lib/components/Attachment/AttachmentList.tsx new file mode 100644 index 0000000..82d2721 --- /dev/null +++ b/lib/components/Attachment/AttachmentList.tsx @@ -0,0 +1,26 @@ +import type { TypographySize } from '../Typography'; +import { AttachmentLink, type AttachmentLinkProps } from './AttachmentLink'; +import styles from './attachmentList.module.css'; + +export interface AttachmentListProps { + items: AttachmentLinkProps[]; + size?: TypographySize; +} + +export const AttachmentList = ({ size, items }: AttachmentListProps) => { + if (!items.length) { + return null; + } + + return ( +
    + {items.map((item, index) => { + return ( +
  • + +
  • + ); + })} +
+ ); +}; diff --git a/lib/components/Attachment/attachmentLink.module.css b/lib/components/Attachment/attachmentLink.module.css new file mode 100644 index 0000000..46454fc --- /dev/null +++ b/lib/components/Attachment/attachmentLink.module.css @@ -0,0 +1,20 @@ +.link { + display: inline-flex; + align-items: start; + column-gap: 0.25em; + color: var(--theme-base-default); +} + +.link:hover { + color: var(--theme-base-hover); +} + +.label { + text-decoration: underline; + text-decoration-thickness: 2px; + text-underline-offset: 2px; +} + +.icon { + font-size: 1.5em; +} diff --git a/lib/components/Attachment/attachmentList.module.css b/lib/components/Attachment/attachmentList.module.css new file mode 100644 index 0000000..1c564fe --- /dev/null +++ b/lib/components/Attachment/attachmentList.module.css @@ -0,0 +1,12 @@ +.list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; +} + +.item { + padding: 0; + margin: 0; +} diff --git a/lib/components/Attachment/index.ts b/lib/components/Attachment/index.ts new file mode 100644 index 0000000..b661c85 --- /dev/null +++ b/lib/components/Attachment/index.ts @@ -0,0 +1,2 @@ +export * from './AttachmentLink'; +export * from './AttachmentList'; diff --git a/lib/components/Button/Button.stories.ts b/lib/components/Button/Button.stories.ts index 2b29974..25b52c7 100644 --- a/lib/components/Button/Button.stories.ts +++ b/lib/components/Button/Button.stories.ts @@ -37,6 +37,12 @@ export const Text: Story = { }, }; +export const Loading: Story = { + args: { + loading: true, + }, +}; + export const Disabled: Story = { args: { disabled: true, diff --git a/lib/components/Button/Button.tsx b/lib/components/Button/Button.tsx index 43733e2..8640047 100644 --- a/lib/components/Button/Button.tsx +++ b/lib/components/Button/Button.tsx @@ -1,12 +1,12 @@ import cx from 'classnames'; import { Icon, type IconName } from '../Icon'; import { ButtonBase, type ButtonBaseProps } from './ButtonBase'; - import styles from './button.module.css'; export interface ButtonProps extends Partial { icon?: IconName; reverse?: boolean; + loading?: boolean; } export const Button = ({ @@ -16,8 +16,26 @@ export const Button = ({ icon, href, children, + loading, ...rest }: ButtonProps) => { + if (loading) { + return ( + + + Loading.... + + + ); + } + return ( { + return ( +
+ + + +
+ +
+
+ ); +}; diff --git a/lib/components/ContextMenu/contextMenu.module.css b/lib/components/ContextMenu/contextMenu.module.css new file mode 100644 index 0000000..51d663c --- /dev/null +++ b/lib/components/ContextMenu/contextMenu.module.css @@ -0,0 +1,35 @@ +.toggle { + position: relative; +} + +.button { + display: flex; + align-items: center; + justify-content: center; + width: 2.75rem; + padding: 0.625rem; + border-radius: 50%; +} + +.icon { + font-size: 1.5rem; +} + +.dropdown { + display: none; +} + +.dropdown[aria-expanded="true"] { + display: block; + position: absolute; + right: 0; + z-index: 2; +} + +.dropdown { + margin-top: 0.5rem; + padding: 0 0.5rem; + background-color: var(--neutral-background-default); + border-radius: 2px; + box-shadow: 0 0 1px 0 rgba(0, 0, 0, 0.15), 0 1px 2px 0 rgba(0, 0, 0, 0.12), 0 2px 4px 0 rgba(0, 0, 0, 0.1); +} diff --git a/lib/components/Dialog/Dialog.stories.ts b/lib/components/Dialog/Dialog.stories.ts new file mode 100644 index 0000000..635b5ea --- /dev/null +++ b/lib/components/Dialog/Dialog.stories.ts @@ -0,0 +1,320 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { fn } from '@storybook/test'; + +import { Dialog } from './Dialog'; + +const meta = { + title: 'Dialog/Dialog', + component: Dialog, + tags: ['autodocs'], + parameters: {}, + argTypes: { body: { control: 'text' } }, + args: { + updatedAt: '1999-05-26', + title: 'Title', + summary: 'Summary', + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Body: Story = { + args: { + body: + '## Body\n\n' + + 'Body will be loaded async.\n\n' + + '### Syntax\n\n' + + 'body supporters basic headings from h2 -> h4 + lists.\n\n' + + '- List 1\n' + + '- List 2\n' + + '- List 3\n', + }, +}; + +export const Attachments: Story = { + args: { + attachments: { + items: [ + { + label: 'Dokument 1.pdf', + }, + { + label: 'Dokument 2.pdf', + }, + ], + }, + }, +}; + +export const Action: Story = { + args: { + action: [ + { + label: 'Primary', + }, + { + label: 'Secondary', + }, + ], + }, +}; + +export const SeenBy: Story = { + args: { + ...Action.args, + seenBy: { + as: 'a', + href: '#', + label: 'Sett av deg + 24', + seenByEndUser: true, + seenByOthersCount: 24, + }, + activityLog: { + as: 'a', + href: '#', + label: 'Aktivitetslogg', + }, + }, +}; + +export const Example: Story = { + args: { + status: { value: 'requires-attention' }, + sender: { + name: 'Statistisk sentralbyrå', + imageUrl: 'https://digdir-proto-proto.vercel.app/_next/static/media/SSB.0ca4474e.png', + }, + recipient: { + name: 'Bergen Bar', + }, + title: 'Rapportering av bedriftsdata', + summary: 'Du må levere bedriftsdata innen 31. oktober.', + additionalInfo: + 'Din bedrift er pålagt å rapportere inn bedriftsdata innen **31. oktober**. ' + + 'Vi bruker svarene dine kun til å utarbeide statistikk, og enkeltsvar vil aldri bli offentliggjort. Du kan når som helst kontakte oss og kreve av opplysningene om deg blir slettet.', + action: [ + { + label: 'Rapporter bedriftsdata', + }, + ], + attachments: { + items: [ + { + label: 'Vedtak om innlevering av bedriftsdata.pdf', + }, + ], + }, + }, +}; + +export const HistoryContact: Story = { + args: { + status: { value: 'requires-attention' }, + sender: { + name: 'Statistisk sentralbyrå', + imageUrl: 'https://digdir-proto-proto.vercel.app/_next/static/media/SSB.0ca4474e.png', + }, + recipient: { + name: 'Bergen Bar', + }, + title: 'Rapportering av bedriftsdata', + summary: 'Innrapporteringen inneholder feil. Se over og lever på nytt.', + additionalInfo: + 'Din bedrift er pålagt å rapportere inn bedriftsdata innen **31. oktober**. ' + + 'Vi bruker svarene dine kun til å utarbeide statistikk, og enkeltsvar vil aldri bli offentliggjort. Du kan når som helst kontakte oss og kreve av opplysningene om deg blir slettet.', + action: [ + { + label: 'Rapporter bedriftsdata', + }, + ], + attachments: { + items: [ + { + label: 'Feilliste.pdf', + }, + ], + }, + history: [ + { + createdAt: '2004-01-01 13:34', + createdBy: { + name: 'Eirik Horneland', + }, + summary: 'Rapportering ble sendt inn.', + attachments: [ + { + label: 'Kvittering.pdf', + }, + ], + }, + { + createdAt: '2004-01-01 13:34', + createdBy: { + imageUrl: 'https://digdir-proto-proto.vercel.app/_next/static/media/SSB.0ca4474e.png', + + name: 'Statistisk sentralbyrå', + }, + summary: 'Du må levere bedriftsdata innen 31. oktober.', + attachments: [ + { + label: 'Vedtak om innlevering av bedriftsdata.pdf', + }, + ], + }, + ], + contactInfo: + 'Kontakt oss på svar@ssb.no eller ring 62 88 56 08.\n\n' + + 'Svartjenesten er åpen alle hverdager fra kl. 9-21 og lørdager fra kl. 10-16.', + }, +}; + +export const Signering: Story = { + args: { + status: { value: 'requires-attention' }, + + sender: { + name: 'Statistisk sentralbyrå', + imageUrl: 'https://digdir-proto-proto.vercel.app/_next/static/media/SSB.0ca4474e.png', + }, + + recipient: { + name: 'Bergen Bar', + }, + + title: 'Registrere enkeltmannsforetak', + summary: 'Skjema er klar til signering.', + additionalInfo: + 'Din bedrift er pålagt å rapportere inn bedriftsdata innen **31. oktober**. Vi bruker svarene dine kun til å utarbeide statistikk, og enkeltsvar vil aldri bli offentliggjort. Du kan når som helst kontakte oss og kreve av opplysningene om deg blir slettet.', + + action: [ + { + label: 'Gå til signering', + }, + ], + + attachments: {}, + + history: { + items: [ + { + updatedAt: '2004-01-01 13:34', + + updatedBy: { + name: 'Eirik Horneland', + }, + + summary: 'Rapportering ble sendt inn.', + + attachments: [ + { + label: 'Kvittering.pdf', + }, + ], + }, + { + updatedAt: '2004-01-01 13:34', + + updatedBy: { + imageUrl: 'https://digdir-proto-proto.vercel.app/_next/static/media/SSB.0ca4474e.png', + name: 'Statistisk sentralbyrå', + }, + + summary: 'Du må levere bedriftsdata innen 31. oktober.', + + attachments: [ + { + label: 'Vedtak om innlevering av bedriftsdata.pdf', + }, + ], + }, + ], + }, + + contact: { + title: 'Ta kontakt', + body: 'Ta kontakt for mer informasjon på telefon 99 00 00 00.', + }, + }, +}; + +export const BrregDraft: Story = { + args: { + status: { value: 'draft' }, + + sender: { + name: 'Statistisk sentralbyrå', + imageUrl: 'https://digdir-proto-proto.vercel.app/_next/static/media/SSB.0ca4474e.png', + }, + + recipient: { + name: 'Bergen Bar', + }, + + title: 'Registrere enkeltmannsforetak', + summary: 'Skjema er opprettet.', + + action: [ + { + label: 'Gå til skjema', + }, + { + label: 'Forkast', + }, + ], + + attachments: {}, + additionalInfo: + 'Enkeltpersonforetak må registreres i Enhetsregisteret for å få et organisasjonsnummer. Enkelte må også registreres i Foretaksregisteret.', + }, +}; + +export const BrregSign: Story = { + args: { + ...BrregDraft.args, + status: { value: 'requires-attention' }, + summary: 'Skjema er klar til signering.', + + action: [ + { + label: 'Til signering', + }, + { + label: 'Avslå signering', + }, + ], + + history: { + items: [ + { + createdAt: '2004-01-01 13:34', + createdBy: { + type: 'person', + name: 'Eirik Horneland', + }, + summary: 'Rapportering ble sendt inn.', + attachments: [ + { + label: 'Kvittering.pdf', + }, + ], + }, + { + createdAt: '2004-01-01 13:34', + createdBy: { + imageUrl: 'https://digdir-proto-proto.vercel.app/_next/static/media/SSB.0ca4474e.png', + name: 'Statistisk sentralbyrå', + }, + + summary: 'Du må levere bedriftsdata innen 31. oktober.', + + attachments: [ + { + label: 'Vedtak om innlevering av bedriftsdata.pdf', + }, + ], + }, + ], + }, + }, +}; diff --git a/lib/components/Dialog/Dialog.tsx b/lib/components/Dialog/Dialog.tsx new file mode 100644 index 0000000..23d5719 --- /dev/null +++ b/lib/components/Dialog/Dialog.tsx @@ -0,0 +1,101 @@ +import { MetaBase } from '../Meta'; +import { DialogActivityLog, type DialogActivityLogProps } from './DialogActivityLog'; +import { DialogArticleBase } from './DialogArticleBase'; +import { DialogAttachments, type DialogAttachmentsProps } from './DialogAttachments'; +import { DialogBase } from './DialogBase'; +import { DialogBodyBase } from './DialogBodyBase'; +import { DialogContent } from './DialogContent'; +import { DialogFooter } from './DialogFooter'; +import { DialogHeader } from './DialogHeader'; +import { DialogSeenBy, type DialogSeenByProps } from './DialogSeenBy'; + +import type { ReactNode } from 'react'; +import { DialogAction, type DialogActionButtonProps } from './DialogAction'; +import type { DialogRecipientProps, DialogSenderProps } from './DialogHeadings.tsx'; +import { DialogHistory, type DialogHistoryProps } from './DialogHistory'; +import { type DialogBackButtonProps, DialogNav } from './DialogNav'; +import type { DialogStatusProps } from './DialogStatus'; + +export interface DialogProps { + /** Title */ + title: string; + /** Back button */ + backButton?: DialogBackButtonProps; + /** Dialog status */ + status?: DialogStatusProps; + /** Updated date time */ + updatedAt?: string; + /** Latest updated by name */ + updatedByName?: string; + /** Due date */ + dueAt?: string; + /** Sender */ + sender?: DialogSenderProps; + /** Recipient */ + recipient?: DialogRecipientProps; + /** Summary */ + summary?: string; + /** Body (should be an output markdown/html rendered to React / HTML) */ + body?: ReactNode; + /** List of action (buttons) */ + actions?: DialogActionButtonProps[]; + /** Dialog attachments */ + attachments?: DialogAttachmentsProps; + /** Dialog is seen by the end user or others */ + seenBy?: DialogSeenByProps; + /** Activity Log */ + activityLog?: DialogActivityLogProps; + /** More information about the dialog, process, etc. */ + additionalInfo?: string; + /** History */ + history?: DialogHistoryProps; +} + +/** + * Full representation of a dialog, including attachments, actions and history, + */ + +export const Dialog = ({ + backButton, + updatedAt, + updatedByName, + dueAt, + status, + title, + sender, + recipient, + summary = 'Summary.', + body, + actions = [], + attachments, + history, + seenBy, + activityLog, + additionalInfo, +}: DialogProps) => { + return ( + + + + + + + {attachments && } + {actions?.length > 0 && } + + {seenBy && } + {activityLog && } + + + {additionalInfo && } + {history && } + + + ); +}; diff --git a/lib/components/Dialog/DialogAction.stories.ts b/lib/components/Dialog/DialogAction.stories.ts new file mode 100644 index 0000000..c836800 --- /dev/null +++ b/lib/components/Dialog/DialogAction.stories.ts @@ -0,0 +1,54 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { DialogAction } from './DialogAction'; + +const meta = { + title: 'Dialog/Sections/DialogAction', + component: DialogAction, + tags: ['autodocs'], + args: { + items: [ + { + label: 'Primary', + priority: 'primary', + }, + { + label: 'Secondary', + priority: 'secondary', + }, + ], + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; + +export const Secondary: Story = { + args: {}, +}; + +export const MultipleButtons: Story = { + args: { + items: [ + { + label: 'Primary', + priority: 'primary', + }, + { + label: 'Secondary', + priority: 'secondary', + }, + { + label: 'Third action', + priority: 'tertiary', + }, + { + label: 'Fourth action', + priority: 'tertiary', + }, + ], + }, +}; diff --git a/lib/components/Dialog/DialogAction.tsx b/lib/components/Dialog/DialogAction.tsx new file mode 100644 index 0000000..adc3fd7 --- /dev/null +++ b/lib/components/Dialog/DialogAction.tsx @@ -0,0 +1,79 @@ +import { type MouseEventHandler, useMemo, useState } from 'react'; +import { Button, ComboButton } from '../Button'; +import { Menu, type MenuItemProps } from '../Menu'; +import styles from './dialogAction.module.css'; + +export type DialogButtonPriority = 'primary' | 'secondary' | 'tertiary'; + +export interface DialogActionButtonProps { + id: string; + priority: DialogButtonPriority; + label?: string; + onClick?: MouseEventHandler; + loading?: boolean; +} + +export interface DialogActionProps { + /** List of actions */ + items: DialogActionButtonProps[]; + /** How many actions to display before turning into a ComboButton */ + maxItems?: number; +} + +export const DialogAction = ({ items, maxItems = 2 }: DialogActionProps) => { + const [expanded, setExpanded] = useState(false); + const sortedItems = useMemo(() => { + return (items || []).sort((a, b) => { + const priorityOrder = ['primary', 'secondary', 'tertiary']; + return priorityOrder.indexOf(a?.priority) - priorityOrder.indexOf(b?.priority); + }); + }, [items]); + + if (!sortedItems.length || maxItems <= 0) { + return null; + } + + if (sortedItems.length > maxItems) { + const remainingItems: MenuItemProps[] = sortedItems.slice(1).map((item) => ({ + id: item.id, + title: item.label, + onClick: item.onClick, + group: item.priority, + })); + return ( +
+ setExpanded((expanded) => !expanded)} + > + {sortedItems[0].label} + +
+ +
+
+ ); + } + + return ( +
+ {sortedItems.map((item, index) => { + return ( + + ); + })} +
+ ); +}; diff --git a/lib/components/Dialog/DialogActivityLog.tsx b/lib/components/Dialog/DialogActivityLog.tsx new file mode 100644 index 0000000..60596e7 --- /dev/null +++ b/lib/components/Dialog/DialogActivityLog.tsx @@ -0,0 +1,18 @@ +import type { ElementType, MouseEventHandler } from 'react'; +import { MetaItem, type MetaItemSize } from '../Meta'; + +export interface DialogActivityLogProps { + size?: MetaItemSize; + label?: string; + as?: ElementType; + onClick?: MouseEventHandler; + href?: string; +} + +export const DialogActivityLog = ({ size = 'xs', label = 'Activity log', ...rest }: DialogActivityLogProps) => { + return ( + + {label} + + ); +}; diff --git a/lib/components/Dialog/DialogArticleBase.tsx b/lib/components/Dialog/DialogArticleBase.tsx new file mode 100644 index 0000000..65ae5d2 --- /dev/null +++ b/lib/components/Dialog/DialogArticleBase.tsx @@ -0,0 +1,10 @@ +import type { ReactNode } from 'react'; +import styles from './dialogArticleBase.module.css'; + +interface ArticleBaseProps { + children?: ReactNode; +} + +export const DialogArticleBase = ({ children }: ArticleBaseProps) => { + return
{children}
; +}; diff --git a/lib/components/Dialog/DialogAttachments.stories.ts b/lib/components/Dialog/DialogAttachments.stories.ts new file mode 100644 index 0000000..c582a9e --- /dev/null +++ b/lib/components/Dialog/DialogAttachments.stories.ts @@ -0,0 +1,40 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { fn } from '@storybook/test'; + +import { DialogAttachments } from './DialogAttachments'; + +const meta = { + title: 'Dialog/Sections/DialogAttachments', + component: DialogAttachments, + tags: ['autodocs'], + args: { + title: '6 vedlegg', + items: [ + { + label: 'A10-01 Situasjonsplan.pdf', + }, + { + label: 'A40-01 Fasade Nord Ny.pdf', + }, + { + label: 'A40-01 Fasade Øst.pdf', + }, + { + label: 'Tegning ny fasade.pdf', + }, + { + label: 'Tegning nytt snitt.pdf', + }, + { + label: 'Redegjørelse estetikk.pdf', + }, + ], + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; diff --git a/lib/components/Dialog/DialogAttachments.tsx b/lib/components/Dialog/DialogAttachments.tsx new file mode 100644 index 0000000..13f1a42 --- /dev/null +++ b/lib/components/Dialog/DialogAttachments.tsx @@ -0,0 +1,25 @@ +import { type AttachmentLinkProps, AttachmentList } from '../Attachment'; +import { MetaItem } from '../Meta'; +import { Typography } from '../Typography'; + +export interface DialogAttachmentsProps { + title?: string; + items?: AttachmentLinkProps[]; +} + +export const DialogAttachments = ({ title = 'Attachments', items }: DialogAttachmentsProps) => { + if (!items?.length) { + return null; + } + + return ( +
+ + {title} + + + + +
+ ); +}; diff --git a/lib/components/Dialog/DialogBase.tsx b/lib/components/Dialog/DialogBase.tsx new file mode 100644 index 0000000..3ff77e4 --- /dev/null +++ b/lib/components/Dialog/DialogBase.tsx @@ -0,0 +1,10 @@ +import type { ReactNode } from 'react'; +import styles from './dialog.module.css'; + +export interface DialogBaseProps { + children?: ReactNode; +} + +export const DialogBase = ({ children }: DialogBaseProps) => { + return
{children}
; +}; diff --git a/lib/components/Dialog/DialogBodyBase.tsx b/lib/components/Dialog/DialogBodyBase.tsx new file mode 100644 index 0000000..cfa6a61 --- /dev/null +++ b/lib/components/Dialog/DialogBodyBase.tsx @@ -0,0 +1,17 @@ +import type { ReactNode } from 'react'; +import { DialogBorder } from './DialogBorder'; +import styles from './dialogBodyBase.module.css'; + +export interface DialogBodyProps { + children?: ReactNode; +} + +export const DialogBodyBase = ({ children }: DialogBodyProps) => { + return ( + +
+ {children} +
+
+ ); +}; diff --git a/lib/components/Dialog/DialogBorder.tsx b/lib/components/Dialog/DialogBorder.tsx new file mode 100644 index 0000000..bcc063c --- /dev/null +++ b/lib/components/Dialog/DialogBorder.tsx @@ -0,0 +1,19 @@ +import cx from 'classnames'; +import type { ReactNode } from 'react'; +import type { DialogListItemSize } from './DialogListItemBase'; +import styles from './dialogBorder.module.css'; + +export interface DialogBorderProps { + seen?: boolean; + size?: DialogListItemSize; + className?: string; + children?: ReactNode; +} + +export const DialogBorder = ({ seen = true, size = 'lg', className, children }: DialogBorderProps) => { + return ( +
+ {children} +
+ ); +}; diff --git a/lib/components/Dialog/DialogContent.stories.ts b/lib/components/Dialog/DialogContent.stories.ts new file mode 100644 index 0000000..3199e88 --- /dev/null +++ b/lib/components/Dialog/DialogContent.stories.ts @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { DialogContent } from './DialogContent'; + +const meta = { + title: 'Dialog/DialogContent', + component: DialogContent, + tags: ['autodocs'], + args: { + updatedAt: '1999-05-26 22:59:00', + summary: 'Summary', + body: 'Body', + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; + +export const UpdatedByName: Story = { + args: { + updatedByName: 'Ole Gunnar Solskjær', + }, +}; diff --git a/lib/components/Dialog/DialogContent.tsx b/lib/components/Dialog/DialogContent.tsx new file mode 100644 index 0000000..30ba4e0 --- /dev/null +++ b/lib/components/Dialog/DialogContent.tsx @@ -0,0 +1,24 @@ +import type { ReactNode } from 'react'; +import { Typography } from '../Typography'; +import { DialogMetadata } from './DialogMetadata'; + +export interface DialogContentProps { + updatedByName?: string; + updatedAt?: string; + updatedAtLabel?: string; + summary?: string; + body?: ReactNode; +} + +/** Main textual content of a dialog, including summary, body and a timestamp */ +export const DialogContent = ({ updatedAt, updatedAtLabel, summary, body }: DialogContentProps) => { + return ( +
+ + +

{summary}

+ {body} +
+
+ ); +}; diff --git a/lib/components/Dialog/DialogFooter.tsx b/lib/components/Dialog/DialogFooter.tsx new file mode 100644 index 0000000..454b7ee --- /dev/null +++ b/lib/components/Dialog/DialogFooter.tsx @@ -0,0 +1,14 @@ +import { Typography } from '../Typography'; +import { DialogSectionBase } from './DialogSectionBase'; + +export interface DialogFooterProps { + additionalInfo?: string; +} + +export const DialogFooter = ({ additionalInfo }: DialogFooterProps) => { + return ( + + {additionalInfo} + + ); +}; diff --git a/lib/components/Dialog/DialogHeader.stories.ts b/lib/components/Dialog/DialogHeader.stories.ts new file mode 100644 index 0000000..7cdf745 --- /dev/null +++ b/lib/components/Dialog/DialogHeader.stories.ts @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { fn } from '@storybook/test'; + +import { DialogHeader } from './DialogHeader'; + +const meta = { + title: 'Dialog/Sections/DialogHeader', + component: DialogHeader, + tags: ['autodocs'], + args: { + title: 'Title', + sender: { + name: 'Sender', + }, + recipient: { + name: 'Recipient', + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; diff --git a/lib/components/Dialog/DialogHeader.tsx b/lib/components/Dialog/DialogHeader.tsx new file mode 100644 index 0000000..61027e9 --- /dev/null +++ b/lib/components/Dialog/DialogHeader.tsx @@ -0,0 +1,23 @@ +import { DialogHeaderBase } from './DialogHeaderBase'; +import { DialogHeadings, type DialogRecipientProps, type DialogSenderProps } from './DialogHeadings'; +import type { DialogListItemVariant } from './DialogListItemBase.tsx'; +import { DialogTitle } from './DialogTitle'; + +export interface DialogHeaderProps { + title: string; + seen: boolean; + variant: DialogListItemVariant; + sender?: DialogSenderProps; + recipient?: DialogRecipientProps; +} + +export const DialogHeader = ({ title, sender, recipient, seen, variant }: DialogHeaderProps) => { + return ( + + + {title} + + + + ); +}; diff --git a/lib/components/Dialog/DialogHeaderBase.tsx b/lib/components/Dialog/DialogHeaderBase.tsx new file mode 100644 index 0000000..7492c37 --- /dev/null +++ b/lib/components/Dialog/DialogHeaderBase.tsx @@ -0,0 +1,10 @@ +import type { ReactNode } from 'react'; +import styles from './dialogHeaderBase.module.css'; + +export interface DialogHeaderProps { + children?: ReactNode; +} + +export const DialogHeaderBase = ({ children }: DialogHeaderProps) => { + return
{children}
; +}; diff --git a/lib/components/Dialog/DialogHeadings.stories.ts b/lib/components/Dialog/DialogHeadings.stories.ts new file mode 100644 index 0000000..0fe9fff --- /dev/null +++ b/lib/components/Dialog/DialogHeadings.stories.ts @@ -0,0 +1,35 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { DialogHeadings } from './DialogHeadings'; + +const meta = { + title: 'Dialog/DialogHeadings', + component: DialogHeadings, + tags: ['autodocs'], + parameters: {}, + args: { + sender: { + name: 'Sender', + }, + recipient: { + type: 'person', + name: 'Recipient', + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Company: Story = { + args: { + grouped: true, + }, +}; + +export const Grouped: Story = { + args: { + grouped: true, + }, +}; diff --git a/lib/components/Dialog/DialogHeadings.tsx b/lib/components/Dialog/DialogHeadings.tsx new file mode 100644 index 0000000..44cfb56 --- /dev/null +++ b/lib/components/Dialog/DialogHeadings.tsx @@ -0,0 +1,77 @@ +import { Avatar, AvatarGroup, type AvatarSize } from '../Avatar'; +import { MetaItem } from '../Meta'; +import styles from './dialogHeadings.module.css'; + +export type DialogSenderType = 'company' | 'person'; +export type DialogRecipientType = 'company' | 'person'; + +export interface DialogSenderProps { + type?: DialogSenderType; + name: string; + imageUrl?: string; +} + +export interface DialogRecipientProps { + type?: DialogRecipientType; + name: string; +} + +type DialogHeadingsSize = 'sm' | 'xs' | 'sm' | 'lg' | 'xl'; + +const sizeMap = { + avatar: { + xs: 'xs', + sm: 'xs', + md: 'xs', + lg: 'xs', + xl: 'lg', + }, +}; + +export interface DialogHeadingsProps { + size: DialogHeadingsSize; + /** Group sender and recipient avatars */ + grouped?: boolean; + /** Sender */ + sender?: DialogSenderProps; + /** Recipient */ + recipient?: DialogRecipientProps; +} + +/** Dialog headings for sender and recipient. Should present an avatar for the sender. */ + +export const DialogHeadings = ({ + grouped, + size = 'lg', + sender = { type: 'company', name: 'Sender' }, + recipient = { type: 'person', name: 'Recipient' }, +}: DialogHeadingsProps) => { + return ( +
+ {grouped ? ( + + ) : ( + + )} + + {sender.name} + {recipient?.name && ( + + {' til '} + {recipient.name} + + )} + +
+ ); +}; diff --git a/lib/components/Dialog/DialogHistory.stories.ts b/lib/components/Dialog/DialogHistory.stories.ts new file mode 100644 index 0000000..1b2fd3d --- /dev/null +++ b/lib/components/Dialog/DialogHistory.stories.ts @@ -0,0 +1,67 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { DialogHistory } from './DialogHistory'; + +const meta = { + title: 'Dialog/Sections/DialogHistory', + component: DialogHistory, + tags: ['autodocs'], + parameters: {}, + args: { + items: [ + { + createdBy: { + name: 'Kari Nordmann', + }, + createdAt: '2023-03-11 08:00', + summary: 'Skattemeldingen ble levert.', + attachments: [ + { + label: 'Kvittering på innsendt skattemelding.pdf', + }, + ], + }, + { + createdBy: { + type: 'company', + name: 'Skatteetaten', + imageUrl: 'https://digdir-proto-proto.vercel.app/_next/static/media/Skatteetaten.636ef817.png', + }, + createdAt: '2023-03-11 08:00', + summary: 'Vi har mottatt nye opplysninger og oppdatert skattemeldingen din.', + attachments: [ + { + label: 'Nye opplysninger til Skattemeldingen.pdf', + }, + ], + }, + { + createdBy: { + name: 'Kari Nordmann', + }, + createdAt: '2023-03-11 08:00', + summary: 'Skattemeldingen ble levert.', + attachments: [ + { + label: 'Kvittering på innsendt skattemelding.pdf', + }, + ], + }, + { + createdBy: { + type: 'company', + name: 'Skatteetaten', + imageUrl: 'https://digdir-proto-proto.vercel.app/_next/static/media/Skatteetaten.636ef817.png', + }, + createdAt: '2023-03-11 08:00', + summary: 'Skattemeldingen din for 2022 er tilgjengelig.', + }, + ], + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; diff --git a/lib/components/Dialog/DialogHistory.tsx b/lib/components/Dialog/DialogHistory.tsx new file mode 100644 index 0000000..84b2e21 --- /dev/null +++ b/lib/components/Dialog/DialogHistory.tsx @@ -0,0 +1,19 @@ +import { type HistoryItemProps, HistoryList } from '../History/'; +import { DialogSectionBase } from './DialogSectionBase'; + +export interface DialogHistoryProps { + title?: string; + items?: HistoryItemProps[]; +} + +export const DialogHistory = ({ title = 'History', items }: DialogHistoryProps) => { + if (!items) { + return null; + } + + return ( + + + + ); +}; diff --git a/lib/components/Dialog/DialogList.stories.ts b/lib/components/Dialog/DialogList.stories.ts new file mode 100644 index 0000000..4d35a63 --- /dev/null +++ b/lib/components/Dialog/DialogList.stories.ts @@ -0,0 +1,61 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { fn } from '@storybook/test'; + +import { DialogList } from './DialogList'; + +const meta = { + title: 'Dialog/DialogList', + component: DialogList, + tags: ['autodocs'], + parameters: { + layout: 'fullscreen', + }, + args: { + items: [ + { + title: 'Støtte til utbygging av solceller', + summary: 'Din støtte er innvilget', + status: { value: 'draft' }, + }, + { + title: 'Støtte til utbygging av solceller', + summary: 'Din støtte er innvilget', + status: { value: 'sent' }, + }, + { + title: 'Støtte til utbygging av solceller', + summary: 'Din støtte er innvilget', + status: { value: 'requires-attention' }, + }, + { + title: 'Støtte til utbygging av solceller', + summary: 'Din støtte er innvilget', + status: { value: 'in-progress' }, + }, + { + title: 'Støtte til utbygging av solceller', + summary: 'Din støtte er innvilget.', + status: { value: 'completed' }, + }, + ], + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; + +export const Company: Story = { + args: { + theme: 'company', + }, +}; + +export const Person: Story = { + args: { + theme: 'person', + }, +}; diff --git a/lib/components/Dialog/DialogList.tsx b/lib/components/Dialog/DialogList.tsx new file mode 100644 index 0000000..a3b7378 --- /dev/null +++ b/lib/components/Dialog/DialogList.tsx @@ -0,0 +1,20 @@ +import type { LayoutTheme } from '../Layout'; +import { ListBase } from '../List'; +import { DialogListItem, type DialogListItemProps } from './DialogListItem'; +import type { DialogListItemSize } from './DialogListItemBase'; + +export interface DialogListProps { + size?: DialogListItemSize; + theme?: LayoutTheme; + items?: DialogListItemProps[]; +} + +export const DialogList = ({ theme, size = 'md', items }: DialogListProps) => { + return ( + + {items?.map((item, index) => { + return ; + })} + + ); +}; diff --git a/lib/components/Dialog/DialogListItem.stories.tsx b/lib/components/Dialog/DialogListItem.stories.tsx new file mode 100644 index 0000000..1179b45 --- /dev/null +++ b/lib/components/Dialog/DialogListItem.stories.tsx @@ -0,0 +1,238 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { Fragment, useState } from 'react'; + +import { DialogListItem } from './DialogListItem'; +import { DialogStatusEnum } from './DialogStatus'; + +import { ListBase } from '../List'; +import { MetaItem } from '../Meta'; + +const getStatusLabel = (value) => { + switch (value) { + case 'draft': + return 'Utkast'; + case 'sent': + return 'Sendt'; + case 'requires-attention': + return 'Krever handling'; + case 'in-progress': + return 'Under arbeid'; + case 'completed': + return 'Avsluttet'; + default: + return ''; + } +}; + +const sizes = ['lg', 'md', 'sm', 'xs']; +const statuslist = Object.keys(DialogStatusEnum)?.map((value) => { + return { + value, + label: getStatusLabel(value), + }; +}); + +const meta = { + title: 'Dialog/DialogListItem', + component: DialogListItem, + tags: ['autodocs'], + parameters: {}, + argTypes: {}, + args: { + title: 'Title', + summary: 'Summary', + sender: { + type: 'company', + name: 'Sender name', + }, + recipient: { + type: 'person', + name: 'Recipient name', + }, + status: { + value: 'completed', + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const selectable: Story = { + args: { + select: { + checked: false, + }, + }, +}; + +export const selected: Story = { + args: { + selected: true, + select: { + checked: true, + }, + }, +}; + +export const seenByEndUser: Story = { + args: { + seenByEndUser: true, + }, +}; + +export const TouchedBy: Story = { + args: { + touchedBy: [ + { + name: 'Lars', + }, + { + name: 'Trine', + }, + ], + }, +}; + +export const Draft: Story = { + args: { + status: { + value: 'draft', + }, + }, +}; + +export const Sent: Story = { + args: { + status: { + value: 'sent', + }, + }, +}; + +export const RequiresAttention: Story = { + args: { + status: { + value: 'requires-attention', + }, + }, +}; + +export const InProgress: Story = { + args: { + status: { + value: 'in-progress', + }, + }, +}; + +export const Completed: Story = { + args: { + status: { + value: 'completed', + }, + }, +}; + +export const GroupedView: Story = { + args: { + grouped: true, + }, +}; + +export const LongSummary: Story = { + args: { + title: 'Long summary', + summary: + 'Praesent commodo cursus magna, vel scelerisque nisl consectetur et. Etiam porta sem malesuada magna mollis euismod. Maecenas faucibus mollis interdum. Nullam id dolor id nibh ultricies vehicula ut id elit.\n\nCras mattis consectetur purus sit amet fermentum. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean lacinia bibendum nulla sed consectetur. Maecenas faucibus mollis interdum. Nullam id dolor id nibh ultricies vehicula ut id elit. Nullam id dolor id nibh ultricies vehicula ut id elit.', + }, +}; + +export const LongTitle: Story = { + args: { + title: + 'Cras mattis consectetur purus sit amet fermentum. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean lacinia bibendum nulla sed consectetur. Maecenas faucibus mollis interdum. Nullam id dolor id nibh ultricies vehicula ut id elit. Nullam id dolor id nibh ultricies vehicula ut id elit.', + summary: 'Short summary.', + }, +}; + +export const SelectableSelected = (args) => { + const [items, setItems] = useState({ + 1: { + id: '1', + title: 'Item 1', + selected: true, + }, + 2: { + id: '2', + title: 'Item 2', + selected: false, + }, + 3: { + id: '3', + title: 'Item 2', + selected: false, + }, + }); + + const onSelect = ({ id }) => { + console.log('XX', id); + setItems((prevState) => { + return { + ...prevState, + [id]: { + ...prevState[id], + selected: !prevState[id].selected, + }, + }; + }); + }; + + return ( + + {Object.values(items)?.map((item) => { + return ( + + onSelect(item) }} + /> + selected:{item.selected ? 'true' : 'false'} + + ); + })} + + ); +}; + +export const Statuses = (args) => { + return ( + + {statuslist?.map((status) => { + return ( + + + {status?.value} + + ); + })} + + ); +}; + +export const Sizes = (args) => { + return ( + + {sizes?.map((size) => { + return ( + + + {size} + + ); + })} + + ); +}; diff --git a/lib/components/Dialog/DialogListItem.tsx b/lib/components/Dialog/DialogListItem.tsx new file mode 100644 index 0000000..5ef50db --- /dev/null +++ b/lib/components/Dialog/DialogListItem.tsx @@ -0,0 +1,114 @@ +import type { ElementType } from 'react'; +import { DialogHeadings, type DialogRecipientProps, type DialogSenderProps } from './DialogHeadings'; +import type { DialogSelectProps } from './DialogSelect'; +import type { DialogStatusProps } from './DialogStatus'; +import { DialogTitle } from './DialogTitle'; +import { DialogTouchedBy, type DialogTouchedByActor } from './DialogTouchedBy'; +import styles from './dialogListItem.module.css'; + +import { DialogListItemBase, type DialogListItemSize, type DialogListItemVariant } from './DialogListItemBase'; + +import { DialogBorder } from './DialogBorder'; +import { DialogMetadata } from './DialogMetadata'; +import type { DialogSeenByProps } from './DialogSeenBy'; + +export type DialogListItemProps = { + /** Dialog title */ + title: string; + /** Render as */ + as?: ElementType; + /** Size */ + size?: DialogListItemSize; + /** Variant */ + variant?: DialogListItemVariant; + /** Link */ + href?: string; + /** Select: Use to support batch operations */ + select?: DialogSelectProps; + /** Dialog is selected */ + selected?: boolean; + /** Dialog status */ + status?: DialogStatusProps; + /** Dialog sender */ + sender?: DialogSenderProps; + /** Dialog Recipient */ + recipient?: DialogRecipientProps; + /** Group view, show avatar for recipient */ + grouped?: boolean; + /** Dialog summary */ + summary?: string; + /** Updated datetime */ + updatedAt?: string; + /** Updated at label */ + updatedAtLabel?: string; + /** Dialog due date */ + dueAt?: string; + /** Dialog due date label */ + dueAtLabel?: string; + /** Dialog has been seen */ + seen?: boolean; + /** Dialog is seen by the user */ + seenBy?: DialogSeenByProps; + /** List of users that have touched the dialog */ + touchedBy?: DialogTouchedByActor[]; + /** Number of attachments */ + attachmentsCount?: number; +}; + +/** + * Represents a dialog in list view, displaying information such as the title, + * summary, sender, and receiver. + * to mark the item as checked/unchecked and can visually indicate if it is unread. + */ + +export const DialogListItem = ({ + as = 'a', + size = 'lg', + variant = 'neutral', + href, + select, + selected, + status, + sender, + recipient, + grouped, + updatedAt, + updatedAtLabel, + dueAt, + dueAtLabel, + seen = false, + seenBy, + touchedBy, + attachmentsCount, + title, + summary, + ...rest +}: DialogListItemProps) => { + return ( + + +
+ + {title} + + +
+

+ {summary} +

+
+ + {touchedBy && } +
+
+
+ ); +}; diff --git a/lib/components/Dialog/DialogListItemBase.tsx b/lib/components/Dialog/DialogListItemBase.tsx new file mode 100644 index 0000000..7ee8d0f --- /dev/null +++ b/lib/components/Dialog/DialogListItemBase.tsx @@ -0,0 +1,50 @@ +import type { ElementType, ReactNode } from 'react'; +import { DialogSelect, type DialogSelectProps } from './DialogSelect'; +import styles from './dialogListItemBase.module.css'; + +export type DialogListItemVariant = 'neutral' | 'draft' | 'bin' | 'archive'; +export type DialogListItemSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl'; + +export type DialogListItemBaseProps = { + /** Render as */ + as?: ElementType; + /** Size */ + size?: DialogListItemSize; + /** Link */ + href?: string; + /** Select? Use to support batch operations */ + select?: DialogSelectProps; + /** Dialog is selected */ + selected?: boolean; + /** Children */ + children?: ReactNode; + /** Variant */ + variant?: DialogListItemVariant; +}; + +/** + * Represents a dialog in list view, displaying information such as the title, + * summary, sender, and receiver. + * to mark the item as checked/unchecked and can visually indicate if it is unread. + */ + +export const DialogListItemBase = ({ + as = 'a', + size, + href, + select, + selected, + children, + ...rest +}: DialogListItemBaseProps) => { + const Component = as || 'button'; + + return ( +
+ + {children} + + {select && } +
+ ); +}; diff --git a/lib/components/Dialog/DialogMetadata.stories.ts b/lib/components/Dialog/DialogMetadata.stories.ts new file mode 100644 index 0000000..6f29b3e --- /dev/null +++ b/lib/components/Dialog/DialogMetadata.stories.ts @@ -0,0 +1,77 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { fn } from '@storybook/test'; + +import { DialogMetadata } from './DialogMetadata'; + +const meta = { + title: 'Dialog/DialogMetadata', + component: DialogMetadata, + tags: ['autodocs'], + parameters: {}, + args: { + updatedAt: '1999-05-26', + updatedAtLabel: '26. mai 1999', + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Draft: Story = { + args: { + status: { + value: 'draft', + label: 'Utkast', + }, + updatedAtLabel: 'Ole Gunnar Solskjær, 26. mai 1999', + }, +}; + +export const Sent: Story = { + args: { + status: { + value: 'sent', + label: 'Sendt', + }, + }, +}; + +export const RequiresAttentionAndDueDate: Story = { + args: { + status: { + value: 'requires-attention', + label: 'Krever handling', + }, + attachmentsCount: 3, + dueAt: '2000-01-01', + dueAtLabel: 'Frist: 1. januar 2001', + }, +}; + +export const InProgressSeenByOthers: Story = { + args: { + status: { + value: 'in-progress', + label: 'Under arbeid', + }, + seenBy: { + seenByEndUser: false, + seenByOthersCount: 4, + label: 'Sett av 4', + }, + }, +}; + +export const CompletedSeenByEndUser: Story = { + args: { + status: { + value: 'completed', + label: 'Avsluttet', + }, + seenBy: { + seenByEndUser: true, + seenByOthersCount: 0, + label: 'Sett av deg', + }, + }, +}; diff --git a/lib/components/Dialog/DialogMetadata.tsx b/lib/components/Dialog/DialogMetadata.tsx new file mode 100644 index 0000000..db95970 --- /dev/null +++ b/lib/components/Dialog/DialogMetadata.tsx @@ -0,0 +1,56 @@ +import { MetaBase, MetaItem, MetaTimestamp } from '../Meta'; +import { DialogSeenBy, type DialogSeenByProps } from './DialogSeenBy'; +import { DialogStatus, type DialogStatusProps } from './DialogStatus'; + +export type DialogMetadataProps = { + /** Dialog status */ + status?: DialogStatusProps; + /** Updated datetime */ + updatedAt?: string; + /** Updated label */ + updatedAtLabel?: string; + /** Due date */ + dueAt?: string; + /** Due date label */ + dueAtLabel?: string; + /** Who have seen the dialog after latest update */ + seenBy?: DialogSeenByProps; + /** Number of attachments */ + attachmentsCount?: number; +}; + +/** + * Metadata for a dialog in list view. + */ + +export const DialogMetadata = ({ + status, + updatedAt, + updatedAtLabel, + dueAt, + dueAtLabel, + seenBy, + attachmentsCount = 0, +}: DialogMetadataProps) => { + return ( + + {status && } + {updatedAt && ( + + {updatedAtLabel} + + )} + {dueAt && dueAtLabel && ( + + {dueAtLabel} + + )} + {seenBy && } + {attachmentsCount > 0 && ( + + {attachmentsCount} + + )} + + ); +}; diff --git a/lib/components/Dialog/DialogNav.stories.ts b/lib/components/Dialog/DialogNav.stories.ts new file mode 100644 index 0000000..62190d5 --- /dev/null +++ b/lib/components/Dialog/DialogNav.stories.ts @@ -0,0 +1,90 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { fn } from '@storybook/test'; + +import { DialogNav } from './DialogNav'; + +const meta = { + title: 'Dialog/DialogNav', + component: DialogNav, + tags: ['autodocs'], + parameters: {}, + args: {}, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Draft: Story = { + args: { + status: { + value: 'draft', + label: 'Utkast', + }, + }, +}; + +export const Sent: Story = { + args: { + status: { + value: 'draft', + label: 'Utkast', + }, + }, +}; + +export const RequiresAttention: Story = { + args: { + status: { + value: 'requires-attention', + label: 'Utkast', + }, + }, +}; + +export const InProgress: Story = { + args: { + status: { + value: 'in-progress', + label: 'Utkast', + }, + }, +}; + +export const ContextMenu: Story = { + args: { + menu: { + items: [ + { + id: '1', + group: '1', + icon: 'arrow-redo', + label: 'Del og gi tilgang', + }, + { + id: '2', + group: '1', + icon: 'eye-closed', + label: 'Marker som ny', + }, + { + id: '3', + group: '2', + icon: 'archive', + label: 'Flytt til arkiv', + }, + { + id: '4', + group: '2', + icon: 'trash', + label: 'Flytt til papirkurv', + }, + { + id: '5', + group: '3', + icon: 'clock-dashed', + label: 'Aktivitetslogg', + }, + ], + }, + }, +}; diff --git a/lib/components/Dialog/DialogNav.tsx b/lib/components/Dialog/DialogNav.tsx new file mode 100644 index 0000000..3fce9ee --- /dev/null +++ b/lib/components/Dialog/DialogNav.tsx @@ -0,0 +1,60 @@ +'use client'; +import { useState } from 'react'; +import type { ElementType } from 'react'; +import { Button } from '../Button'; +import { ContextMenu, type DialogContextMenuProps } from '../ContextMenu/ContextMenu.tsx'; +import { MetaTimestamp } from '../Meta'; +import { DialogStatus, type DialogStatusProps } from './DialogStatus'; +import { DialogTouchedBy, type DialogTouchedByActor } from './DialogTouchedBy'; +import styles from './dialog.module.css'; + +export interface DialogBackButtonProps { + as?: ElementType; + href?: string; + label?: string; +} + +export interface DialogNavProps { + status?: DialogStatusProps; + dueAt?: string; + duaAtLabel?: string; + touchedBy?: DialogTouchedByActor[]; + backButton?: DialogBackButtonProps; + menu?: DialogContextMenuProps; +} + +/** + * Dialog navigation bar with Back button and possibly a context menu. + */ +export const DialogNav = ({ + backButton = { + as: 'a', + label: 'Back', + }, + status, + dueAt, + touchedBy, + duaAtLabel, + menu, +}: DialogNavProps) => { + const [expandedItem, setexpandedItem] = useState(false); + const onToggle = () => setexpandedItem((expanded) => !expanded); + + return ( + + ); +}; diff --git a/lib/components/Dialog/DialogSectionBase.tsx b/lib/components/Dialog/DialogSectionBase.tsx new file mode 100644 index 0000000..b8efdc9 --- /dev/null +++ b/lib/components/Dialog/DialogSectionBase.tsx @@ -0,0 +1,20 @@ +import type { ReactNode } from 'react'; +import styles from './dialogSectionBase.module.css'; + +export interface DialogSectionBaseProps { + title?: string; + children?: ReactNode; +} + +export const DialogSectionBase = ({ title, children }: DialogSectionBaseProps) => { + if (!children) { + return null; + } + + return ( +
+ {title &&

{title}

} + {children} +
+ ); +}; diff --git a/lib/components/Dialog/DialogSeenBy.stories.tsx b/lib/components/Dialog/DialogSeenBy.stories.tsx new file mode 100644 index 0000000..d483ab4 --- /dev/null +++ b/lib/components/Dialog/DialogSeenBy.stories.tsx @@ -0,0 +1,58 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { fn } from '@storybook/test'; +import { useState } from 'react'; + +import { DialogSeenBy } from './DialogSeenBy'; + +const meta = { + title: 'Dialog/Atoms/DialogSeenBy', + component: DialogSeenBy, + tags: ['autodocs'], + parameters: {}, + args: { + seenByEndUser: true, + seenByOthersCount: 2, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Unseen: Story = { + args: { + seenByEndUser: false, + seenByOthersCount: 0, + }, +}; + +export const seenByEndUser: Story = { + args: { + seenByEndUser: true, + seenByOthersCount: 0, + }, +}; + +export const seenByEndUserAndOthers: Story = { + args: { + seenByEndUser: true, + seenByOthersCount: 10, + }, +}; + +export const ExampleLabel = ({ seenByEndUser, seenByOthersCount }) => { + let seen = false; + const seenByLabel = []; + + if (seenByEndUser) { + seen = true; + seenByLabel.push('deg'); + } + + if (seenByOthersCount) { + seen = true; + seenByLabel.push(seenByOthersCount); + } + + const label = 'Sett av ' + seenByLabel.join('+'); + return ; +}; diff --git a/lib/components/Dialog/DialogSeenBy.tsx b/lib/components/Dialog/DialogSeenBy.tsx new file mode 100644 index 0000000..1287fb0 --- /dev/null +++ b/lib/components/Dialog/DialogSeenBy.tsx @@ -0,0 +1,36 @@ +import type { ElementType, MouseEventHandler } from 'react'; +import { MetaItem, type MetaItemSize } from '../Meta'; + +export interface DialogSeenByProps { + size?: MetaItemSize; + /** Dialog has been seen by current end user */ + seenByEndUser?: boolean; + /** Dialog has been seen by other people */ + seenByOthersCount?: number; + /** A label explaining that the dialog have been seen and by whom. */ + label: string; + /** Render element as a link or button to display more informastion */ + as?: ElementType; + onClick?: MouseEventHandler; + href?: string; +} + +/** + * Dialog seen by. Used to indicate if the dialog has been seen by end user or other users. + */ + +export const DialogSeenBy = ({ + size = 'xs', + label = 'Seen by label', + seenByEndUser = false, + seenByOthersCount = 0, + ...rest +}: DialogSeenByProps) => { + const seen = seenByEndUser || seenByOthersCount > 0; + const iconName = seen ? 'eye' : 'eye-closed'; + return ( + + {label} + + ); +}; diff --git a/lib/components/Dialog/DialogSelect.tsx b/lib/components/Dialog/DialogSelect.tsx new file mode 100644 index 0000000..5b23e3b --- /dev/null +++ b/lib/components/Dialog/DialogSelect.tsx @@ -0,0 +1,25 @@ +import cx from 'classnames'; +import type { ChangeEventHandler } from 'react'; +import { CheckboxIcon } from '../Icon/'; +import styles from './dialogSelect.module.css'; + +export type DialogSelectProps = { + checked?: boolean; + onChange?: ChangeEventHandler; + className?: string; +}; + +/** + * Dialog checkbox + */ + +export const DialogSelect = ({ checked = false, onChange, className }: DialogSelectProps) => { + return ( + + ); +}; diff --git a/lib/components/Dialog/DialogStatus.stories.ts b/lib/components/Dialog/DialogStatus.stories.ts new file mode 100644 index 0000000..7dd62eb --- /dev/null +++ b/lib/components/Dialog/DialogStatus.stories.ts @@ -0,0 +1,57 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { fn } from '@storybook/test'; + +import { DialogStatus, DialogStatusEnum } from './DialogStatus'; + +const meta = { + title: 'Dialog/DialogStatus', + component: DialogStatus, + tags: ['autodocs'], + parameters: { + layout: 'centered', + }, + argTypes: { + value: { + options: Object.keys(DialogStatusEnum), + }, + }, + args: {}, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Draft: Story = { + args: { + value: 'draft', + label: 'Utkast', + }, +}; + +export const Sent: Story = { + args: { + value: 'sent', + label: 'Sendt', + }, +}; + +export const RequiresAttention: Story = { + args: { + value: 'requires-attention', + label: 'Krever handling', + }, +}; + +export const InProgress: Story = { + args: { + value: 'in-progress', + label: 'Under arbeid', + }, +}; + +export const Completed: Story = { + args: { + value: 'completed', + label: 'Avsluttet', + }, +}; diff --git a/lib/components/Dialog/DialogStatus.tsx b/lib/components/Dialog/DialogStatus.tsx new file mode 100644 index 0000000..09bffac --- /dev/null +++ b/lib/components/Dialog/DialogStatus.tsx @@ -0,0 +1,61 @@ +import { MetaItem, type MetaItemSize, MetaProgress } from '../Meta'; + +export enum DialogStatusEnum { + /** Used to indicate user-initiated dialogs not yet sent. */ + draft = 'DRAFT', + /** Sent by the service owner. In a serial process, this is used after a submission is made. */ + sent = 'SENT', + /** The dialogue is considered new. Typically used for simple messages that do not require any interaction, or as an initial step for dialogues. This is the default. */ + new = 'NEW', + /** The dialogue was completed. This typically means that the dialogue is moved to a GUI archive or similar. */ + completed = 'COMPLETED', + /** Started. In a serial process, this is used to indicate that, for example, a form filling is ongoing. */ + 'in-progress' = 'IN_PROGRESS', + /** Used to indicate that the dialogue is in progress/under work, but is in a state where the user must do something - for example, correct an error, or other conditions that hinder further processing. */ + 'requires-attention' = 'REQUIRES_ATTENTION', +} + +export type DialogStatusValue = keyof typeof DialogStatusEnum; + +export interface DialogStatusProps { + size?: MetaItemSize; + value?: DialogStatusValue; + label?: string; +} + +/** + * Dialog status. + */ + +export const DialogStatus = ({ size = 'xs', value = 'new', label }: DialogStatusProps) => { + switch (value) { + case 'new': + return null; + case 'draft': + return ( + + {label || value} + + ); + case 'requires-attention': + return {label || value}; + case 'in-progress': + return ( + + {label || value} + + ); + case 'completed': + return ( + + {label || value} + + ); + default: + return ( + + {label || value} + + ); + } +}; diff --git a/lib/components/Dialog/DialogTitle.stories.ts b/lib/components/Dialog/DialogTitle.stories.ts new file mode 100644 index 0000000..fd53b6a --- /dev/null +++ b/lib/components/Dialog/DialogTitle.stories.ts @@ -0,0 +1,33 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { fn } from '@storybook/test'; + +import { DialogTitle } from './DialogTitle'; + +const meta = { + title: 'Dialog/DialogTitle', + component: DialogTitle, + tags: ['autodocs'], + parameters: {}, + args: { + children: 'Title', + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; + +export const Seen: Story = { + args: { + variant: 'seen', + }, +}; + +export const Trash: Story = { + args: { + variant: 'trash', + }, +}; diff --git a/lib/components/Dialog/DialogTitle.tsx b/lib/components/Dialog/DialogTitle.tsx new file mode 100644 index 0000000..4672024 --- /dev/null +++ b/lib/components/Dialog/DialogTitle.tsx @@ -0,0 +1,31 @@ +import type { ReactNode } from 'react'; +import { Icon, type IconName } from '../Icon'; +import type { DialogListItemVariant } from './DialogListItemBase'; +import styles from './dialogTitle.module.css'; + +export type DialogTitleSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl'; + +export type DialogTitleProps = { + /** Variant */ + variant: DialogListItemVariant; + /** Size */ + size?: DialogTitleSize; + /** Variant */ + seen?: boolean; + /** Display an icon next to title */ + icon?: IconName; + /** Dialog title */ + children?: ReactNode; +}; + +/** + * Dialog title + */ +export const DialogTitle = ({ size = 'sm', seen = false, variant, icon, children }: DialogTitleProps) => { + return ( +

+ {children} + {icon && } +

+ ); +}; diff --git a/lib/components/Dialog/DialogTouchedBy.stories.tsx b/lib/components/Dialog/DialogTouchedBy.stories.tsx new file mode 100644 index 0000000..120bf3a --- /dev/null +++ b/lib/components/Dialog/DialogTouchedBy.stories.tsx @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { DialogTouchedBy } from './DialogTouchedBy'; + +const meta = { + title: 'Dialog/Atoms/DialogTouchedBy', + component: DialogTouchedBy, + tags: ['autodocs'], + parameters: {}, + args: { + touchedBy: [ + { + name: 'Donald Duck', + }, + { + name: 'Pelle Khan', + }, + { + name: 'Langbein', + }, + ], + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/lib/components/Dialog/DialogTouchedBy.tsx b/lib/components/Dialog/DialogTouchedBy.tsx new file mode 100644 index 0000000..3d5eabc --- /dev/null +++ b/lib/components/Dialog/DialogTouchedBy.tsx @@ -0,0 +1,19 @@ +import { AvatarGroup, type AvatarSize } from '../Avatar'; + +export interface DialogTouchedByActor { + name: string; +} + +export interface DialogTouchedByProps { + size?: AvatarSize; + touchedBy?: DialogTouchedByActor[]; + className?: string; +} + +export const DialogTouchedBy = ({ size = 'sm', touchedBy = [], className }: DialogTouchedByProps) => { + if (!touchedBy?.length) { + return null; + } + + return ; +}; diff --git a/lib/components/Dialog/dialog.module.css b/lib/components/Dialog/dialog.module.css new file mode 100644 index 0000000..0817642 --- /dev/null +++ b/lib/components/Dialog/dialog.module.css @@ -0,0 +1,21 @@ +.dialog { + background-color: var(--theme-background-default); + display: flex; + flex-direction: column; + row-gap: 1.5em; + box-shadow: var(--ds-shadow-xs); +} + +.nav { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem; +} + +.header { + display: flex; + flex-direction: column; + row-gap: 1.5em; + margin-bottom: 1.5rem; +} diff --git a/lib/components/Dialog/dialogAction.module.css b/lib/components/Dialog/dialogAction.module.css new file mode 100644 index 0000000..3c2e0bd --- /dev/null +++ b/lib/components/Dialog/dialogAction.module.css @@ -0,0 +1,26 @@ +.action { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.comboButton { + position: relative; +} + +.dropdown { + margin-top: 0.5rem; + padding: 0 0.5rem; + background-color: var(--neutral-background-default); + border-radius: 2px; + box-shadow: 0 0 1px 0 rgba(0, 0, 0, 0.15), 0 1px 2px 0 rgba(0, 0, 0, 0.12), 0 2px 4px 0 rgba(0, 0, 0, 0.1); + display: none; +} + +.dropdown[aria-expanded="true"] { + display: block; + position: absolute; + min-width: 256px; + left: 0; + z-index: 2; +} diff --git a/lib/components/Dialog/dialogArticleBase.module.css b/lib/components/Dialog/dialogArticleBase.module.css new file mode 100644 index 0000000..7ab349e --- /dev/null +++ b/lib/components/Dialog/dialogArticleBase.module.css @@ -0,0 +1,5 @@ +.article { + display: flex; + flex-direction: column; + padding: 0 1.5rem; +} diff --git a/lib/components/Dialog/dialogBodyBase.module.css b/lib/components/Dialog/dialogBodyBase.module.css new file mode 100644 index 0000000..20b4b99 --- /dev/null +++ b/lib/components/Dialog/dialogBodyBase.module.css @@ -0,0 +1,13 @@ +.border { + padding-left: 1.5rem; + padding-top: 0; + margin-left: 1rem; + row-gap: 1.5rem; + margin-bottom: 1.5rem; +} + +.body { + display: flex; + flex-direction: column; + row-gap: 1.5em; +} diff --git a/lib/components/Dialog/dialogBorder.module.css b/lib/components/Dialog/dialogBorder.module.css new file mode 100644 index 0000000..5cd886c --- /dev/null +++ b/lib/components/Dialog/dialogBorder.module.css @@ -0,0 +1,42 @@ +.border { + border-left: 0.25rem solid; + border-color: var(--theme-surface-active); + /* + padding-left: 1rem; + display: flex; + flex-direction: column; + */ +} + +.border[data-seen="true"] { + border-color: var(--neutral-surface-default); +} + +/* + +.border[data-size="sm"][data-status="draft"] { + border-color: transparent; + padding-left: 0; +} + +.border[data-size="sm"] { + row-gap: 1rem; +} + +.border[data-size="lg"] { + font-size: 18px; + padding-left: 1.5rem; + padding-top: 0; + padding-bottom: 1.5rem; + margin-left: 1rem; + row-gap: 1.5rem; +} + +.border[data-size="lg"][data-status="draft"] { + border: 1px dashed; + border-color: var(--theme-text-subtle); + padding: 1.5rem; + margin-left: 0; +} + +*/ diff --git a/lib/components/Dialog/dialogHeaderBase.module.css b/lib/components/Dialog/dialogHeaderBase.module.css new file mode 100644 index 0000000..f90b9e8 --- /dev/null +++ b/lib/components/Dialog/dialogHeaderBase.module.css @@ -0,0 +1,6 @@ +.header { + display: flex; + flex-direction: column; + row-gap: 1.5em; + margin-bottom: 1.5rem; +} diff --git a/lib/components/Dialog/dialogHeadings.module.css b/lib/components/Dialog/dialogHeadings.module.css new file mode 100644 index 0000000..e23ffc3 --- /dev/null +++ b/lib/components/Dialog/dialogHeadings.module.css @@ -0,0 +1,29 @@ +.headings { + display: flex; + column-gap: 0.5em; + align-items: center; + font-size: 16px; +} + +.headings[data-size="xs"] { + font-size: 14px; +} + +.headings[data-size="sm"] { + font-size: 14px; +} + +.text { + display: inline-flex; + color: var(--neutral-text-subtle); +} + +.sender { + font-weight: 500; + color: var(--neutral-text-default); +} + +.headings[data-size="xs"] .avatar, +.headings[data-size="sm"] .avatar { + display: none; +} diff --git a/lib/components/Dialog/dialogHistory.module.css b/lib/components/Dialog/dialogHistory.module.css new file mode 100644 index 0000000..aae64d1 --- /dev/null +++ b/lib/components/Dialog/dialogHistory.module.css @@ -0,0 +1,12 @@ +.section { + border-color: var(--neutral-surface-active); + display: flex; + flex-direction: column; + margin-bottom: 1.5rem; +} + +.title { + font-size: 1.25rem; + font-weight: 600; + margin: 1em 0; +} diff --git a/lib/components/Dialog/dialogListItem.module.css b/lib/components/Dialog/dialogListItem.module.css new file mode 100644 index 0000000..4399aba --- /dev/null +++ b/lib/components/Dialog/dialogListItem.module.css @@ -0,0 +1,81 @@ +.border { + width: 100%; + display: flex; + flex-direction: column; + text-decoration: none; + row-gap: 1rem; + padding-left: 1rem; +} + +.header { + display: flex; + flex-direction: column; + row-gap: 0.25rem; +} + +.summary { + font-size: 1rem; + line-height: 1.35; + margin: 0; + font-weight: 400; + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + color: var(--neutral-text-subtle); +} + +.footer { + width: 100%; +} + +.touchedBy { + position: absolute; + right: 0; + bottom: 0; + margin: 8px; +} + +/* medium */ + +.border[data-size="md"] { + padding-left: 0.75rem; + row-gap: 0.5rem; +} + +.summary[data-size="md"] { + font-size: 1rem; +} + +.summary[data-size="md"] { + font-size: 1rem; +} + +/* sizes */ + +.link[data-size="xs"], +.link[data-size="sm"] { + padding: 0.5rem 0.75rem; +} + +.summary[data-size="xs"], +.summary[data-size="sm"] { + font-size: 0.875rem; +} + +.header[data-size="xs"], +.header[data-size="sm"] { + row-gap: 0; +} + +.footer[data-size="xs"] { + display: none; +} + +.border[data-size="xs"], +.border[data-size="sm"] { + padding-left: 0.75rem; + row-gap: 0.25rem; +} diff --git a/lib/components/Dialog/dialogListItemBase.module.css b/lib/components/Dialog/dialogListItemBase.module.css new file mode 100644 index 0000000..a95bfd7 --- /dev/null +++ b/lib/components/Dialog/dialogListItemBase.module.css @@ -0,0 +1,28 @@ +.item { + position: relative; + background-color: var(--theme-background-default); + position: relative; + box-shadow: var(--ds-shadow-xs); +} + +.item[aria-selected="true"] { + background-color: var(--theme-background-subtle); + outline: 1px solid; +} + +.link { + display: flex; + padding: 1rem; +} + +.link:hover { + outline: 2px solid; + outline-color: var(--global-base-default); +} + +.select { + position: absolute; + top: 0; + right: 0; + margin: 10px; +} diff --git a/lib/components/Dialog/dialogSectionBase.module.css b/lib/components/Dialog/dialogSectionBase.module.css new file mode 100644 index 0000000..34e4949 --- /dev/null +++ b/lib/components/Dialog/dialogSectionBase.module.css @@ -0,0 +1,11 @@ +.section { + display: flex; + flex-direction: column; + margin-bottom: 1.5rem; +} + +.title { + font-size: 1.25rem; + font-weight: 600; + margin: 1em 0; +} diff --git a/lib/components/Dialog/dialogSelect.module.css b/lib/components/Dialog/dialogSelect.module.css new file mode 100644 index 0000000..376bdb6 --- /dev/null +++ b/lib/components/Dialog/dialogSelect.module.css @@ -0,0 +1,43 @@ +.label { + display: inline-flex; + align-items: center; + justify-content: center; + background-color: transparent; + padding: 9px; + border-radius: 50%; + border: 0; +} + +.label:hover { + background-color: var(--theme-surface-default); +} + +.input { + position: absolute; + opacity: 0; +} + +.checkbox { + /* + border: 2px solid; + border-radius: 2px; + */ + display: flex; + align-items: center; + justify-content: center; +} + +.label > input:checked + .checkbox { + border-color: var(--theme-base-default); + background-color: var(--theme-base-default); + color: var(--theme-background-subtle); +} + +.label > input:checked + .checkbox { + outline: 1px solid red; +} + +.icon { + width: 1.5rem; + height: 1.5rem; +} diff --git a/lib/components/Dialog/dialogTitle.module.css b/lib/components/Dialog/dialogTitle.module.css new file mode 100644 index 0000000..ed2fdec --- /dev/null +++ b/lib/components/Dialog/dialogTitle.module.css @@ -0,0 +1,47 @@ +.title { + font-weight: 600; + margin: 0; + padding-right: 1.25rem; + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; +} + +.title[data-size="xs"] { + font-size: 1rem; +} + +.title[data-size="sm"] { + font-size: 1rem; +} + +.title[data-size="md"] { + font-size: 1.125rem; + line-height: 1.2; +} + +.title[data-size="lg"] { + font-size: 1.25rem; + line-height: 1.2; +} + +.title[data-size="xl"] { + font-size: 1.5rem; + line-height: 1.25; +} + +.title[data-seen="true"] { + font-weight: 400; +} + +.title[data-variant="archive"] { + font-weight: 400; +} + +.title[data-variant="bin"] { + font-weight: 400; + text-decoration: line-through; +} diff --git a/lib/components/Dialog/index.ts b/lib/components/Dialog/index.ts new file mode 100644 index 0000000..09c98a3 --- /dev/null +++ b/lib/components/Dialog/index.ts @@ -0,0 +1,2 @@ +export * from './DialogMetadata'; +export * from './DialogListItem'; diff --git a/lib/components/Header/GlobalMenu.tsx b/lib/components/Header/GlobalMenu.tsx index 53eb99d..5c841a3 100644 --- a/lib/components/Header/GlobalMenu.tsx +++ b/lib/components/Header/GlobalMenu.tsx @@ -116,7 +116,7 @@ export const GlobalMenu = ({ return (
-
+
{selectAccount ? ( <> diff --git a/lib/components/Header/Header.tsx b/lib/components/Header/Header.tsx index 5df542a..37e37ad 100644 --- a/lib/components/Header/Header.tsx +++ b/lib/components/Header/Header.tsx @@ -41,19 +41,19 @@ export const Header = ({ color, search, menu }: HeaderProps) => { return (
- + {menu && ( onToggle('account')} - className={styles?.button} + className={styles.button} /> )} {search && ( diff --git a/lib/components/History/HistoryBorder.tsx b/lib/components/History/HistoryBorder.tsx new file mode 100644 index 0000000..ef8daa7 --- /dev/null +++ b/lib/components/History/HistoryBorder.tsx @@ -0,0 +1,17 @@ +import cx from 'classnames'; +import type { ReactNode } from 'react'; +import styles from './historyBorder.module.css'; + +export interface HistoryBorderProps { + seen?: boolean; + className?: string; + children?: ReactNode; +} + +export const HistoryBorder = ({ seen = true, className, children }: HistoryBorderProps) => { + return ( +
+ {children} +
+ ); +}; diff --git a/lib/components/History/HistoryItem.stories.ts b/lib/components/History/HistoryItem.stories.ts new file mode 100644 index 0000000..b420738 --- /dev/null +++ b/lib/components/History/HistoryItem.stories.ts @@ -0,0 +1,47 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { fn } from '@storybook/test'; + +import { HistoryItem } from './HistoryItem'; + +const meta = { + title: 'History/HistoryItem', + component: HistoryItem, + tags: ['autodocs'], + parameters: {}, + args: { + createdAt: '2004-09-22 13:34', + createdBy: { + name: 'Eirik Horneland', + }, + summary: 'Brann slo Glimt 4-1 på Stadion.', + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; + +export const Attachments: Story = { + args: { + attachments: [ + { + label: '1-0 Castro.pdf', + }, + { + label: '2-0 Kornvig.pdf', + }, + { + label: '3-0 Kartum.pdf', + }, + { + label: '3-1 Zinkernagel.pdf', + }, + { + label: '4-1 Castro.pdf', + }, + ], + }, +}; diff --git a/lib/components/History/HistoryItem.tsx b/lib/components/History/HistoryItem.tsx new file mode 100644 index 0000000..21bce7a --- /dev/null +++ b/lib/components/History/HistoryItem.tsx @@ -0,0 +1,64 @@ +import { type AttachmentLinkProps, AttachmentList } from '../Attachment'; +import { Avatar } from '../Avatar/'; +import { MetaBase, MetaItem, MetaTimestamp } from '../Meta/'; +import { Typography } from '../Typography'; +import { HistoryBorder } from './HistoryBorder'; +import styles from './historyItem.module.css'; + +export interface CreatedByProps { + type?: 'company' | 'person'; + name?: string; + imageUrl?: string; +} + +export interface HistoryItemProps { + createdBy?: CreatedByProps; + createdAt?: string; + summary?: string; + attachments?: AttachmentLinkProps[]; +} + +export const HistoryItem = ({ + createdBy = { + type: 'person', + }, + createdAt, + summary, + attachments, +}: HistoryItemProps) => { + const title = attachments?.length + ' vedlegg'; + + return ( +
+
+ +
+ +
+ + + {createdBy?.name + ', '} + {createdAt} + + + +

{summary}

+ {attachments ? ( +
+ {title} + +
+ ) : ( + '' + )} +
+
+
+
+ ); +}; diff --git a/lib/components/History/HistoryList.stories.ts b/lib/components/History/HistoryList.stories.ts new file mode 100644 index 0000000..4f39d2f --- /dev/null +++ b/lib/components/History/HistoryList.stories.ts @@ -0,0 +1,58 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { fn } from '@storybook/test'; + +import { HistoryList } from './HistoryList'; + +const meta = { + title: 'History/HistoryList', + component: HistoryList, + tags: ['autodocs'], + parameters: {}, + args: { + items: [ + { + createdAt: '2004-09-22 13:34', + createdBy: { + name: 'Eirik Horneland', + }, + summary: 'Brann slo Glimt 4-1 på Stadion.', + attachments: [ + { + label: '1-0 Castro.pdf', + }, + { + label: '2-0 Kornvig.pdf', + }, + { + label: '3-0 Kartum.pdf', + }, + { + label: '3-1 Zinkernagel.pdf', + }, + { + label: '4-1 Castro.pdf', + }, + ], + }, + { + createdAt: '2004-09-09 13:34', + createdBy: { + name: 'Eirik Horneland', + }, + summary: 'Brann vant 1-0 i Haugesund.', + attachments: [ + { + label: 'Målet til Heggebø.pdf', + }, + ], + }, + ], + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: {}, +}; diff --git a/lib/components/History/HistoryList.tsx b/lib/components/History/HistoryList.tsx new file mode 100644 index 0000000..068ddcd --- /dev/null +++ b/lib/components/History/HistoryList.tsx @@ -0,0 +1,26 @@ +import cx from 'classnames'; +import { HistoryItem, type HistoryItemProps } from './HistoryItem'; +import styles from './historyList.module.css'; + +export interface HistoryProps { + items: HistoryItemProps[]; + className?: string; +} + +export const HistoryList = ({ items, className }: HistoryProps) => { + if (!items.length) { + return null; + } + + return ( +
    + {items.map((item, index) => { + return ( +
  • + +
  • + ); + })} +
+ ); +}; diff --git a/lib/components/History/historyBorder.module.css b/lib/components/History/historyBorder.module.css new file mode 100644 index 0000000..c621b68 --- /dev/null +++ b/lib/components/History/historyBorder.module.css @@ -0,0 +1,8 @@ +.border { + border-left: 0.25rem solid; + border-color: var(--theme-surface-active); +} + +.border[data-seen="true"] { + border-color: var(--neutral-surface-default); +} diff --git a/lib/components/History/historyItem.module.css b/lib/components/History/historyItem.module.css new file mode 100644 index 0000000..bcdc6f9 --- /dev/null +++ b/lib/components/History/historyItem.module.css @@ -0,0 +1,19 @@ +.item { + display: flex; + flex-direction: column; +} + +.header { + margin: 0.375em; +} + +.border { + padding-left: 1.5rem; + padding-top: 0; + margin-left: 1rem; + padding-bottom: 1.5rem; +} + +.body { + margin-top: -1.875rem; +} diff --git a/lib/components/History/historyList.module.css b/lib/components/History/historyList.module.css new file mode 100644 index 0000000..d77c055 --- /dev/null +++ b/lib/components/History/historyList.module.css @@ -0,0 +1,12 @@ +.list { + list-style: none; + display: flex; + flex-direction: column; + row-gap: 1rem; + padding: 0; + margin: 0; +} + +.item { + margin: 0; +} diff --git a/lib/components/History/index.ts b/lib/components/History/index.ts new file mode 100644 index 0000000..05ccb90 --- /dev/null +++ b/lib/components/History/index.ts @@ -0,0 +1,2 @@ +export * from './HistoryList'; +export * from './HistoryItem'; diff --git a/lib/components/List/ListBase.tsx b/lib/components/List/ListBase.tsx index c54fe61..e5c7355 100644 --- a/lib/components/List/ListBase.tsx +++ b/lib/components/List/ListBase.tsx @@ -12,7 +12,7 @@ export interface ListBaseProps { export const ListBase = ({ size = 'md', theme, children }: ListBaseProps) => { return ( -
+
{children}
); diff --git a/lib/components/List/ListItemBase.tsx b/lib/components/List/ListItemBase.tsx index de8ebb1..c1dda87 100644 --- a/lib/components/List/ListItemBase.tsx +++ b/lib/components/List/ListItemBase.tsx @@ -42,7 +42,7 @@ export const ListItemBase = ({ return ( { return ( - + {children ? ( children ) : ( <> - + {title} - + {description} diff --git a/lib/components/Menu/Menu.tsx b/lib/components/Menu/Menu.tsx index 092c75e..26e62e3 100644 --- a/lib/components/Menu/Menu.tsx +++ b/lib/components/Menu/Menu.tsx @@ -20,11 +20,11 @@ interface MenuItemsGroupProps { export type MenuGroups = Record; export interface MenuProps { + items: MenuItemProps[]; theme?: MenuTheme; defaultItemColor?: MenuItemColor; defaultItemSize?: MenuItemSize; groups?: MenuGroups; - items?: MenuItemProps[]; search?: MenuSearchProps; } diff --git a/lib/components/Menu/MenuItem.tsx b/lib/components/Menu/MenuItem.tsx index 76f76a2..1a2f832 100644 --- a/lib/components/Menu/MenuItem.tsx +++ b/lib/components/Menu/MenuItem.tsx @@ -1,4 +1,4 @@ -import type { ElementType, ReactNode } from 'react'; +import type { ElementType, MouseEventHandler, ReactNode } from 'react'; import type { AvatarGroupProps, AvatarProps } from '../Avatar'; import type { BadgeProps } from '../Badge'; import type { IconName } from '../Icon'; @@ -12,7 +12,7 @@ export interface MenuItemProps { as?: ElementType; color?: MenuItemColor; href?: string; - onClick?: () => void; + onClick?: MouseEventHandler; hidden?: boolean; collapsible?: boolean; expanded?: boolean; diff --git a/lib/components/Menu/MenuItemBase.tsx b/lib/components/Menu/MenuItemBase.tsx index e82a4ee..ffa1283 100644 --- a/lib/components/Menu/MenuItemBase.tsx +++ b/lib/components/Menu/MenuItemBase.tsx @@ -47,7 +47,7 @@ export const MenuItemBase = ({ aria-expanded={expanded} aria-disabled={disabled} aria-selected={selected} - className={cx(styles?.item, className)} + className={cx(styles.item, className)} {...rest} >
diff --git a/lib/components/Menu/MenuItemLabel.tsx b/lib/components/Menu/MenuItemLabel.tsx index 3c0ee58..25b0dab 100644 --- a/lib/components/Menu/MenuItemLabel.tsx +++ b/lib/components/Menu/MenuItemLabel.tsx @@ -12,15 +12,15 @@ export interface MenuItemLabelProps { export const MenuItemLabel = ({ size = 'sm', label, title, description, children }: MenuItemLabelProps) => { return ( - + {children ? ( children ) : ( <> - + {title || label} - + {description} diff --git a/lib/components/Menu/MenuItemMedia.tsx b/lib/components/Menu/MenuItemMedia.tsx index 4da99d9..b4f7266 100644 --- a/lib/components/Menu/MenuItemMedia.tsx +++ b/lib/components/Menu/MenuItemMedia.tsx @@ -33,7 +33,7 @@ export const MenuItemMedia = ({ size = 'sm', color, icon, avatar, avatarGroup, c return (
- {icon ? : ''} + {icon && } {avatar && } {avatarGroup && } {children} diff --git a/lib/components/Menu/MenuOption.tsx b/lib/components/Menu/MenuOption.tsx index 6d55489..4774032 100644 --- a/lib/components/Menu/MenuOption.tsx +++ b/lib/components/Menu/MenuOption.tsx @@ -33,8 +33,8 @@ export const MenuOption = ({ onChange, }: MenuOptionProps) => { return ( - - + + {type === 'checkbox' && } {type === 'radio' && } diff --git a/lib/components/Meta/MetaItem.tsx b/lib/components/Meta/MetaItem.tsx index 2f8cb2f..3665da8 100644 --- a/lib/components/Meta/MetaItem.tsx +++ b/lib/components/Meta/MetaItem.tsx @@ -1,10 +1,12 @@ -import type { ReactNode } from 'react'; +import type { ElementType, ReactNode } from 'react'; import type { IconName } from '../Icon'; import { MetaItemBase, type MetaItemSize, type MetaItemVariant } from './MetaItemBase'; import { MetaItemLabel } from './MetaItemLabel'; import { MetaItemMedia } from './MetaItemMedia'; export interface MetaItemProps { + /** Render as element */ + as?: ElementType; /** Meta size */ size?: MetaItemSize; /** Variant */ @@ -13,12 +15,14 @@ export interface MetaItemProps { icon?: IconName; /** Label */ children?: ReactNode; + /** classname */ + className?: string; } export const MetaItem = ({ size = 'xs', variant = 'text', icon, children, ...rest }: MetaItemProps) => { return ( - {icon ? : ''} + {icon && } {children} diff --git a/lib/components/Meta/MetaItemBase.tsx b/lib/components/Meta/MetaItemBase.tsx index 4270c6c..381471e 100644 --- a/lib/components/Meta/MetaItemBase.tsx +++ b/lib/components/Meta/MetaItemBase.tsx @@ -37,7 +37,7 @@ export const MetaItemBase = ({ data-variant={variant} data-progress={progress} dateTime={datetime} - className={cx(styles?.item, className)} + className={cx(styles.item, className)} {...rest} > {children} diff --git a/lib/components/Meta/MetaItemMedia.tsx b/lib/components/Meta/MetaItemMedia.tsx index 43598a2..7aed36f 100644 --- a/lib/components/Meta/MetaItemMedia.tsx +++ b/lib/components/Meta/MetaItemMedia.tsx @@ -9,14 +9,14 @@ interface MetaItemMediaProps { } export const MetaItemMedia = ({ size = 'sm', icon, progress }: MetaItemMediaProps) => { - if (!icon && !progress) { + if (!icon && typeof progress !== 'number') { return false; } return ( - {icon ? : ''} - {progress ? : ''} + {icon && } + {progress && } ); }; diff --git a/lib/components/Meta/MetaList.tsx b/lib/components/Meta/MetaList.tsx index e2124cc..05a1325 100644 --- a/lib/components/Meta/MetaList.tsx +++ b/lib/components/Meta/MetaList.tsx @@ -8,13 +8,13 @@ import styles from './metaList.module.css'; export type MetaListItemType = 'default' | 'progress' | 'timestamp'; export interface MetaListItemProps extends MetaItemBaseProps { - type?: MetaListItemType; label: string; + type?: MetaListItemType; } export interface MetaListProps { + items: MetaListItemProps[]; size?: MetaItemSize; - items?: MetaListItemProps[]; } export const MetaListItem = ({ type = 'default', label, ...rest }: MetaListItemProps) => { @@ -31,9 +31,9 @@ export const MetaListItem = ({ type = 'default', label, ...rest }: MetaListItemP export const MetaList = ({ size = 'xs', items = [] }: MetaListProps) => { return ( -
    - {items?.map((item, index) => ( -
  • +
      + {items.map((item, index) => ( +
    • ))} diff --git a/lib/components/Meta/MetaTimestamp.tsx b/lib/components/Meta/MetaTimestamp.tsx index e02044e..0c9aceb 100644 --- a/lib/components/Meta/MetaTimestamp.tsx +++ b/lib/components/Meta/MetaTimestamp.tsx @@ -1,5 +1,4 @@ import type { ReactNode } from 'react'; - import type { IconName } from '../Icon'; import { MetaItemBase, type MetaItemSize, type MetaItemVariant } from './MetaItemBase'; import { MetaItemLabel } from './MetaItemLabel'; @@ -21,7 +20,7 @@ export interface MetaTimestampProps { export const MetaTimestamp = ({ size = 'xs', variant = 'text', datetime, icon, children }: MetaTimestampProps) => { return ( - {icon ? : ''} + {icon && } {children} diff --git a/lib/components/Meta/metaItem.module.css b/lib/components/Meta/metaItem.module.css index 3965e2d..ec39229 100644 --- a/lib/components/Meta/metaItem.module.css +++ b/lib/components/Meta/metaItem.module.css @@ -14,7 +14,8 @@ a.item:hover { color: var(--theme-base-hover); } -.item { +.label { + font-weight: normal; line-height: 1; } diff --git a/lib/components/Toolbar/ToolbarAdd.tsx b/lib/components/Toolbar/ToolbarAdd.tsx index 4e70c5d..ddf731b 100644 --- a/lib/components/Toolbar/ToolbarAdd.tsx +++ b/lib/components/Toolbar/ToolbarAdd.tsx @@ -17,7 +17,7 @@ export const ToolbarAdd = ({ expanded = false, onToggle, label = 'Legg til', ite {label} -
      +
      diff --git a/lib/components/Typography/Typography.tsx b/lib/components/Typography/Typography.tsx new file mode 100644 index 0000000..61b5fc4 --- /dev/null +++ b/lib/components/Typography/Typography.tsx @@ -0,0 +1,21 @@ +import cx from 'classnames'; +import type { ReactNode } from 'react'; +import type { LayoutTheme } from '../Layout'; +import styles from './typography.module.css'; + +export type TypographySize = 'md' | 'lg' | 'xl'; + +export interface TypographyProps { + size?: TypographySize; + theme?: LayoutTheme; + className?: string; + children?: ReactNode; +} + +export const Typography = ({ size = 'md', theme, className, children }: TypographyProps) => { + return ( +
      + {children} +
      + ); +}; diff --git a/lib/components/Typography/index.ts b/lib/components/Typography/index.ts new file mode 100644 index 0000000..d64ebba --- /dev/null +++ b/lib/components/Typography/index.ts @@ -0,0 +1 @@ +export * from './Typography'; diff --git a/lib/components/Typography/typography.module.css b/lib/components/Typography/typography.module.css new file mode 100644 index 0000000..da03c48 --- /dev/null +++ b/lib/components/Typography/typography.module.css @@ -0,0 +1,56 @@ +.typography { + width: 100%; + max-width: 45rem; + line-height: 1.5; +} + +.typography[data-size="lg"] { + font-size: 1.125rem; +} + +.typography > *:first-child { + margin-top: 0; +} + +.typography > *:last-child { + margin-bottom: 0; +} + +.typography h2 { + font-size: 1.25rem; + font-weight: 600; + margin-top: 1.5em; + margin-bottom: 0; +} + +.typography h3 { + font-size: 1em; + font-weight: 600; + margin: 1em 0; +} + +.typography h4 { + font-size: 1em; + font-weight: 600; + margin: 1em 0 0; +} + +.typography p { + font-size: 1em; + margin: 1em 0; +} + +.typography strong { + font-weight: 600; +} + +.typography a { + color: var(--link-base-default); +} + +.typography ul:not([class]), +.typography ol:not([class]) { + list-style-position: inside; + padding-left: 1rem; + margin: 1em 0; +}