diff --git a/packages/web/src/scss/components/Grid/_tools.scss b/packages/web/src/scss/components/Grid/_tools.scss
index f60a318ffa..f8c7516020 100644
--- a/packages/web/src/scss/components/Grid/_tools.scss
+++ b/packages/web/src/scss/components/Grid/_tools.scss
@@ -4,25 +4,6 @@
@use '@tokens' as tokens;
@use '../../tools/breakpoint';
-// Function to get the breakpoint name as a infix or suffix
-// Example: -get-breakpoint-infix('tablet', 768px) will return 'tablet--'
-// Example: -get-breakpoint-infix('mobile', 0) will return ''
-// Parameters are:
-// * $name: the breakpoint name
-// * $value: the breakpoint value
-// * $infix: whether to return the suffix or infix
-@function -get-breakpoint-name($name, $value, $infix: true) {
- @if $value > 0 {
- @if $infix {
- @return unquote($name + '--');
- }
-
- @return unquote('-' + $name);
- }
-
- @return '';
-}
-
// Generates grid-gap values for each breakpoint
// Parameters are:
// * $gutters: the gutters map
@@ -46,7 +27,7 @@
@each $breakpoint-name, $breakpoint-value in $breakpoints {
@include breakpoint.up($breakpoint-value) {
@each $column in $column-count {
- .Grid--#{-get-breakpoint-name($breakpoint-name, $breakpoint-value)}cols-#{$column} {
+ .Grid--#{breakpoint.get-modifier('infix', $breakpoint-name, $breakpoint-value)}cols-#{$column} {
grid-template-columns: repeat(#{$column}, 1fr);
}
}
@@ -63,7 +44,7 @@
@each $breakpoint-name, $breakpoint-value in $breakpoints {
@include breakpoint.up($breakpoint-value) {
@each $option in $grid-span-variants {
- .Grid__span--#{-get-breakpoint-name($breakpoint-name, $breakpoint-value)}over-#{$option} {
+ .Grid__span--#{breakpoint.get-modifier('infix', $breakpoint-name, $breakpoint-value)}over-#{$option} {
$start: 1 + math.div(($grid-columns - $option), 2);
grid-column: $start / span $option;
@@ -83,7 +64,7 @@
$row-end: 'initial';
@each $breakpoint-name, $breakpoint-value in $breakpoints {
- $suffix: -get-breakpoint-name($breakpoint-name, $breakpoint-value, false);
+ $suffix: breakpoint.get-modifier('suffix', $breakpoint-name, $breakpoint-value);
@include breakpoint.up($breakpoint-value) {
$column-start: var(--grid-item-column-start#{$suffix}, $column-start);
@@ -102,7 +83,7 @@
:where(.GridItem .GridItem) {
@each $breakpoint-name, $breakpoint-value in $breakpoints {
- $suffix: -get-breakpoint-name($breakpoint-name, $breakpoint-value, false);
+ $suffix: breakpoint.get-modifier('suffix', $breakpoint-name, $breakpoint-value);
--grid-item-column-start#{$suffix}: initial;
--grid-item-column-end#{$suffix}: initial;
diff --git a/packages/web/src/scss/components/Toast/README.md b/packages/web/src/scss/components/Toast/README.md
new file mode 100644
index 0000000000..5d4ce9aaa8
--- /dev/null
+++ b/packages/web/src/scss/components/Toast/README.md
@@ -0,0 +1,259 @@
+# Toast
+
+Toast displays a brief, temporary notification that appears at a prescribed location of an application window.
+
+Toast is a composition of a few subcomponents:
+
+- [Toast](#toast)
+ - [ToastBar](#toastbar)
+
+## Toast
+
+The Toast component is a container responsible for positioning the [ToastBar](#toastbar) component. It is capable of
+handling even multiple toast messages at once, stacking them in a [queue](#toast-queue).
+
+```html
+
+
+
+
+
+```
+
+### Accessibility
+
+The wrapping Toast container has the [`role="log"`][mdn-role-log] attribute set (which results in an implicit
+[`aria-live`][mdn-aria-live] value of `polite`). Assistive technologies then announce any **dynamic changes** inside the
+container as they happen. In order for this to work, the Toast component **must be present in the DOM** on the initial
+page load, even when empty.
+
+👉 Unless you are absolutely sure that your toast messages are critical to interrupt the user, you should not change the
+(implicit) `polite` value of the [`aria-live`][mdn-aria-live] attribute. When set to `assertive`, assistive technologies
+immediately notify the user, potentially clearing the speech queue of previous updates.
+
+### Alignment
+
+The Toast component is positioned at the bottom of the screen by default. It is also fixed to the bottom of the screen,
+so it will always be visible, even when the user scrolls. Available alignment options are derived from the
+[AlignmentX and AlignmentY][dictionary-alignment] dictionaries and are as follows:
+
+- `top` `left`,
+- `top` `center`,
+- `top` `right`,
+- `bottom` `left`,
+- `bottom` `center` (default),
+- `bottom` `right`.
+
+Use the `Toast--` and `Toast--` modifiers to change the alignment of the Toast component:
+
+| AlignmentX/Y | left | center | right |
+| ------------ | --------------------------- | ----------------------------- | ---------------------------- |
+| top | `Toast--top Toast--left` | `Toast--top Toast--center` | `Toast--top Toast--right` |
+| bottom | `Toast--bottom Toast--left` | `Toast--bottom Toast--center` | `Toast--bottom Toast--right` |
+
+ℹ️ The `center` vertical alignment is not supported, as it would not make sense for a toast notification to be in the
+middle of the screen.
+
+Example:
+
+```html
+
+
+
+
+
+```
+
+### Responsive Alignment
+
+The Toast container can be aligned differently on different screen sizes. Use the `Toast----`
+modifiers to change the alignment of the Toast component starting on a specific screen size, e.g. `Toast--tablet--top`,
+`Tablet--desktop--left`, etc. (leave the breakpoint empty for alignment on all screen sizes, including mobile screens).
+
+Example:
+
+```html
+
+
+
+
+
+```
+
+### Mobile Screens
+
+Positioning becomes trickier on mobile screens due to the presence of notches, rounded corners, and the virtual
+keyboard. The Toast component tries to find the best position to be visible using the following detection mechanisms:
+
+1. On devices with rounded displays and/or notches (e.g. iPhone X and newer), the Toast component is pushed inwards to
+ avoid the rounded corners. The `viewport-fit="cover"` meta tag is required for this feature to work:
+
+ ```html
+
+ ```
+
+2. Android Chrome only: When the vertical alignment is set to `bottom` and the virtual keyboard is open, the Toast
+ component is pushed upwards to avoid being covered by the keyboard. This feature requires the following JavaScript
+ snippet and is currently supported only in Chrome 94 on Android and later.
+
+ ```js
+ // Enable CSS to detect the presence of virtual keyboard:
+ if ('virtualKeyboard' in navigator) {
+ navigator.virtualKeyboard.overlaysContent = true;
+ }
+ ```
+
+### Toast Queue
+
+When multiple ToastBar components are present, they stack up in a queue, separated by a gap. The ToastBar components are
+sorted from top to bottom for the `top` vertical alignment, and from bottom to top for the `bottom` vertical alignment.
+
+#### Toast Queue Limitations
+
+While the Toast queue becomes scrollable when it does not fit the screen, we recommend displaying only a few toasts at
+once for several reasons:
+
+⚠️ **We strongly discourage from displaying too many toasts at once as it may cause the page to be unusable,
+especially on mobile screens. As of now, there is no automatic stacking of the toast queue items. It is the
+responsibility of the developer to ensure that the Toast queue does not overflow the screen.**
+
+⚠️ Please note that scrolling is only available on pointer-equipped devices (mouse, trackpad). Furthermore, scrolling is
+only possible when the cursor is placed over the toast message boxes. This way the page content behind the toast
+messages can remain accessible.
+
+👉 Please note that the initial scroll position is always at the **top** of the queue.
+
+## ToastBar
+
+The ToastBar component is the actual toast notification. It is a simple container with a message and a few optional
+elements.
+
+Minimum example:
+
+```html
+
+
+
Message only
+
+
+```
+
+### Optional Icon
+
+An icon can be added to the ToastBar component:
+
+```html
+
+
+
+
Message with icon
+
+
+```
+
+### Action Link
+
+An action link can be added to the ToastBar component:
+
+```html
+
+```
+
+👉 **Do not put any important actions** like "Undo" in the ToastBar component (unless there are other means to perform
+said action), as it is very hard (if not impossible) to reach for users with assistive technologies. Read more about
+[Toast accessibility](#scott-o-hara-toast) at Scott O'Hara's blog.
+
+### Colors
+
+The ToastBar component is available in all [emotion colors][dictionary-color], plus the `inverted` variant (default).
+Use the `ToastBar--` modifier class to change the color of the ToastBar component.
+
+For example:
+
+```html
+
+
+
Success message
+
+
+```
+
+### Dismissible ToastBar
+
+To make the ToastBar dismissible, add the `ToastBar--dismissible` modifier class, a unique `id` attribute, and a close
+button:
+
+```html
+
+
+
Dismissible message
+
+
+
+```
+
+👉 Please keep in mind that the Button color should match the ToastBar color.
+
+⚠️ The JavaScript functionality for dismissing the ToastBar is yet to be implemented.
+
+## Full Example
+
+```html
+
+
+
+```
+
+[mdn-role-log]: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/log_role
+[mdn-aria-live]: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-live
+[dictionary-alignment]: https://github.com/lmc-eu/spirit-design-system/blob/main/docs/DICTIONARIES.md#alignment
+[dictionary-color]: https://github.com/lmc-eu/spirit-design-system/blob/main/docs/DICTIONARIES.md#color
+[scott-o-hara-toast]: https://www.scottohara.me/blog/2019/07/08/a-toast-to-a11y-toasts.html
diff --git a/packages/web/src/scss/components/Toast/_Toast.scss b/packages/web/src/scss/components/Toast/_Toast.scss
new file mode 100644
index 0000000000..b563899a55
--- /dev/null
+++ b/packages/web/src/scss/components/Toast/_Toast.scss
@@ -0,0 +1,135 @@
+// 1. Spread the `Toast` container over the viewport.
+//
+// a) For top-to-bottom languages, the `inset-inline` of `0` is a safe way to spread the container horizontally.
+// b) For the best scrolling experience on mobile devices with dynamic viewports, only one of the `inset-block`
+// values can be set to `0` at a time. Having both set to `0` would lead to sluggish scrolling on mobile devices.
+// c) The `height` property is set to `100lvh` to spread the container over the entire viewport so the toast messages
+// (and their shadows) are not clipped, even during dynamic viewport changes (again, using the `dvh` unit would
+// lead to sluggish scrolling on mobile devices).
+//
+// 2. Use grid layout to control horizontal (x) and vertical (y) alignment of the messages.
+//
+// a) Horizontal alignment is controlled by the `justify-content` property of the grid container. This is further
+// reinforced by the `align-items` property of the toast queue (7).
+// b) Vertical alignment is controlled by the `align-self` property of the toast queue itself (7). The reason for
+// this is that vertical scrolling of the toast container is not possible with `align-content: end` (more on that
+// below). This is also the reason why the initial scroll position is at the top of the container in both cases.
+//
+// ⚠️ CAUTION: Vertical scrolling of grid containers works only with `align-content` other than `end`.
+// While the CSS spec speaks [1] about the interaction of `overflow` and `align-content` properties, there may be
+// a blind spot in the spec because all major browsers (Firefox, Chrome, Safari) do not allow scrolling for
+// `align-content: end`.
+//
+// [1] https://drafts.csswg.org/css-align-3/#overflow-scroll-position
+//
+// A similar Chromium bug with flex containers is tracked here:
+//
+// https://issues.chromium.org/issues/41130651
+//
+// 3. Allow scrolling when the messages do not fit the screen. Scrolling is only possible over the toast message boxes
+// and not over the entire container. This is because the `pointer-events` property of the container is set to `none`
+// (4) so neither the toast container, nor the toast queue stands in the way of the user's interaction with the page
+// content beneath it.
+//
+// (The alternative would be to scroll the toast queue itself, but this would go against (4) by blocking user's
+// interaction with the underlying content.)
+//
+// ⚠️ CAUTION: As a result of this construction, vertical scrolling of the toast queue only works with pointers, not
+// with touch events.
+//
+// 4. Because the `Toast` container is present on the page all the time, we must allow interaction with the page content
+// beneath it. The same applies to the toast queue element (7).
+//
+// 5. On devices with rounded displays (like iPhone X and later), prefer the safe area value over our spacing, if
+// bigger. The `viewport-fit="cover"` viewport meta must be present in HTML for this to work. Zero value is used as
+// a fallback.
+//
+// 6. EXPERIMENTAL: When the virtual keyboard is open, the toast container should be pushed up to make space for the
+// keyboard. This is achieved by setting the bottom margin of the container to the height of the keyboard. This
+// feature requires the following JavaScript snippet and is currently supported only in Chrome 94 on Android and
+// later.
+//
+// ```js
+// if ('virtualKeyboard' in navigator) {
+// navigator.virtualKeyboard.overlaysContent = true;
+// }
+// ```
+//
+// ℹ️ Similar behavior can be achieved with the `interactive-widget=resizes-content` viewport meta, but then it would
+// affect the entire page, not just the toast container. Again, this feature is currently supported only in Chrome
+// 108 on Android and later.
+//
+// https://ishadeed.com/article/virtual-keyboard-api/
+// https://developer.chrome.com/blog/viewport-resize-behavior/
+//
+// 7. The toast queue element is in place so we can reverse the visual order of child toast messages when vertical
+// alignment is set to `bottom`. Reversing order of all child elements is only possible with flex containers.
+//
+// 8. Generate:
+//
+// a) horizontal (x) and
+// b) vertical (y) alignment classes for each breakpoint.
+
+@use 'sass:map';
+@use '../../tools/breakpoint';
+@use 'theme';
+
+.Toast {
+ --toast-padding-x: #{theme.$padding-x};
+ --toast-padding-y: #{theme.$padding-y};
+
+ position: fixed; // 1.
+ inset-inline: 0; // 1.a
+ inset-block: var(--toast-inset-top, 0) var(--toast-inset-bottom, 0); // 1.b
+ display: grid; // 2.
+ justify-content: var(--toast-alignment-x); // 2.a
+ height: 100lvh; // 1.c
+
+ // 5.
+ padding: max(var(--toast-padding-y), env(safe-area-inset-top, 0))
+ max(var(--toast-padding-x), env(safe-area-inset-right, 0))
+ max(var(--toast-padding-y), env(safe-area-inset-bottom, 0))
+ max(var(--toast-padding-x), env(safe-area-inset-left, 0));
+ margin-bottom: env(keyboard-inset-height, 0); // 6.
+ overflow-y: auto; // 3.
+ pointer-events: none; // 4.
+
+ @include breakpoint.up(map.get(theme.$breakpoints, tablet)) {
+ --toast-padding-x: #{theme.$padding-x-tablet};
+ }
+
+ @include breakpoint.up(map.get(theme.$breakpoints, desktop)) {
+ --toast-padding-x: #{theme.$padding-x-desktop};
+ }
+}
+
+.Toast__queue {
+ display: flex;
+ flex-direction: var(--toast-queue-direction, column-reverse); // 7.
+ row-gap: theme.$gap;
+ align-self: var(--toast-alignment-y, end); // 2.b
+ align-items: var(--toast-alignment-x, center); // 2.a
+}
+
+@each $breakpoint-name, $breakpoint-value in theme.$breakpoints {
+ $infix: breakpoint.get-modifier('infix', $breakpoint-name, $breakpoint-value);
+
+ @include breakpoint.up($breakpoint-value) {
+ // 8.a
+ @each $alignment-name, $alignment-value in theme.$alignments-x {
+ .Toast--#{$infix}#{$alignment-name} {
+ --toast-alignment-x: #{$alignment-value};
+ }
+ }
+
+ // 8.b
+ @each $alignment-name, $alignment-value in theme.$alignments-y {
+ .Toast--#{$infix}#{$alignment-name} {
+ --toast-alignment-y: #{$alignment-value};
+ --toast-inset-top: #{if($alignment-name == 'top', 0, 'auto')};
+ --toast-inset-bottom: #{if($alignment-name == 'bottom', 0, 'auto')};
+ --toast-queue-direction: #{if($alignment-name == 'bottom', 'column-reverse', 'column')};
+ }
+ }
+ }
+}
diff --git a/packages/web/src/scss/components/Toast/_ToastBar.scss b/packages/web/src/scss/components/Toast/_ToastBar.scss
new file mode 100644
index 0000000000..a2e3e6d698
--- /dev/null
+++ b/packages/web/src/scss/components/Toast/_ToastBar.scss
@@ -0,0 +1,56 @@
+// 1. Restore pointer events that have been disabled by parent `Toast` component.
+// 2. If the message is short enough, align it vertically to the center of the bar.
+// 3. Allow the action to wrap to the next line if the message is too long.
+// 4. Add margin to the action to separate it from the dismiss button.
+
+@use '../../tools/dictionaries';
+@use '../../tools/typography';
+@use 'theme';
+
+.ToastBar {
+ max-width: theme.$bar-max-width;
+ padding: theme.$bar-padding;
+ border-radius: theme.$bar-border-radius;
+ box-shadow: theme.$bar-box-shadow;
+ pointer-events: initial; // 1.
+}
+
+.ToastBar--dismissible {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ column-gap: theme.$bar-gap;
+ align-items: start;
+}
+
+.ToastBar--dismissible > .ToastBar__content {
+ align-self: center; // 2.
+}
+
+.ToastBar__content:has(> svg:first-child) {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ column-gap: theme.$bar-content-gap;
+}
+
+.ToastBar__message {
+ @include typography.generate(theme.$bar-typography);
+
+ display: flex;
+ flex-wrap: wrap; // 3.
+ gap: theme.$bar-message-gap-y theme.$bar-message-gap-x;
+}
+
+.ToastBar__message > :is(a, button):last-child {
+ font-weight: 400;
+}
+
+.ToastBar--dismissible .ToastBar__message > :is(a, button):last-child {
+ margin-inline-end: theme.$bar-action-margin-inline-end; // 4.
+}
+
+@include dictionaries.generate-colors(
+ $class-name: 'ToastBar',
+ $dictionary-values: theme.$color-dictionary,
+ $config: theme.$color-dictionary-config,
+ $overrides: theme.$color-dictionary-overrides
+);
diff --git a/packages/web/src/scss/components/Toast/_theme.scss b/packages/web/src/scss/components/Toast/_theme.scss
new file mode 100644
index 0000000000..40d144b636
--- /dev/null
+++ b/packages/web/src/scss/components/Toast/_theme.scss
@@ -0,0 +1,43 @@
+@use 'sass:list';
+@use '@tokens' as tokens;
+@use '../../settings/dictionaries';
+
+$alignments-x: (
+ left: start,
+ center: center,
+ right: end,
+);
+$alignments-y: (
+ top: start,
+ bottom: end,
+);
+$breakpoints: tokens.$breakpoints;
+
+$gap: tokens.$space-600;
+$padding-x: tokens.$space-700;
+$padding-x-tablet: tokens.$space-800;
+$padding-x-desktop: tokens.$space-900;
+$padding-y: tokens.$space-700;
+
+$bar-typography: tokens.$body-small-text-bold;
+$bar-max-width: 480px;
+$bar-gap: tokens.$space-600;
+$bar-padding: tokens.$space-600;
+$bar-border-radius: tokens.$radius-200;
+$bar-box-shadow: tokens.$shadow-400;
+$bar-content-gap: tokens.$space-500;
+$bar-message-gap-x: tokens.$space-700;
+$bar-message-gap-y: tokens.$space-500;
+$bar-action-margin-inline-end: tokens.$space-400;
+
+$color-dictionary: list.join('inverted', dictionaries.$emotion-colors);
+$color-dictionary-config: (
+ color: tokens.$text-primary-inverted-default,
+ background-color: 'default',
+);
+$color-dictionary-overrides: (
+ inverted: (
+ color: tokens.$text-primary-inverted-default,
+ background-color: tokens.$background-inverted,
+ ),
+);
diff --git a/packages/web/src/scss/components/Toast/index.html b/packages/web/src/scss/components/Toast/index.html
new file mode 100644
index 0000000000..c30aef4990
--- /dev/null
+++ b/packages/web/src/scss/components/Toast/index.html
@@ -0,0 +1,354 @@
+{{#> layout/plain }}
+
+
+
+