Skip to content

Commit dcb1e5c

Browse files
Feature/alertbox (#734)
* Added new component (basic styling + stories) * Added new background color * Set correct icon based on `variant` prop * Added stories for other variants * Changed variable name * Center grid items vertically * Changed template texts * Removed redundant tailwind class * Fixed prop defaults + added new stories * Support controlled open/closed state * Warn if passing onClose to non-dismissable alert * Fixed grid layout * Added required role prop * Renaming, style fix + WIP expandable * Fixed Heading type * Added auto generated aria-label for close button * Removed everything related to expand feature * Added prop types for sub components * Reverted color added * Added new changeset * Changed AlertboxFooter role * Removed custom aria-label prop * Fixed element order and click area on close btn. * Changed text-block components to <p>-tags * Adjusted spacing according to design * Moved defaultVariants and adjusted focus-ring * Fixed oversized looking close button * Changed <p>-tags to <div>-tags * Added custom colors for alertboxes * Fixed comment typo * Removed incorrect comment * Pass on className prop for sub-components * Pass on className prop for AlertboxFooter * Fixed font-weight on footer text * Moved comment * More semantic date * Removed redundant classes * Removed more redundant classes * Refined story semantics and content * Removed TODO * Simplified type casting * Fixed comment * add defaultProp for variant so the correct variant is selected in the storybook controls * Only output errors and warnings in dev-mode * Changed name form AlertboxBody to AlertboxContent * Changed role union type * Generic components for Content, Heading & Footer * Optimzed rendering (early returns) * Omit role attribute when set to 'none' * Added comments --------- Co-authored-by: Alexander Bjerkan <alexander.bjerkan@obos.no>
1 parent 6f28bba commit dcb1e5c

File tree

6 files changed

+286
-0
lines changed

6 files changed

+286
-0
lines changed

.changeset/polite-keys-remain.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@obosbbl/grunnmuren-react": minor
3+
---
4+
5+
Added new component `<Alertbox/>` for dismissable and non-dismissable alerts.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import type { Meta, StoryObj } from '@storybook/react';
2+
import { AlertboxProps, Alertbox } from '.';
3+
import { useState } from 'react';
4+
import { Button } from '..';
5+
import { Content, Heading, Footer } from '../content';
6+
7+
const meta: Meta<typeof Alertbox> = {
8+
title: 'Alertbox',
9+
component: Alertbox,
10+
};
11+
12+
export default meta;
13+
14+
type Story = StoryObj<typeof Alertbox>;
15+
16+
const Template = (args: AlertboxProps) => (
17+
<Alertbox {...args}>
18+
<Heading level={2}>Informativ tittel</Heading>
19+
<Content>
20+
<p>
21+
Bruk dette tekstfeltet til å beskrive hva varslingen handler om. Du kan
22+
bruke så mange linjer du har behov for, men prøv likevel å være kort og
23+
konsis.
24+
</p>
25+
</Content>
26+
<Footer>
27+
<p>
28+
Sist oppdatert: <time dateTime="2024-01-20">20.01.2024</time>
29+
</p>
30+
</Footer>
31+
</Alertbox>
32+
);
33+
34+
const SmallTemplate = (args: AlertboxProps) => (
35+
<Alertbox {...args}>
36+
<Content>Bruk dette tekstfeltet til å skrive en kort varsling</Content>
37+
</Alertbox>
38+
);
39+
40+
const ControlledTemplate = (args: AlertboxProps) => {
41+
const [isVisible, setIsVisible] = useState(false);
42+
43+
return (
44+
<>
45+
<Button
46+
onClick={() => setIsVisible((prevState) => !prevState)}
47+
className="mb-4"
48+
>
49+
{`${isVisible ? 'Skjul' : 'Vis'} alert`}
50+
</Button>
51+
<Template
52+
{...args}
53+
isVisible={isVisible}
54+
onClose={() => setIsVisible(false)}
55+
/>
56+
</>
57+
);
58+
};
59+
60+
const defaultProps = { role: 'alert', variant: 'info' } as const;
61+
62+
export const DefaultAlert: Story = {
63+
render: Template,
64+
args: defaultProps,
65+
};
66+
67+
export const SmallAlert: Story = {
68+
render: SmallTemplate,
69+
args: defaultProps,
70+
};
71+
72+
export const DismissableAlert: Story = {
73+
render: Template,
74+
args: { ...defaultProps, isDismissable: true },
75+
};
76+
77+
export const SmallDismissableAlert: Story = {
78+
render: SmallTemplate,
79+
args: { ...defaultProps, isDismissable: true },
80+
};
81+
82+
export const SuccessAlert: Story = {
83+
render: Template,
84+
args: { ...defaultProps, variant: 'success' },
85+
};
86+
87+
export const WarningAlert: Story = {
88+
render: Template,
89+
args: { ...defaultProps, variant: 'warning' },
90+
};
91+
92+
export const DangerAlert: Story = {
93+
render: Template,
94+
args: { ...defaultProps, variant: 'danger' },
95+
};
96+
97+
export const ControlledAlert: Story = {
98+
render: ControlledTemplate,
99+
args: { ...defaultProps, variant: 'danger', isDismissable: true },
100+
};
+146
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { Children } from 'react';
2+
import { cva, type VariantProps } from 'cva';
3+
import { useLocale, Button } from 'react-aria-components';
4+
import {
5+
Close,
6+
InfoCircle,
7+
CheckCircle,
8+
Warning,
9+
CloseCircle,
10+
} from '@obosbbl/grunnmuren-icons-react';
11+
import { useState } from 'react';
12+
13+
// TODO: expand/collapse
14+
// TODO: add new icons
15+
16+
const iconMap = {
17+
info: InfoCircle,
18+
success: CheckCircle,
19+
warning: Warning,
20+
danger: CloseCircle,
21+
};
22+
23+
const alertVariants = cva({
24+
base: [
25+
'grid grid-cols-[auto_1fr_auto] items-center gap-x-2 gap-y-4 rounded-md border-2 px-3 py-2',
26+
// Heading styles:
27+
'[&_[data-slot="heading"]]:text-base [&_[data-slot="heading"]]:font-medium [&_[data-slot="heading"]]:leading-7',
28+
// Content styles:
29+
'[&:has([data-slot="heading"])_[data-slot="content"]]:col-span-full [&_[data-slot="content"]]:text-sm [&_[data-slot="content"]]:leading-6',
30+
// Footer styles:
31+
'[&_[data-slot="footer"]]:col-span-full [&_[data-slot="footer"]]:-mt-[6px] [&_[data-slot="footer"]]:text-xs [&_[data-slot="footer"]]:font-light [&_[data-slot="footer"]]:leading-6',
32+
],
33+
variants: {
34+
/**
35+
* The variant of the alert
36+
* @default info
37+
*/
38+
variant: {
39+
info: 'border-[#1A7FA7] bg-sky-light',
40+
success: 'border-[#0F9B6E] bg-mint-light',
41+
warning: 'border-[#C57C13] bg-[#FFF2DE]',
42+
danger: 'border-[#C0385D] bg-red-light',
43+
},
44+
},
45+
defaultVariants: {
46+
variant: 'info',
47+
},
48+
});
49+
50+
type Props = VariantProps<typeof alertVariants> & {
51+
children: React.ReactNode;
52+
/**
53+
* The ARIA role for the alertbox.
54+
*/
55+
role: 'alert' | 'status' | 'none';
56+
/**
57+
* Controls if the alert can be dismissed with a close button.
58+
* @default true
59+
*/
60+
isDismissable?: boolean;
61+
/** Additional CSS className for the element. */
62+
className?: string;
63+
/**
64+
* Controls if the alert is rendered or not.
65+
* This is used to control the open/closed state of the component; make the component "controlled".
66+
*/
67+
isVisible?: boolean;
68+
/**
69+
* Callback that should be triggered when a dismissable alert is closed.
70+
* This is used to control the open/closed state of the component; make the component "controlled".
71+
*/
72+
onClose?: () => void;
73+
};
74+
75+
const Alertbox = ({
76+
children,
77+
role,
78+
className,
79+
variant = 'info',
80+
isDismissable,
81+
isVisible: isControlledVisible,
82+
onClose,
83+
}: Props) => {
84+
const Icon = iconMap[variant];
85+
86+
// Set a default aria-label for the close button and handle translations based on the current locale
87+
const { locale } = useLocale();
88+
let closeLabel = 'Lukk';
89+
if (locale === 'sv') closeLabel = 'Stäng';
90+
else if (locale === 'en') closeLabel = 'Close';
91+
92+
const [isUncontrolledVisible, setIsUncontrolledVisible] = useState(true);
93+
const isVisible =
94+
isControlledVisible !== undefined
95+
? isControlledVisible
96+
: isUncontrolledVisible;
97+
98+
if (!isVisible) return;
99+
100+
const close = () => {
101+
setIsUncontrolledVisible(false);
102+
if (onClose) onClose();
103+
};
104+
105+
const isInDevMode = process.env.NODE_ENV !== 'production';
106+
107+
if (isInDevMode && onClose && !isDismissable) {
108+
console.warn(
109+
'Passing an `onClose` callback without setting the `isDismissable` prop to `true` will not have any effect.',
110+
);
111+
}
112+
113+
if (isInDevMode && !children) {
114+
console.error('`No children was passed to the <AlertBox/>` component.');
115+
return;
116+
}
117+
118+
const [firstChild, ...restChildren] = Children.toArray(children);
119+
120+
return (
121+
<div
122+
className={alertVariants({
123+
className,
124+
variant,
125+
})}
126+
// The role prop is required to force consumers to consider and choose the appropriate alertbox role.
127+
// role="none" will not have any effect on a div, so it can be omitted.
128+
role={role === 'none' ? undefined : role}
129+
>
130+
<Icon />
131+
{firstChild}
132+
{isDismissable && (
133+
<Button
134+
className="-m-2 grid h-11 w-11 place-items-center outline-transparent transition-[outline] duration-200 focus:-outline-offset-8 focus:outline-black"
135+
onPress={close}
136+
aria-label={closeLabel}
137+
>
138+
<Close />
139+
</Button>
140+
)}
141+
{restChildren}
142+
</div>
143+
);
144+
};
145+
146+
export { type Props as AlertboxProps, Alertbox };

packages/react/src/alertbox/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './Alertbox';
+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { HTMLProps } from 'react';
2+
3+
type HeadingProps = HTMLProps<HTMLHeadingElement> & {
4+
children: React.ReactNode;
5+
/** The level of the heading */
6+
level: 1 | 2 | 3 | 4 | 5 | 6;
7+
};
8+
9+
const Heading = ({ level, ...restProps }: HeadingProps) => {
10+
const Heading = `h${level}` as const;
11+
return <Heading {...restProps} data-slot="heading" />;
12+
};
13+
14+
type ContentProps = HTMLProps<HTMLDivElement> & {
15+
children: React.ReactNode;
16+
};
17+
18+
const Content = (props: ContentProps) => <div {...props} data-slot="content" />;
19+
20+
type FooterProps = HTMLProps<HTMLDivElement> & {
21+
children: React.ReactNode;
22+
};
23+
24+
const Footer = (props: FooterProps) => <div {...props} data-slot="footer" />;
25+
26+
export {
27+
type HeadingProps,
28+
Heading,
29+
type ContentProps,
30+
Content,
31+
type FooterProps,
32+
Footer,
33+
};

packages/react/src/content/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './Content';

0 commit comments

Comments
 (0)