From 6730575f7ae98df73aa2a425d7c54391dc49c939 Mon Sep 17 00:00:00 2001 From: Erik Tiekstra Date: Wed, 3 Dec 2025 10:45:34 +0000 Subject: [PATCH] feat(BOOK-113): Synced hover/focus states for buttons and added better examples to storybook * fix(BOOK-113): Updated hover colors after blend/mix has been removed Approved-by: Christel Westerberg --- .../HotelListing/HotelListingItem/index.tsx | 21 +- .../components/MeetingPackageWidget/index.tsx | 34 +- .../meetingPackageWidget.module.css | 20 - .../BackToTopButton.stories.tsx | 100 ++++ .../backToTopButton.module.css | 51 +- .../lib/components/BackToTopButton/index.tsx | 16 +- .../components/BackToTopButton/variants.ts | 6 +- .../lib/components/Button/Button.stories.tsx | 441 ++++++++--------- .../lib/components/Button/Button.tsx | 2 +- .../lib/components/Button/button.module.css | 143 ++++-- .../ButtonLink/ButtonLink.stories.tsx | 460 ++++++++++++++++++ .../ButtonLink/buttonLink.module.css | 15 - .../lib/components/ButtonLink/index.tsx | 9 +- .../lib/components/ButtonLink/variants.ts | 5 +- .../FakeButton/fakeButton.module.css | 6 - .../lib/components/FakeButton/index.tsx | 20 +- .../lib/components/FakeButton/variants.ts | 14 +- .../components/HotelCard/hotelCard.module.css | 44 +- .../lib/components/HotelCard/index.tsx | 51 +- .../IconButton/IconButton.stories.tsx | 75 ++- .../IconButton/iconButton.module.css | 75 ++- .../Lightbox/Gallery/gallery.module.css | 25 +- .../lib/components/Lightbox/Gallery/index.tsx | 29 +- .../components/TextLink/textLink.module.css | 9 + 24 files changed, 1143 insertions(+), 528 deletions(-) create mode 100644 packages/design-system/lib/components/BackToTopButton/BackToTopButton.stories.tsx create mode 100644 packages/design-system/lib/components/ButtonLink/ButtonLink.stories.tsx delete mode 100644 packages/design-system/lib/components/ButtonLink/buttonLink.module.css delete mode 100644 packages/design-system/lib/components/FakeButton/fakeButton.module.css diff --git a/apps/scandic-web/components/Blocks/HotelListing/HotelListingItem/index.tsx b/apps/scandic-web/components/Blocks/HotelListing/HotelListingItem/index.tsx index ca9cd2387..65533d1a7 100644 --- a/apps/scandic-web/components/Blocks/HotelListing/HotelListingItem/index.tsx +++ b/apps/scandic-web/components/Blocks/HotelListing/HotelListingItem/index.tsx @@ -3,6 +3,7 @@ import ButtonLink from "@scandic-hotels/design-system/ButtonLink" import { Divider } from "@scandic-hotels/design-system/Divider" import HotelLogoIcon from "@scandic-hotels/design-system/Icons/HotelLogoIcon" import Image from "@scandic-hotels/design-system/Image" +import ImageFallback from "@scandic-hotels/design-system/ImageFallback" import { Typography } from "@scandic-hotels/design-system/Typography" import { getIntl } from "@/i18n" @@ -42,14 +43,18 @@ export default async function HotelListingItem({ return (
- {image.altText + {image?.src ? ( + {image.altText + ) : ( + + )}
diff --git a/apps/scandic-web/components/MeetingPackageWidget/index.tsx b/apps/scandic-web/components/MeetingPackageWidget/index.tsx index 42cd0c4e4..f331a0a04 100644 --- a/apps/scandic-web/components/MeetingPackageWidget/index.tsx +++ b/apps/scandic-web/components/MeetingPackageWidget/index.tsx @@ -1,7 +1,8 @@ "use client" +import { useState } from "react" import { - Button, + Button as ButtonRAC, Dialog, DialogTrigger, Modal, @@ -10,6 +11,7 @@ import { import { useIntl } from "react-intl" import { useMediaQuery } from "usehooks-ts" +import { FakeButton } from "@scandic-hotels/design-system/FakeButton" import { IconButton } from "@scandic-hotels/design-system/IconButton" import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon" import { Typography } from "@scandic-hotels/design-system/Typography" @@ -29,6 +31,7 @@ export default function MeetingPackageWidget(props: MeetingPackageWidgetProps) { const isDesktop = useMediaQuery("(min-width: 948px)", { initializeWithValue: false, }) + const [isHovered, setIsHovered] = useState(false) return isDesktop ? ( @@ -36,7 +39,11 @@ export default function MeetingPackageWidget(props: MeetingPackageWidgetProps) {
- + {intl.formatMessage({ + id: "bookingWidget.button.search", + defaultMessage: "Search", + })} + +
diff --git a/apps/scandic-web/components/MeetingPackageWidget/meetingPackageWidget.module.css b/apps/scandic-web/components/MeetingPackageWidget/meetingPackageWidget.module.css index 021f60c93..72134d856 100644 --- a/apps/scandic-web/components/MeetingPackageWidget/meetingPackageWidget.module.css +++ b/apps/scandic-web/components/MeetingPackageWidget/meetingPackageWidget.module.css @@ -14,12 +14,6 @@ background-color: transparent; width: 100%; cursor: pointer; - - &:hover .fakeButton { - background-color: var(--Component-Button-Brand-Primary-Fill-Hover); - border-color: var(--Component-Button-Brand-Primary-Border-Hover); - color: var(--Component-Button-Brand-Primary-On-fill-Hover); - } } .fakeInput { @@ -35,20 +29,6 @@ color: var(--Text-Interactive-Placeholder); } -.fakeButton { - border-radius: var(--Corner-radius-rounded); - border-width: 2px; - border-style: solid; - display: flex; - align-items: center; - justify-content: center; - gap: var(--Space-x05); - padding: 10px var(--Space-x2); - background-color: var(--Component-Button-Brand-Primary-Fill-Default); - border-color: var(--Component-Button-Brand-Primary-Border-Default); - color: var(--Component-Button-Brand-Primary-On-fill-Default); -} - .overlay { position: fixed; inset: 0; diff --git a/packages/design-system/lib/components/BackToTopButton/BackToTopButton.stories.tsx b/packages/design-system/lib/components/BackToTopButton/BackToTopButton.stories.tsx new file mode 100644 index 000000000..9d2ac2960 --- /dev/null +++ b/packages/design-system/lib/components/BackToTopButton/BackToTopButton.stories.tsx @@ -0,0 +1,100 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite' + +import { expect } from 'storybook/test' + +import { BackToTopButton } from '.' +import { config as backToTopButtonConfig } from './variants' + +const meta: Meta = { + title: 'Components/BackToTopButton', + component: BackToTopButton, + argTypes: { + onPress: { + table: { + disable: true, + }, + }, + position: { + control: 'select', + options: Object.keys(backToTopButtonConfig.variants.position), + table: { + type: { + summary: 'string', + detail: Object.keys(backToTopButtonConfig.variants.position).join( + ' | ' + ), + }, + defaultValue: { + summary: backToTopButtonConfig.defaultVariants.position, + }, + }, + }, + label: { + control: 'text', + }, + }, +} + +export default meta + +type Story = StoryObj + +const globalStoryPropsInverted = { + backgrounds: { value: 'scandicPrimaryDark' }, +} + +export const Default: Story = { + args: { + onPress: () => alert('Back to top button pressed!'), + label: 'Back to top', + }, + play: async ({ canvas, userEvent, args }) => { + await userEvent.click(await canvas.findByRole('button')) + expect(args.onPress).toHaveBeenCalledTimes(1) + }, +} + +export const PositionLeft: Story = { + args: { + ...Default.args, + position: 'left', + }, + play: async ({ canvas, userEvent, args }) => { + await userEvent.click(await canvas.findByRole('button')) + expect(args.onPress).toHaveBeenCalledTimes(1) + }, +} + +export const PositionCenter: Story = { + args: { + ...Default.args, + position: 'center', + }, + play: async ({ canvas, userEvent, args }) => { + await userEvent.click(await canvas.findByRole('button')) + expect(args.onPress).toHaveBeenCalledTimes(1) + }, +} + +export const PositionRight: Story = { + args: { + ...Default.args, + position: 'right', + }, + play: async ({ canvas, userEvent, args }) => { + await userEvent.click(await canvas.findByRole('button')) + expect(args.onPress).toHaveBeenCalledTimes(1) + }, +} + +export const OnDarkBackground: Story = { + globals: globalStoryPropsInverted, + args: { + onPress: () => alert('Back to top button pressed!'), + label: 'Back to top', + }, + play: async ({ canvas, userEvent, args }) => { + await userEvent.click(await canvas.findByRole('button')) + expect(args.onPress).toHaveBeenCalledTimes(1) + }, +} diff --git a/packages/design-system/lib/components/BackToTopButton/backToTopButton.module.css b/packages/design-system/lib/components/BackToTopButton/backToTopButton.module.css index 8cb0aae36..0e45d2cce 100644 --- a/packages/design-system/lib/components/BackToTopButton/backToTopButton.module.css +++ b/packages/design-system/lib/components/BackToTopButton/backToTopButton.module.css @@ -1,24 +1,43 @@ .backToTopButton { - display: inline-flex; - padding: var(--Space-x1); - justify-content: center; - align-items: center; - gap: var(--Space-x05); - width: max-content; - color: var(--Component-Button-Brand-Secondary-On-fill-Default); - background-color: var(--Component-Button-Brand-Secondary-Fill-Inverted); - border: 2px solid var(--Component-Button-Brand-Secondary-Border-Default); - border-radius: var(--Corner-radius-Rounded); - box-shadow: 0px 0px 8px 3px rgba(0, 0, 0, 0.1); + border-radius: var(--Corner-radius-rounded); cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--Space-x05); + + padding: var(--Space-x1); + width: max-content; + background-color: var(--Component-Button-Brand-Secondary-Fill-Inverted); + color: var(--Component-Button-Brand-Secondary-On-fill-Default); + border: 2px solid var(--Component-Button-Brand-Secondary-Border-Default); + box-shadow: 0px 0px 8px 3px rgba(0, 0, 0, 0.1); position: sticky; bottom: var(--Space-x2); - &:hover { - color: var(--Component-Button-Brand-Secondary-On-fill-Inverted); - background-color: var( - --Component-Button-Brand-Secondary-Fill-Hover-Inverted - ); + @media (hover: hover) { + &:hover { + background-color: var( + --Component-Button-Brand-Secondary-Fill-Hover-Inverted + ); + color: var(--Component-Button-Brand-Secondary-On-fill-Inverted); + } + } + + &:focus-visible { + outline: 2px solid var(--Border-Interactive-Focus); + outline-offset: 2px; + + /* This button is able to be on top of dark background colors, + so we need to create an illusion that it also has an inverted border on focus */ + &::before { + content: ''; + position: absolute; + inset: -4px; + border: 2px solid var(--Border-Inverted); + border-radius: inherit; + pointer-events: none; + } } } diff --git a/packages/design-system/lib/components/BackToTopButton/index.tsx b/packages/design-system/lib/components/BackToTopButton/index.tsx index 61e1a7475..f83f47153 100644 --- a/packages/design-system/lib/components/BackToTopButton/index.tsx +++ b/packages/design-system/lib/components/BackToTopButton/index.tsx @@ -1,11 +1,11 @@ 'use client' -import { type Button, Button as ButtonRAC } from 'react-aria-components' +import { Button as ButtonRAC } from 'react-aria-components' import { MaterialIcon } from '../Icons/MaterialIcon' import { Typography } from '../Typography' -import { backToTopButtonVariants } from './variants' +import { variants } from './variants' import styles from './backToTopButton.module.css' @@ -13,8 +13,8 @@ import type { VariantProps } from 'class-variance-authority' import type { ComponentProps } from 'react' interface BackToTopButtonProps - extends ComponentProps, - VariantProps { + extends ComponentProps, + VariantProps { label: string } @@ -23,13 +23,11 @@ export function BackToTopButton({ label, ...props }: BackToTopButtonProps) { + const classNames = variants({ position }) + return ( - + {label} diff --git a/packages/design-system/lib/components/BackToTopButton/variants.ts b/packages/design-system/lib/components/BackToTopButton/variants.ts index 1dd7cc413..fd09e7957 100644 --- a/packages/design-system/lib/components/BackToTopButton/variants.ts +++ b/packages/design-system/lib/components/BackToTopButton/variants.ts @@ -2,7 +2,7 @@ import { cva } from 'class-variance-authority' import styles from './backToTopButton.module.css' -export const backToTopButtonVariants = cva(styles.backToTopButton, { +export const config = { variants: { position: { left: styles.left, @@ -13,4 +13,6 @@ export const backToTopButtonVariants = cva(styles.backToTopButton, { defaultVariants: { position: 'right', }, -}) +} as const + +export const variants = cva(styles.backToTopButton, config) diff --git a/packages/design-system/lib/components/Button/Button.stories.tsx b/packages/design-system/lib/components/Button/Button.stories.tsx index 33cfeebd0..50c780d41 100644 --- a/packages/design-system/lib/components/Button/Button.stories.tsx +++ b/packages/design-system/lib/components/Button/Button.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite' -import { expect, fn } from 'storybook/test' +import { expect } from 'storybook/test' import { MaterialIcon } from '../Icons/MaterialIcon' import { config as typographyConfig } from '../Typography/variants' @@ -24,42 +24,69 @@ const meta: Meta = { control: 'select', options: Object.keys(buttonConfig.variants.variant), default: 'Primary', + table: { + defaultValue: { + summary: buttonConfig.defaultVariants.variant, + }, + type: { + summary: 'string', + detail: Object.keys(buttonConfig.variants.variant).join(' | '), + }, + }, }, color: { control: 'select', options: Object.keys(buttonConfig.variants.color), - type: 'string', - description: - 'The color variant, only applies to the variants `Primary`, `Secondary` and `Text`. Defaults to `Primary`.', + table: { + type: { + summary: 'string', + detail: Object.keys(buttonConfig.variants.color).join(' | '), + }, + defaultValue: { + summary: buttonConfig.defaultVariants.color, + }, + }, }, size: { control: 'select', options: Object.keys(buttonConfig.variants.size), - type: 'string', - description: 'The size of the button. Defaults to `Large`.', + table: { + type: { + summary: 'string', + detail: Object.keys(buttonConfig.variants.size).join(' | '), + }, + defaultValue: { + summary: buttonConfig.defaultVariants.size, + }, + }, }, wrapping: { control: 'radio', options: Object.keys(buttonConfig.variants.wrapping), type: 'boolean', + table: { + defaultValue: { + summary: buttonConfig.defaultVariants.wrapping.toString(), + }, + }, description: - 'Only applies to variant `Text`. If `true`, the button will keep the default padding set on the buttons. Defaults to `true`.', + 'Only applies to variant `Text`. If `false`, the button will use smaller padding.', }, }, } +const globalStoryPropsInverted = { + backgrounds: { value: 'scandicPrimaryDark' }, +} export default meta type Story = StoryObj -export const PrimaryDefault: Story = { +export const Default: Story = { args: { - onPress: fn(), - children: 'Primary button', + onPress: () => alert('Primary button pressed!'), + children: 'Button', typography: 'Body/Paragraph/mdBold', - variant: 'Primary', - isDisabled: false, - isPending: false, }, play: async ({ canvas, userEvent, args }) => { await userEvent.click(await canvas.findByRole('button')) @@ -67,31 +94,10 @@ export const PrimaryDefault: Story = { }, } -export const PrimaryDisabled: Story = { - args: { - ...PrimaryDefault.args, - isDisabled: true, - }, - play: async ({ canvas, userEvent, args }) => { - await userEvent.click(await canvas.findByRole('button')) - expect(args.onPress).toHaveBeenCalledTimes(0) - }, -} - -export const PrimaryLoading: Story = { - args: { - ...PrimaryDefault.args, - isPending: true, - }, - play: async ({ canvas, userEvent, args }) => { - await userEvent.click(await canvas.findByRole('button')) - expect(args.onPress).toHaveBeenCalledTimes(0) - }, -} - export const PrimaryLarge: Story = { args: { - ...PrimaryDefault.args, + ...Default.args, + variant: 'Primary', size: 'Large', }, play: async ({ canvas, userEvent, args }) => { @@ -102,7 +108,7 @@ export const PrimaryLarge: Story = { export const PrimaryMedium: Story = { args: { - ...PrimaryDefault.args, + ...PrimaryLarge.args, size: 'Medium', }, play: async ({ canvas, userEvent, args }) => { @@ -113,7 +119,8 @@ export const PrimaryMedium: Story = { export const PrimarySmall: Story = { args: { - ...PrimaryDefault.args, + ...PrimaryLarge.args, + typography: 'Body/Supporting text (caption)/smBold', size: 'Small', }, play: async ({ canvas, userEvent, args }) => { @@ -122,12 +129,44 @@ export const PrimarySmall: Story = { }, } -export const PrimaryInvertedDefault: Story = { +export const PrimaryDisabled: Story = { args: { - onPress: fn(), - children: 'Primary inverted button', - typography: 'Body/Paragraph/mdBold', - variant: 'Primary', + ...PrimaryLarge.args, + isDisabled: true, + }, + play: async ({ canvas, userEvent, args }) => { + await userEvent.click(await canvas.findByRole('button')) + expect(args.onPress).toHaveBeenCalledTimes(0) + }, +} + +export const PrimaryLoading: Story = { + args: { + ...PrimaryLarge.args, + isPending: true, + }, + play: async ({ canvas, userEvent, args }) => { + await userEvent.click(await canvas.findByRole('button')) + expect(args.onPress).toHaveBeenCalledTimes(0) + }, +} + +export const PrimaryOnDarkBackground: Story = { + globals: globalStoryPropsInverted, + args: { + ...PrimaryLarge.args, + }, + play: async ({ canvas, userEvent, args }) => { + await userEvent.click(await canvas.findByRole('button')) + expect(args.onPress).toHaveBeenCalledTimes(0) + }, +} + +export const PrimaryInvertedLarge: Story = { + globals: globalStoryPropsInverted, + args: { + ...Default.args, + size: 'Large', color: 'Inverted', }, play: async ({ canvas, userEvent, args }) => { @@ -136,9 +175,35 @@ export const PrimaryInvertedDefault: Story = { }, } -export const PrimaryInvertedDisabled: Story = { +export const PrimaryInvertedMedium: Story = { + globals: globalStoryPropsInverted, args: { - ...PrimaryInvertedDefault.args, + ...PrimaryInvertedLarge.args, + size: 'Medium', + }, + play: async ({ canvas, userEvent, args }) => { + await userEvent.click(await canvas.findByRole('button')) + expect(args.onPress).toHaveBeenCalledTimes(1) + }, +} + +export const PrimaryInvertedSmall: Story = { + globals: globalStoryPropsInverted, + args: { + ...PrimaryInvertedLarge.args, + typography: 'Body/Supporting text (caption)/smBold', + size: 'Small', + }, + play: async ({ canvas, userEvent, args }) => { + await userEvent.click(await canvas.findByRole('button')) + expect(args.onPress).toHaveBeenCalledTimes(1) + }, +} + +export const PrimaryInvertedDisabled: Story = { + globals: globalStoryPropsInverted, + args: { + ...PrimaryInvertedLarge.args, isDisabled: true, }, play: async ({ canvas, userEvent, args }) => { @@ -148,8 +213,9 @@ export const PrimaryInvertedDisabled: Story = { } export const PrimaryInvertedLoading: Story = { + globals: globalStoryPropsInverted, args: { - ...PrimaryInvertedDefault.args, + ...PrimaryInvertedLarge.args, isPending: true, }, play: async ({ canvas, userEvent, args }) => { @@ -158,50 +224,35 @@ export const PrimaryInvertedLoading: Story = { }, } -export const PrimaryInvertedLarge: Story = { +export const SecondaryLarge: Story = { args: { - ...PrimaryInvertedDefault.args, + ...Default.args, + variant: 'Secondary', size: 'Large', }, - play: async ({ canvas, userEvent, args }) => { await userEvent.click(await canvas.findByRole('button')) expect(args.onPress).toHaveBeenCalledTimes(1) }, } -export const PrimaryInvertedMedium: Story = { +export const SecondaryMedium: Story = { args: { - ...PrimaryInvertedDefault.args, + ...SecondaryLarge.args, size: 'Medium', }, - play: async ({ canvas, userEvent, args }) => { await userEvent.click(await canvas.findByRole('button')) expect(args.onPress).toHaveBeenCalledTimes(1) }, } -export const PrimaryInvertedSmall: Story = { +export const SecondarySmall: Story = { args: { - ...PrimaryInvertedDefault.args, + ...SecondaryLarge.args, + typography: 'Body/Supporting text (caption)/smBold', size: 'Small', }, - - play: async ({ canvas, userEvent, args }) => { - await userEvent.click(await canvas.findByRole('button')) - expect(args.onPress).toHaveBeenCalledTimes(1) - }, -} - -export const SecondaryDefault: Story = { - args: { - onPress: fn(), - children: 'Secondary button', - typography: 'Body/Paragraph/mdBold', - variant: 'Secondary', - }, - play: async ({ canvas, userEvent, args }) => { await userEvent.click(await canvas.findByRole('button')) expect(args.onPress).toHaveBeenCalledTimes(1) @@ -210,7 +261,7 @@ export const SecondaryDefault: Story = { export const SecondaryDisabled: Story = { args: { - ...SecondaryDefault.args, + ...SecondaryLarge.args, isDisabled: true, }, play: async ({ canvas, userEvent, args }) => { @@ -221,85 +272,9 @@ export const SecondaryDisabled: Story = { export const SecondaryLoading: Story = { args: { - ...SecondaryDefault.args, + ...SecondaryLarge.args, isPending: true, }, - - play: async ({ canvas, userEvent, args }) => { - await userEvent.click(await canvas.findByRole('button')) - expect(args.onPress).toHaveBeenCalledTimes(0) - }, -} - -export const SecondaryLarge: Story = { - args: { - ...SecondaryDefault.args, - size: 'Large', - }, - - play: async ({ canvas, userEvent, args }) => { - await userEvent.click(await canvas.findByRole('button')) - expect(args.onPress).toHaveBeenCalledTimes(1) - }, -} - -export const SecondaryMedium: Story = { - args: { - ...SecondaryDefault.args, - size: 'Medium', - }, - - play: async ({ canvas, userEvent, args }) => { - await userEvent.click(await canvas.findByRole('button')) - expect(args.onPress).toHaveBeenCalledTimes(1) - }, -} - -export const SecondarySmall: Story = { - args: { - ...SecondaryDefault.args, - size: 'Small', - }, - - play: async ({ canvas, userEvent, args }) => { - await userEvent.click(await canvas.findByRole('button')) - expect(args.onPress).toHaveBeenCalledTimes(1) - }, -} - -export const SecondaryInvertedDefault: Story = { - args: { - onPress: fn(), - children: 'Secondary inverted button', - typography: 'Body/Paragraph/mdBold', - variant: 'Secondary', - color: 'Inverted', - }, - - play: async ({ canvas, userEvent, args }) => { - await userEvent.click(await canvas.findByRole('button')) - expect(args.onPress).toHaveBeenCalledTimes(1) - }, -} - -export const SecondaryInvertedDisabled: Story = { - args: { - ...SecondaryInvertedDefault.args, - isDisabled: true, - }, - - play: async ({ canvas, userEvent, args }) => { - await userEvent.click(await canvas.findByRole('button')) - expect(args.onPress).toHaveBeenCalledTimes(0) - }, -} - -export const SecondaryInvertedLoading: Story = { - args: { - ...SecondaryInvertedDefault.args, - isPending: true, - }, - play: async ({ canvas, userEvent, args }) => { await userEvent.click(await canvas.findByRole('button')) expect(args.onPress).toHaveBeenCalledTimes(0) @@ -307,11 +282,13 @@ export const SecondaryInvertedLoading: Story = { } export const SecondaryInvertedLarge: Story = { + globals: globalStoryPropsInverted, args: { - ...SecondaryInvertedDefault.args, + ...Default.args, + variant: 'Secondary', + color: 'Inverted', size: 'Large', }, - play: async ({ canvas, userEvent, args }) => { await userEvent.click(await canvas.findByRole('button')) expect(args.onPress).toHaveBeenCalledTimes(1) @@ -319,11 +296,11 @@ export const SecondaryInvertedLarge: Story = { } export const SecondaryInvertedMedium: Story = { + globals: globalStoryPropsInverted, args: { - ...SecondaryInvertedDefault.args, + ...SecondaryInvertedLarge.args, size: 'Medium', }, - play: async ({ canvas, userEvent, args }) => { await userEvent.click(await canvas.findByRole('button')) expect(args.onPress).toHaveBeenCalledTimes(1) @@ -331,60 +308,48 @@ export const SecondaryInvertedMedium: Story = { } export const SecondaryInvertedSmall: Story = { + globals: globalStoryPropsInverted, args: { - ...SecondaryInvertedDefault.args, + ...SecondaryInvertedLarge.args, + typography: 'Body/Supporting text (caption)/smBold', size: 'Small', }, - play: async ({ canvas, userEvent, args }) => { await userEvent.click(await canvas.findByRole('button')) expect(args.onPress).toHaveBeenCalledTimes(1) }, } -export const TertiaryDefault: Story = { +export const SecondaryInvertedDisabled: Story = { + globals: globalStoryPropsInverted, args: { - onPress: fn(), - children: 'Tertiary button', - typography: 'Body/Paragraph/mdBold', - variant: 'Tertiary', - }, - - play: async ({ canvas, userEvent, args }) => { - await userEvent.click(await canvas.findByRole('button')) - expect(args.onPress).toHaveBeenCalledTimes(1) - }, -} - -export const TertiaryDisabled: Story = { - args: { - ...TertiaryDefault.args, + ...SecondaryInvertedLarge.args, isDisabled: true, }, - play: async ({ canvas, userEvent, args }) => { await userEvent.click(await canvas.findByRole('button')) expect(args.onPress).toHaveBeenCalledTimes(0) }, } -export const TertiaryLoading: Story = { +export const SecondaryInvertedLoading: Story = { + globals: globalStoryPropsInverted, args: { - ...TertiaryDefault.args, + ...SecondaryInvertedLarge.args, isPending: true, }, - play: async ({ canvas, userEvent, args }) => { await userEvent.click(await canvas.findByRole('button')) expect(args.onPress).toHaveBeenCalledTimes(0) }, } + export const TertiaryLarge: Story = { args: { - ...TertiaryDefault.args, + ...Default.args, + variant: 'Tertiary', size: 'Large', }, - play: async ({ canvas, userEvent, args }) => { await userEvent.click(await canvas.findByRole('button')) expect(args.onPress).toHaveBeenCalledTimes(1) @@ -393,10 +358,9 @@ export const TertiaryLarge: Story = { export const TertiaryMedium: Story = { args: { - ...TertiaryDefault.args, + ...TertiaryLarge.args, size: 'Medium', }, - play: async ({ canvas, userEvent, args }) => { await userEvent.click(await canvas.findByRole('button')) expect(args.onPress).toHaveBeenCalledTimes(1) @@ -405,36 +369,32 @@ export const TertiaryMedium: Story = { export const TertiarySmall: Story = { args: { - ...TertiaryDefault.args, + ...TertiaryLarge.args, + typography: 'Body/Supporting text (caption)/smBold', size: 'Small', }, - play: async ({ canvas, userEvent, args }) => { await userEvent.click(await canvas.findByRole('button')) expect(args.onPress).toHaveBeenCalledTimes(1) }, } -export const TextDefault: Story = { +export const TertiaryDisabled: Story = { args: { - onPress: fn(), - children: 'Text button', - typography: 'Body/Paragraph/mdBold', - variant: 'Text', - }, - - play: async ({ canvas, userEvent, args }) => { - await userEvent.click(await canvas.findByRole('button')) - expect(args.onPress).toHaveBeenCalledTimes(1) - }, -} - -export const TextDisabled: Story = { - args: { - ...TextDefault.args, + ...TertiaryLarge.args, isDisabled: true, }, + play: async ({ canvas, userEvent, args }) => { + await userEvent.click(await canvas.findByRole('button')) + expect(args.onPress).toHaveBeenCalledTimes(0) + }, +} +export const TertiaryLoading: Story = { + args: { + ...TertiaryLarge.args, + isPending: true, + }, play: async ({ canvas, userEvent, args }) => { await userEvent.click(await canvas.findByRole('button')) expect(args.onPress).toHaveBeenCalledTimes(0) @@ -443,10 +403,10 @@ export const TextDisabled: Story = { export const TextLarge: Story = { args: { - ...TextDefault.args, + ...Default.args, + variant: 'Text', size: 'Large', }, - play: async ({ canvas, userEvent, args }) => { await userEvent.click(await canvas.findByRole('button')) expect(args.onPress).toHaveBeenCalledTimes(1) @@ -455,7 +415,7 @@ export const TextLarge: Story = { export const TextMedium: Story = { args: { - ...TextDefault.args, + ...TextLarge.args, size: 'Medium', }, @@ -467,7 +427,8 @@ export const TextMedium: Story = { export const TextSmall: Story = { args: { - ...TextDefault.args, + ...TextLarge.args, + typography: 'Body/Supporting text (caption)/smBold', size: 'Small', }, @@ -477,10 +438,20 @@ export const TextSmall: Story = { }, } +export const TextDisabled: Story = { + args: { + ...TextLarge.args, + isDisabled: true, + }, + play: async ({ canvas, userEvent, args }) => { + await userEvent.click(await canvas.findByRole('button')) + expect(args.onPress).toHaveBeenCalledTimes(0) + }, +} + export const TextNoWrapping: Story = { args: { - ...TextDefault.args, - children: 'Text button with wrapping false', + ...TextLarge.args, wrapping: false, }, play: async ({ canvas, userEvent, args }) => { @@ -489,39 +460,14 @@ export const TextNoWrapping: Story = { }, } -export const TextInvertedDefault: Story = { +export const TextInvertedLarge: Story = { + globals: globalStoryPropsInverted, args: { - onPress: fn(), - children: 'Text inverted button', - typography: 'Body/Paragraph/mdBold', + ...Default.args, variant: 'Text', color: 'Inverted', - }, - - play: async ({ canvas, userEvent, args }) => { - await userEvent.click(await canvas.findByRole('button')) - expect(args.onPress).toHaveBeenCalledTimes(1) - }, -} - -export const TextInvertedDisabled: Story = { - args: { - ...TextInvertedDefault.args, - isDisabled: true, - }, - - play: async ({ canvas, userEvent, args }) => { - await userEvent.click(await canvas.findByRole('button')) - expect(args.onPress).toHaveBeenCalledTimes(0) - }, -} - -export const TextInvertedLarge: Story = { - args: { - ...TextInvertedDefault.args, size: 'Large', }, - play: async ({ canvas, userEvent, args }) => { await userEvent.click(await canvas.findByRole('button')) expect(args.onPress).toHaveBeenCalledTimes(1) @@ -529,11 +475,11 @@ export const TextInvertedLarge: Story = { } export const TextInvertedMedium: Story = { + globals: globalStoryPropsInverted, args: { - ...TextInvertedDefault.args, + ...TextInvertedLarge.args, size: 'Medium', }, - play: async ({ canvas, userEvent, args }) => { await userEvent.click(await canvas.findByRole('button')) expect(args.onPress).toHaveBeenCalledTimes(1) @@ -541,28 +487,39 @@ export const TextInvertedMedium: Story = { } export const TextInvertedSmall: Story = { + globals: globalStoryPropsInverted, args: { - ...TextInvertedDefault.args, + ...TextInvertedLarge.args, + typography: 'Body/Supporting text (caption)/smBold', size: 'Small', }, - play: async ({ canvas, userEvent, args }) => { await userEvent.click(await canvas.findByRole('button')) expect(args.onPress).toHaveBeenCalledTimes(1) }, } +export const TextInvertedDisabled: Story = { + globals: globalStoryPropsInverted, + args: { + ...TextInvertedLarge.args, + isDisabled: true, + }, + play: async ({ canvas, userEvent, args }) => { + await userEvent.click(await canvas.findByRole('button')) + expect(args.onPress).toHaveBeenCalledTimes(0) + }, +} + export const TextWithIcon: Story = { args: { - onPress: fn(), + ...TextLarge.args, children: ( <> Text with icon ), - typography: 'Body/Paragraph/mdBold', - variant: 'Text', }, play: async ({ canvas, userEvent, args }) => { await userEvent.click(await canvas.findByRole('button')) @@ -574,19 +531,11 @@ export const TextWithIcon: Story = { } export const TextWithIconInverted: Story = { + globals: globalStoryPropsInverted, args: { - onPress: fn(), - children: ( - <> - Text with icon - - - ), - typography: 'Body/Paragraph/mdBold', - variant: 'Text', + ...TextWithIcon.args, color: 'Inverted', }, - play: async ({ canvas, userEvent, args }) => { await userEvent.click(await canvas.findByRole('button')) expect(args.onPress).toHaveBeenCalledTimes(1) diff --git a/packages/design-system/lib/components/Button/Button.tsx b/packages/design-system/lib/components/Button/Button.tsx index 96fcaf6be..1c6dc9493 100644 --- a/packages/design-system/lib/components/Button/Button.tsx +++ b/packages/design-system/lib/components/Button/Button.tsx @@ -1,8 +1,8 @@ import { Button as ButtonRAC } from 'react-aria-components' import { Loading, type LoadingProps } from '../Loading/Loading' -import { variants } from './variants' import type { ButtonProps } from './types' +import { variants } from './variants' export function Button({ variant, diff --git a/packages/design-system/lib/components/Button/button.module.css b/packages/design-system/lib/components/Button/button.module.css index 1af64c26b..118dc4e5e 100644 --- a/packages/design-system/lib/components/Button/button.module.css +++ b/packages/design-system/lib/components/Button/button.module.css @@ -1,28 +1,33 @@ .button { + position: relative; border-radius: var(--Corner-radius-rounded); border-width: 2px; border-style: solid; cursor: pointer; - display: flex; + display: inline-flex; align-items: center; justify-content: center; gap: var(--Space-x05); - &:disabled, &[data-disabled] { cursor: unset; } &[data-pending] { cursor: progress; + gap: var(--Space-x1); } &:focus-visible { - outline: 2px auto -webkit-focus-ring-color; - outline-offset: 4px; + outline: 2px solid var(--Border-Interactive-Focus); + outline-offset: 2px; } } +.color-inverted:focus-visible { + outline-color: var(--Border-Inverted); +} + .size-large { padding: var(--Space-x2) var(--Space-x3); } @@ -41,14 +46,32 @@ color: var(--Component-Button-Brand-Primary-On-fill-Default); @media (hover: hover) { - &:not(:disabled):hover { - background-color: var(--Component-Button-Brand-Primary-Fill-Hover); - border-color: var(--Component-Button-Brand-Primary-Border-Hover); - color: var(--Component-Button-Brand-Primary-On-fill-Hover); + &:not([data-disabled]) { + &:hover, + &.hovered { + background: + linear-gradient( + 0deg, + var(--Component-Button-Brand-Primary-Fill-Hover) 0%, + var(--Component-Button-Brand-Primary-Fill-Hover) 100% + ), + var(--Component-Button-Brand-Primary-Fill-Default); + } } } - &:disabled { + /* This variant is able to be on top of dark background colors, + so we need to create an illusion that it also has an inverted border on focus */ + &:not(.color-inverted):focus-visible::before { + content: ''; + position: absolute; + inset: -4px; + border: 2px solid var(--Border-Inverted); + border-radius: inherit; + pointer-events: none; + } + + &[data-disabled] { background-color: var(--Component-Button-Brand-Primary-Fill-Disabled); border-color: var(--Component-Button-Brand-Primary-Border-Disabled); color: var(--Component-Button-Brand-Primary-On-fill-Disabled); @@ -61,14 +84,21 @@ color: var(--Component-Button-Inverted-On-fill-Default); @media (hover: hover) { - &:not(:disabled):hover { - background-color: var(--Component-Button-Inverted-Fill-Hover); - border-color: var(--Component-Button-Inverted-Border-Hover); - color: var(--Component-Button-Inverted-On-fill-Hover); + &:not([data-disabled]) { + &:hover, + &.hovered { + background: + linear-gradient( + 0deg, + var(--Component-Button-Inverted-Fill-Hover) 0%, + var(--Component-Button-Inverted-Fill-Hover) 100% + ), + var(--Component-Button-Inverted-Fill-Default); + } } } - &:disabled { + &[data-disabled] { background-color: var(--Component-Button-Inverted-Fill-Disabled); border-color: var(--Component-Button-Inverted-Border-Disabled); color: var(--Component-Button-Inverted-On-fill-Disabled); @@ -81,15 +111,17 @@ color: var(--Component-Button-Brand-Secondary-On-fill-Default); @media (hover: hover) { - &:not(:disabled):hover { - background-color: var(--Component-Button-Brand-Secondary-Fill-Hover); - border-color: var(--Component-Button-Brand-Secondary-Border-Hover); - color: var(--Component-Button-Brand-Secondary-On-fill-Hover); + &:not([data-disabled]) { + &:hover, + &.hovered { + background-color: var(--Component-Button-Brand-Secondary-Fill-Hover); + border-color: var(--Component-Button-Brand-Secondary-Border-Hover); + color: var(--Component-Button-Brand-Secondary-On-fill-Hover); + } } } - &:disabled { - background-color: var(--Component-Button-Brand-Secondary-Fill-Disabled); + &[data-disabled] { border-color: var(--Component-Button-Brand-Secondary-Border-Disabled); color: var(--Component-Button-Brand-Secondary-On-fill-Disabled); } @@ -101,16 +133,19 @@ color: var(--Component-Button-Brand-Secondary-On-fill-Inverted); @media (hover: hover) { - &:not(:disabled):hover { - background-color: var(--Component-Button-Brand-Secondary-Fill-Hover); - border-color: var( - --Component-Button-Brand-Secondary-Border-Hover-inverted - ); - color: var(--Component-Button-Brand-Secondary-On-fill-Hover-inverted); + &:not([data-disabled]) { + &:hover, + &.hovered { + background-color: var(--Component-Button-Brand-Secondary-Fill-Hover); + border-color: var( + --Component-Button-Brand-Secondary-Border-Hover-inverted + ); + color: var(--Component-Button-Brand-Secondary-On-fill-Hover-inverted); + } } } - &:disabled { + &[data-disabled] { background-color: var(--Component-Button-Brand-Secondary-Fill-Disabled); border-color: var(--Component-Button-Brand-Secondary-Border-Disabled); color: var(--Component-Button-Brand-Secondary-On-fill-Disabled); @@ -123,14 +158,23 @@ color: var(--Component-Button-Brand-Tertiary-On-fill-Default); @media (hover: hover) { - &:not(:disabled):hover { - background-color: var(--Component-Button-Brand-Tertiary-Fill-Hover); - border-color: var(--Component-Button-Brand-Tertiary-Border-Hover); - color: var(--Component-Button-Brand-Tertiary-On-fill-Hover); + &:not([data-disabled]) { + &:hover, + &.hovered { + background: + linear-gradient( + 0deg, + rgba(255, 255, 255, 0.1) 0%, + rgba(255, 255, 255, 0.1) 100% + ), + var(--Component-Button-Brand-Tertiary-Fill-Default); + border-color: var(--Component-Button-Brand-Tertiary-Border-Hover); + color: var(--Component-Button-Brand-Tertiary-On-fill-Hover); + } } } - &:disabled { + &[data-disabled] { background-color: var(--Component-Button-Brand-Tertiary-Fill-Disabled); border-color: var(--Component-Button-Brand-Tertiary-Border-Disabled); color: var(--Component-Button-Brand-Tertiary-On-fill-Disabled); @@ -143,14 +187,17 @@ color: var(--Component-Button-Inverted-On-fill-Default); @media (hover: hover) { - &:not(:disabled):hover { - background-color: var(--Component-Button-Inverted-Hover); - border-color: transparent; - color: var(--Component-Button-Inverted-On-fill-Hover); + &:not([data-disabled]) { + &:hover, + &.hovered { + background-color: var(--Component-Button-Inverted-Hover); + border-color: transparent; + color: var(--Component-Button-Inverted-On-fill-Hover); + } } } - &:disabled { + &[data-disabled] { background-color: var(--Component-Button-Inverted-Disabled); border-color: transparent; color: var(--Component-Button-Inverted-On-fill-Disabled); @@ -165,13 +212,15 @@ padding-right: 0; @media (hover: hover) { - &:not(:disabled):hover { - color: var(--Component-Button-Brand-Secondary-On-fill-Hover); - text-decoration: underline; + &:not([data-disabled]) { + &:hover, + &.hovered { + color: var(--Component-Button-Brand-Secondary-On-fill-Hover); + text-decoration: underline; + } } } - - &:disabled { + &[data-disabled] { color: var(--Component-Button-Brand-Secondary-On-fill-Disabled); text-decoration: none; } @@ -180,19 +229,21 @@ .variant-text.no-wrapping { padding: var(--Space-x025) 0; border-width: 0; - border-radius: 0; } .variant-text.color-inverted { color: var(--Component-Button-Brand-Secondary-On-fill-Inverted); @media (hover: hover) { - &:not(:disabled):hover { - color: var(--Component-Button-Brand-Secondary-On-fill-Hover-inverted); + &:not([data-disabled]) { + &:hover, + &.hovered { + color: var(--Component-Button-Brand-Secondary-On-fill-Hover-inverted); + } } } - &:disabled { + &[data-disabled] { color: var(--Component-Button-Brand-Secondary-On-fill-Disabled); } } diff --git a/packages/design-system/lib/components/ButtonLink/ButtonLink.stories.tsx b/packages/design-system/lib/components/ButtonLink/ButtonLink.stories.tsx new file mode 100644 index 000000000..8f7ee0405 --- /dev/null +++ b/packages/design-system/lib/components/ButtonLink/ButtonLink.stories.tsx @@ -0,0 +1,460 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite' + +import { expect } from 'storybook/test' + +import ButtonLink from '.' +import { config as buttonConfig } from '../Button/variants' +import { MaterialIcon } from '../Icons/MaterialIcon' +import { config as typographyConfig } from '../Typography/variants' + +const meta: Meta = { + title: 'Components/ButtonLink', + component: ButtonLink, + argTypes: { + onClick: { + table: { + disable: true, + }, + }, + typography: { + control: 'select', + options: Object.keys(typographyConfig.variants.variant), + }, + variant: { + control: 'select', + options: Object.keys(buttonConfig.variants.variant), + default: 'Primary', + table: { + defaultValue: { + summary: buttonConfig.defaultVariants.variant, + }, + type: { + summary: 'string', + detail: Object.keys(buttonConfig.variants.variant).join(' | '), + }, + }, + }, + color: { + control: 'select', + options: Object.keys(buttonConfig.variants.color), + table: { + type: { + summary: 'string', + detail: Object.keys(buttonConfig.variants.color).join(' | '), + }, + defaultValue: { + summary: buttonConfig.defaultVariants.color, + }, + }, + }, + size: { + control: 'select', + options: Object.keys(buttonConfig.variants.size), + table: { + type: { + summary: 'string', + detail: Object.keys(buttonConfig.variants.size).join(' | '), + }, + defaultValue: { + summary: buttonConfig.defaultVariants.size, + }, + }, + }, + wrapping: { + control: 'radio', + options: Object.keys(buttonConfig.variants.wrapping), + type: 'boolean', + table: { + defaultValue: { + summary: buttonConfig.defaultVariants.wrapping.toString(), + }, + }, + description: + 'Only applies to variant `Text`. If `false`, the button will use smaller padding.', + }, + }, +} + +const globalStoryPropsInverted = { + backgrounds: { value: 'scandicPrimaryDark' }, +} +export default meta + +type Story = StoryObj + +export const Default: Story = { + args: { + onClick: (event) => { + event.preventDefault() + alert('Button link clicked!') + }, + href: '#', + children: 'Button link', + typography: 'Body/Paragraph/mdBold', + }, + play: async ({ canvasElement }) => { + const link = canvasElement.querySelector('a') + if (!link) throw new Error('Link not found') + expect(link).toBeInTheDocument() + }, +} + +export const PrimaryLarge: Story = { + args: { + ...Default.args, + variant: 'Primary', + size: 'Large', + }, + play: async ({ canvasElement }) => { + const link = canvasElement.querySelector('a') + if (!link) throw new Error('Link not found') + expect(link).toBeInTheDocument() + }, +} + +export const PrimaryMedium: Story = { + args: { + ...PrimaryLarge.args, + size: 'Medium', + }, + play: async ({ canvasElement }) => { + const link = canvasElement.querySelector('a') + if (!link) throw new Error('Link not found') + expect(link).toBeInTheDocument() + }, +} + +export const PrimarySmall: Story = { + args: { + ...PrimaryLarge.args, + typography: 'Body/Supporting text (caption)/smBold', + size: 'Small', + }, + play: async ({ canvasElement }) => { + const link = canvasElement.querySelector('a') + if (!link) throw new Error('Link not found') + expect(link).toBeInTheDocument() + }, +} + +export const PrimaryOnDarkBackground: Story = { + globals: globalStoryPropsInverted, + args: { + ...Default.args, + variant: 'Primary', + size: 'Large', + }, + play: async ({ canvasElement }) => { + const link = canvasElement.querySelector('a') + if (!link) throw new Error('Link not found') + expect(link).toBeInTheDocument() + }, +} + +export const PrimaryInvertedLarge: Story = { + globals: globalStoryPropsInverted, + args: { + ...Default.args, + variant: 'Primary', + color: 'Inverted', + size: 'Large', + }, + + play: async ({ canvasElement }) => { + const link = canvasElement.querySelector('a') + if (!link) throw new Error('Link not found') + expect(link).toBeInTheDocument() + }, +} + +export const PrimaryInvertedMedium: Story = { + globals: globalStoryPropsInverted, + args: { + ...PrimaryInvertedLarge.args, + size: 'Medium', + }, + + play: async ({ canvasElement }) => { + const link = canvasElement.querySelector('a') + if (!link) throw new Error('Link not found') + expect(link).toBeInTheDocument() + }, +} + +export const PrimaryInvertedSmall: Story = { + globals: globalStoryPropsInverted, + args: { + ...PrimaryInvertedLarge.args, + typography: 'Body/Supporting text (caption)/smBold', + size: 'Small', + }, + + play: async ({ canvasElement }) => { + const link = canvasElement.querySelector('a') + if (!link) throw new Error('Link not found') + expect(link).toBeInTheDocument() + }, +} + +export const SecondaryLarge: Story = { + args: { + ...Default.args, + variant: 'Secondary', + size: 'Large', + }, + + play: async ({ canvasElement }) => { + const link = canvasElement.querySelector('a') + if (!link) throw new Error('Link not found') + expect(link).toBeInTheDocument() + }, +} + +export const SecondaryMedium: Story = { + args: { + ...SecondaryLarge.args, + size: 'Medium', + }, + + play: async ({ canvasElement }) => { + const link = canvasElement.querySelector('a') + if (!link) throw new Error('Link not found') + expect(link).toBeInTheDocument() + }, +} + +export const SecondarySmall: Story = { + args: { + ...SecondaryLarge.args, + typography: 'Body/Supporting text (caption)/smBold', + size: 'Small', + }, + + play: async ({ canvasElement }) => { + const link = canvasElement.querySelector('a') + if (!link) throw new Error('Link not found') + expect(link).toBeInTheDocument() + }, +} + +export const SecondaryInvertedLarge: Story = { + globals: globalStoryPropsInverted, + args: { + ...Default.args, + variant: 'Secondary', + color: 'Inverted', + size: 'Large', + }, + + play: async ({ canvasElement }) => { + const link = canvasElement.querySelector('a') + if (!link) throw new Error('Link not found') + expect(link).toBeInTheDocument() + }, +} + +export const SecondaryInvertedMedium: Story = { + globals: globalStoryPropsInverted, + args: { + ...SecondaryInvertedLarge.args, + size: 'Medium', + }, + + play: async ({ canvasElement }) => { + const link = canvasElement.querySelector('a') + if (!link) throw new Error('Link not found') + expect(link).toBeInTheDocument() + }, +} + +export const SecondaryInvertedSmall: Story = { + globals: globalStoryPropsInverted, + args: { + ...SecondaryInvertedLarge.args, + typography: 'Body/Supporting text (caption)/smBold', + size: 'Small', + }, + + play: async ({ canvasElement }) => { + const link = canvasElement.querySelector('a') + if (!link) throw new Error('Link not found') + expect(link).toBeInTheDocument() + }, +} + +export const TertiaryLarge: Story = { + args: { + ...Default.args, + variant: 'Tertiary', + size: 'Large', + }, + + play: async ({ canvasElement }) => { + const link = canvasElement.querySelector('a') + if (!link) throw new Error('Link not found') + expect(link).toBeInTheDocument() + }, +} + +export const TertiaryMedium: Story = { + args: { + ...TertiaryLarge.args, + size: 'Medium', + }, + + play: async ({ canvasElement }) => { + const link = canvasElement.querySelector('a') + if (!link) throw new Error('Link not found') + expect(link).toBeInTheDocument() + }, +} + +export const TertiarySmall: Story = { + args: { + ...TertiaryLarge.args, + typography: 'Body/Supporting text (caption)/smBold', + size: 'Small', + }, + + play: async ({ canvasElement }) => { + const link = canvasElement.querySelector('a') + if (!link) throw new Error('Link not found') + expect(link).toBeInTheDocument() + }, +} + +export const TextLarge: Story = { + args: { + ...Default.args, + variant: 'Text', + size: 'Large', + }, + + play: async ({ canvasElement }) => { + const link = canvasElement.querySelector('a') + if (!link) throw new Error('Link not found') + expect(link).toBeInTheDocument() + }, +} + +export const TextMedium: Story = { + args: { + ...TextLarge.args, + size: 'Medium', + }, + + play: async ({ canvasElement }) => { + const link = canvasElement.querySelector('a') + if (!link) throw new Error('Link not found') + expect(link).toBeInTheDocument() + }, +} + +export const TextSmall: Story = { + args: { + ...TextLarge.args, + typography: 'Body/Supporting text (caption)/smBold', + size: 'Small', + }, + + play: async ({ canvasElement }) => { + const link = canvasElement.querySelector('a') + if (!link) throw new Error('Link not found') + expect(link).toBeInTheDocument() + }, +} + +export const TextNoWrapping: Story = { + args: { + ...TextLarge.args, + children: 'Text button with wrapping false', + wrapping: false, + }, + play: async ({ canvasElement }) => { + const link = canvasElement.querySelector('a') + if (!link) throw new Error('Link not found') + expect(link).toBeInTheDocument() + }, +} + +export const TextInvertedLarge: Story = { + globals: globalStoryPropsInverted, + args: { + ...Default.args, + variant: 'Text', + color: 'Inverted', + size: 'Large', + }, + + play: async ({ canvasElement }) => { + const link = canvasElement.querySelector('a') + if (!link) throw new Error('Link not found') + expect(link).toBeInTheDocument() + }, +} + +export const TextInvertedMedium: Story = { + globals: globalStoryPropsInverted, + args: { + ...TextInvertedLarge.args, + size: 'Medium', + }, + + play: async ({ canvasElement }) => { + const link = canvasElement.querySelector('a') + if (!link) throw new Error('Link not found') + expect(link).toBeInTheDocument() + }, +} + +export const TextInvertedSmall: Story = { + globals: globalStoryPropsInverted, + args: { + ...TextInvertedLarge.args, + typography: 'Body/Supporting text (caption)/smBold', + size: 'Small', + }, + + play: async ({ canvasElement }) => { + const link = canvasElement.querySelector('a') + if (!link) throw new Error('Link not found') + expect(link).toBeInTheDocument() + }, +} + +export const TextWithIcon: Story = { + args: { + ...Default.args, + variant: 'Text', + children: ( + <> + Text with icon + + + ), + }, + play: async ({ canvasElement }) => { + const link = canvasElement.querySelector('a') + if (!link) throw new Error('Link not found') + expect(link).toBeInTheDocument() + }, +} + +export const TextWithIconInverted: Story = { + globals: globalStoryPropsInverted, + args: { + ...TextWithIcon.args, + color: 'Inverted', + children: ( + <> + Text with icon + + + ), + }, + play: async ({ canvasElement }) => { + const link = canvasElement.querySelector('a') + if (!link) throw new Error('Link not found') + expect(link).toBeInTheDocument() + }, +} diff --git a/packages/design-system/lib/components/ButtonLink/buttonLink.module.css b/packages/design-system/lib/components/ButtonLink/buttonLink.module.css deleted file mode 100644 index 78aab83fd..000000000 --- a/packages/design-system/lib/components/ButtonLink/buttonLink.module.css +++ /dev/null @@ -1,15 +0,0 @@ -.buttonLink { - border-radius: var(--Corner-radius-rounded); - border-width: 2px; - border-style: solid; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - gap: var(--Space-x05); - - &:focus-visible { - outline: 2px auto -webkit-focus-ring-color; - outline-offset: 4px; - } -} diff --git a/packages/design-system/lib/components/ButtonLink/index.tsx b/packages/design-system/lib/components/ButtonLink/index.tsx index fcd978160..98ea9ec26 100644 --- a/packages/design-system/lib/components/ButtonLink/index.tsx +++ b/packages/design-system/lib/components/ButtonLink/index.tsx @@ -1,16 +1,15 @@ 'use client' -import Link from 'next/link' -import { type ComponentProps, type PropsWithChildren } from 'react' +import { type ComponentProps } from 'react' import { variants } from './variants' import type { VariantProps } from 'class-variance-authority' +import Link from 'next/link' import { useIntl } from 'react-intl' export interface ButtonLinkProps - extends PropsWithChildren, - Omit, 'color'>, + extends Omit, 'color'>, VariantProps {} export default function ButtonLink({ @@ -22,7 +21,6 @@ export default function ButtonLink({ className, href, target, - onClick = () => {}, ...props }: ButtonLinkProps) { const classNames = variants({ @@ -45,7 +43,6 @@ export default function ButtonLink({ className={classNames} href={href} target={target} - onClick={onClick} title={target === '_blank' ? newTabText : ''} {...props} /> diff --git a/packages/design-system/lib/components/ButtonLink/variants.ts b/packages/design-system/lib/components/ButtonLink/variants.ts index 1aeb40209..2318e1490 100644 --- a/packages/design-system/lib/components/ButtonLink/variants.ts +++ b/packages/design-system/lib/components/ButtonLink/variants.ts @@ -1,7 +1,6 @@ import { cva } from 'class-variance-authority' import { withButton } from '../Button' +import buttonStyles from '../Button/button.module.css' -import styles from './buttonLink.module.css' - -export const variants = cva(styles.buttonLink, withButton({})) +export const variants = cva([buttonStyles.button], withButton({})) diff --git a/packages/design-system/lib/components/FakeButton/fakeButton.module.css b/packages/design-system/lib/components/FakeButton/fakeButton.module.css deleted file mode 100644 index fcd897342..000000000 --- a/packages/design-system/lib/components/FakeButton/fakeButton.module.css +++ /dev/null @@ -1,6 +0,0 @@ -.fakeButton { - display: flex; - align-items: center; - border-radius: var(--Corner-radius-rounded); - gap: var(--Space-x05); -} diff --git a/packages/design-system/lib/components/FakeButton/index.tsx b/packages/design-system/lib/components/FakeButton/index.tsx index 634ea5ebf..a3b61f3ca 100644 --- a/packages/design-system/lib/components/FakeButton/index.tsx +++ b/packages/design-system/lib/components/FakeButton/index.tsx @@ -2,14 +2,14 @@ import { variants } from './variants' -import type { VariantProps } from 'class-variance-authority' -import type { ComponentProps, PropsWithChildren } from 'react' -import type { Button } from 'react-aria-components' +import { cx, type VariantProps } from 'class-variance-authority' +import type { HTMLAttributes } from 'react' interface FakeButtonProps - extends PropsWithChildren, - Omit, 'children' | 'onPress'>, - VariantProps {} + extends Omit, 'color'>, + VariantProps { + isDisabled?: boolean +} export function FakeButton({ variant, @@ -18,6 +18,8 @@ export function FakeButton({ typography, children, className, + isHovered, + isDisabled, ...props }: FakeButtonProps) { const classNames = variants({ @@ -25,13 +27,15 @@ export function FakeButton({ size, variant, typography, + isHovered, className, }) return ( )} + className={cx(classNames)} + data-disabled={isDisabled || undefined} + {...props} > {children} diff --git a/packages/design-system/lib/components/FakeButton/variants.ts b/packages/design-system/lib/components/FakeButton/variants.ts index f46d57c2e..a7cf23258 100644 --- a/packages/design-system/lib/components/FakeButton/variants.ts +++ b/packages/design-system/lib/components/FakeButton/variants.ts @@ -1,6 +1,16 @@ import { cva } from 'class-variance-authority' -import styles from './fakeButton.module.css' import { withButton } from '../Button' -export const variants = cva(styles.fakeButton, withButton({})) +import buttonStyles from '../Button/button.module.css' + +export const variants = cva( + buttonStyles.button, + withButton({ + variants: { + isHovered: { + true: buttonStyles.hovered, + }, + }, + }) +) diff --git a/packages/design-system/lib/components/HotelCard/hotelCard.module.css b/packages/design-system/lib/components/HotelCard/hotelCard.module.css index 7f1362fa8..f78a9d539 100644 --- a/packages/design-system/lib/components/HotelCard/hotelCard.module.css +++ b/packages/design-system/lib/components/HotelCard/hotelCard.module.css @@ -87,19 +87,13 @@ width: 100%; } -.link:hover { - .fakeButton { - background-color: var(--Component-Button-Brand-Primary-Fill-Hover); - border-color: var(--Component-Button-Brand-Primary-Border-Hover); - color: var(--Component-Button-Brand-Primary-On-fill-Hover); - } +.link { + text-decoration: none; + color: inherit; - .priceCard { - background: linear-gradient( - 0deg, - var(--Surface-Primary-Hover) 0%, - var(--Surface-Primary-Hover) 100% - ); + &:focus-visible { + outline: 2px solid var(--Border-Interactive-Focus); + outline-offset: 2px; } } @@ -113,28 +107,6 @@ border-radius: var(--Corner-radius-md); } -.fakeButton { - min-width: 160px; - border-radius: var(--Corner-radius-rounded); - border-width: 2px; - border-style: solid; - display: flex; - align-items: center; - justify-content: center; - gap: var(--Space-x05); - - padding: 10px var(--Space-x2); - background-color: var(--Component-Button-Brand-Primary-Fill-Default); - border-color: var(--Component-Button-Brand-Primary-Border-Default); - color: var(--Component-Button-Brand-Primary-On-fill-Default); -} - -.fakeButton.disabled { - background-color: var(--Component-Button-Brand-Primary-Fill-Disabled); - border-color: var(--Component-Button-Brand-Primary-Border-Disabled); - color: var(--Component-Button-Brand-Primary-On-fill-Disabled); -} - @media screen and (min-width: 768px) and (max-width: 1024px) { .imageContainer { height: 180px; @@ -178,10 +150,6 @@ margin-bottom: var(--Space-x15); } - .pageListing .fakeButton { - width: 100%; - } - .pageListing .prices { width: 260px; } diff --git a/packages/design-system/lib/components/HotelCard/index.tsx b/packages/design-system/lib/components/HotelCard/index.tsx index 9cc10c83f..dcf12378d 100644 --- a/packages/design-system/lib/components/HotelCard/index.tsx +++ b/packages/design-system/lib/components/HotelCard/index.tsx @@ -1,6 +1,6 @@ 'use client' -import { cx } from 'class-variance-authority' +import NextLink from 'next/link' import { type ReadonlyURLSearchParams, useSearchParams } from 'next/navigation' import { memo, useState } from 'react' import { useFocusWithin } from 'react-aria' @@ -35,6 +35,7 @@ import { HotelType } from '@scandic-hotels/common/constants/hotelType' import type { Lang } from '@scandic-hotels/common/constants/language' import { RateTypeEnum } from '@scandic-hotels/common/constants/rateType' import { BookingCodeChip } from '../BookingCodeChip' +import { FakeButton } from '../FakeButton' import { TripAdvisorChip } from '../TripAdvisorChip' type Price = { @@ -147,6 +148,7 @@ export const HotelCardComponent = memo( }: HotelCardProps) => { const searchParams = useSearchParams() const [isFocusWithin, setIsFocusWithin] = useState(false) + const [isPricesHovered, setIsPricesHovered] = useState(false) const { focusWithinProps } = useFocusWithin({ onFocusWithin: onFocusIn, onBlurWithin: onFocusOut, @@ -295,6 +297,8 @@ export const HotelCardComponent = memo( hotelId={hotel.id} removeBookingCodeFromSearchParams={!!(bookingCode && fullPrice)} searchParams={searchParams} + onHoverStart={() => setIsPricesHovered(true)} + onHoverEnd={() => setIsPricesHovered(false)} > {!prices ? ( @@ -358,24 +362,20 @@ export const HotelCardComponent = memo( ))}
) : null} - {isDisabled ? ( -
- - {notEnoughPointsLabel} - -
- ) : ( -
- - - {intl.formatMessage({ - id: 'common.seeRooms', - defaultMessage: 'See rooms', - })} - - -
- )} + + {isDisabled + ? notEnoughPointsLabel + : intl.formatMessage({ + id: 'common.seeRooms', + defaultMessage: 'See rooms', + })} + )} @@ -396,6 +396,8 @@ interface PricesWrapperProps { pathname: string removeBookingCodeFromSearchParams: boolean searchParams: ReadonlyURLSearchParams + onHoverStart: () => void + onHoverEnd: () => void } function PricesWrapper({ children, @@ -404,6 +406,8 @@ function PricesWrapper({ pathname, removeBookingCodeFromSearchParams, searchParams, + onHoverStart, + onHoverEnd, }: PricesWrapperProps) { const content =
{children}
@@ -422,8 +426,13 @@ function PricesWrapper({ const href = `${pathname}?${params.toString()}` return ( - + {content} - + ) } diff --git a/packages/design-system/lib/components/IconButton/IconButton.stories.tsx b/packages/design-system/lib/components/IconButton/IconButton.stories.tsx index 6bc28342b..5aa768d95 100644 --- a/packages/design-system/lib/components/IconButton/IconButton.stories.tsx +++ b/packages/design-system/lib/components/IconButton/IconButton.stories.tsx @@ -1,6 +1,6 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite' -import { expect, fn } from 'storybook/test' +import { expect } from 'storybook/test' import { MaterialIcon } from '../Icons/MaterialIcon' import { IconButton } from './IconButton' @@ -15,35 +15,53 @@ const meta: Meta = { disable: true, }, }, + children: { + table: { + disable: true, + }, + }, theme: { control: 'select', options: Object.keys(config.variants.theme), - default: 'Primary', + table: { + defaultValue: { + summary: config.defaultVariants.theme, + }, + type: { + summary: 'string', + detail: Object.keys(config.variants.theme).join(' | '), + }, + }, }, style: { control: 'select', options: Object.keys(config.variants.style), - default: 'Normal', - type: 'string', - description: `The style variant is only applied on certain variants. The examples below shows the possible combinations of variants and style variants.`, - }, - wrapping: { - control: 'select', - options: Object.keys(config.variants.wrapping), - default: undefined, + table: { + defaultValue: { + summary: config.defaultVariants.style, + }, + type: { + summary: 'string', + detail: Object.keys(config.variants.style).join(' | '), + }, + }, + description: + 'The style variant is only applied on certain variants. The examples below shows the possible combinations of variants and style variants.', }, }, } +const globalStoryPropsInverted = { + backgrounds: { value: 'scandicPrimaryDark' }, +} export default meta type Story = StoryObj -export const PrimaryDefault: Story = { +export const Default: Story = { args: { - onPress: fn(), + onPress: () => alert('Icon button pressed!'), children: , - theme: 'Primary', }, play: async ({ canvas, userEvent, args }) => { await userEvent.click(canvas.getByRole('button')) @@ -51,9 +69,20 @@ export const PrimaryDefault: Story = { }, } +export const Primary: Story = { + args: { + ...Default.args, + theme: 'Primary', + }, + play: async ({ canvas, userEvent, args }) => { + await userEvent.click(canvas.getByRole('button')) + expect(args.onPress).toHaveBeenCalledTimes(0) + }, +} + export const PrimaryDisabled: Story = { args: { - ...PrimaryDefault.args, + ...Primary.args, isDisabled: true, }, play: async ({ canvas, userEvent, args }) => { @@ -62,9 +91,9 @@ export const PrimaryDisabled: Story = { }, } -export const InvertedDefault: Story = { +export const Inverted: Story = { args: { - onPress: fn(), + ...Default.args, children: ( ), @@ -78,7 +107,7 @@ export const InvertedDefault: Story = { export const InvertedDisabled: Story = { args: { - ...InvertedDefault.args, + ...Inverted.args, isDisabled: true, }, play: async ({ canvas, userEvent, args }) => { @@ -89,7 +118,7 @@ export const InvertedDisabled: Story = { export const InvertedElevated: Story = { args: { - ...InvertedDefault.args, + ...Inverted.args, style: 'Elevated', }, play: async ({ canvas, userEvent, args }) => { @@ -110,8 +139,9 @@ export const InvertedElevatedDisabled: Story = { } export const InvertedMuted: Story = { + globals: globalStoryPropsInverted, args: { - ...InvertedDefault.args, + ...Inverted.args, children: , style: 'Muted', }, @@ -123,6 +153,7 @@ export const InvertedMuted: Story = { } export const InvertedMutedDisabled: Story = { + globals: globalStoryPropsInverted, args: { ...InvertedMuted.args, isDisabled: true, @@ -136,7 +167,7 @@ export const InvertedMutedDisabled: Story = { export const InvertedFaded: Story = { args: { - ...InvertedDefault.args, + ...Inverted.args, style: 'Faded', }, play: async ({ canvas, userEvent, args }) => { @@ -158,7 +189,7 @@ export const InvertedFadedDisabled: Story = { export const TertiaryElevated: Story = { args: { - onPress: fn(), + ...Default.args, children: , theme: 'Tertiary', style: 'Elevated', @@ -182,7 +213,7 @@ export const TertiaryDisabled: Story = { export const BlackMuted: Story = { args: { - onPress: fn(), + ...Default.args, children: , theme: 'Black', }, diff --git a/packages/design-system/lib/components/IconButton/iconButton.module.css b/packages/design-system/lib/components/IconButton/iconButton.module.css index 7f24fa2e9..302ee443b 100644 --- a/packages/design-system/lib/components/IconButton/iconButton.module.css +++ b/packages/design-system/lib/components/IconButton/iconButton.module.css @@ -1,15 +1,21 @@ .iconButton { + position: relative; border-radius: var(--Corner-radius-rounded); border-width: 0; cursor: pointer; - display: flex; + display: inline-flex; align-items: center; justify-content: center; padding: 10px; - &:disabled { + &[data-disabled] { cursor: unset; } + + &:focus-visible { + outline: 2px solid var(--Border-Interactive-Focus); + outline-offset: 2px; + } } .theme-primary { @@ -17,13 +23,30 @@ color: var(--Component-Button-Brand-Primary-On-fill-Default); @media (hover: hover) { - &:hover:not(:disabled) { - background-color: var(--Component-Button-Brand-Primary-Fill-Hover); + &:hover:not([data-disabled]) { + background: + linear-gradient( + 0deg, + var(--Component-Button-Brand-Primary-Fill-Hover) 0%, + var(--Component-Button-Brand-Primary-Fill-Hover) 100% + ), + var(--Component-Button-Brand-Primary-Fill-Default); color: var(--Component-Button-Brand-Primary-On-fill-Hover); } } - &:disabled { + /* This theme is able to be on top of dark background colors, + so we need to create an illusion that it also has an inverted border on focus */ + &:focus-visible::after { + content: ''; + position: absolute; + inset: -2px; + border: 2px solid var(--Border-Inverted); + border-radius: inherit; + pointer-events: none; + } + + &[data-disabled] { background-color: var(--Component-Button-Brand-Primary-Fill-Disabled); color: var(--Component-Button-Brand-Primary-On-fill-Disabled); } @@ -34,27 +57,37 @@ color: var(--Component-Button-Inverted-On-fill-Default); @media (hover: hover) { - &:hover:not(:disabled) { - background-color: var(--Component-Button-Inverted-Fill-Hover); - color: var(--Component-Button-Inverted-On-fill-Hover); + &:hover:not([data-disabled]) { + background: + linear-gradient( + 0deg, + var(--Component-Button-Inverted-Fill-Hover) 0%, + var(--Component-Button-Inverted-Fill-Hover) 100% + ), + var(--Component-Button-Inverted-Fill-Default); } } - &:disabled { + &[data-disabled] { background-color: var(--Component-Button-Inverted-Fill-Disabled); color: var(--Component-Button-Inverted-On-fill-Disabled); } &.style-muted { + background-color: var(--Component-Button-Muted-Fill-Default); color: var(--Component-Button-Muted-On-fill-Inverted); @media (hover: hover) { &:hover:not(:disabled) { - color: var(--Component-Button-Muted-On-fill-Inverted); + background-color: var(--Component-Button-Muted-Fill-Hover); } } - &:disabled { + &:focus-visible { + outline-color: var(--Border-Inverted); + } + + &[data-disabled] { color: var(--Component-Button-Muted-On-fill-Disabled); } } @@ -65,13 +98,19 @@ color: var(--Component-Button-Brand-Tertiary-On-fill-Default); @media (hover: hover) { - &:hover:not(:disabled) { - background-color: var(--Component-Button-Brand-Tertiary-Fill-Hover); + &:hover:not([data-disabled]) { + background: + linear-gradient( + 0deg, + var(--Component-Button-Brand-Tertiary-Fill-Hover) 0%, + var(--Component-Button-Brand-Tertiary-Fill-Hover) 100% + ), + var(--Component-Button-Brand-Tertiary-Fill-Default); color: var(--Component-Button-Brand-Tertiary-On-fill-Hover); } } - &:disabled { + &[data-disabled] { background-color: var(--Component-Button-Brand-Tertiary-Fill-Disabled); color: var(--Component-Button-Brand-Tertiary-On-fill-Disabled); } @@ -81,12 +120,12 @@ color: var(--Component-Button-Muted-On-fill-Default); @media (hover: hover) { - &:hover:not(:disabled) { + &:hover:not([data-disabled]) { color: var(--Component-Button-Muted-On-fill-Hover-Inverted); } } - &:disabled { + &[data-disabled] { color: var(--Component-Button-Muted-On-fill-Disabled); } } @@ -103,12 +142,12 @@ background-color: var(--Component-Button-Muted-Fill-Default); @media (hover: hover) { - &:hover:not(:disabled) { + &:hover:not([data-disabled]) { background-color: var(--Component-Button-Muted-Fill-Hover-inverted); } } - &:disabled { + &[data-disabled] { background-color: var(--Component-Button-Muted-Fill-Disabled-inverted); } } diff --git a/packages/design-system/lib/components/Lightbox/Gallery/gallery.module.css b/packages/design-system/lib/components/Lightbox/Gallery/gallery.module.css index 5b1d78738..013d75ba7 100644 --- a/packages/design-system/lib/components/Lightbox/Gallery/gallery.module.css +++ b/packages/design-system/lib/components/Lightbox/Gallery/gallery.module.css @@ -152,27 +152,12 @@ position: absolute; top: 50%; transform: translateY(-50%); - background-color: var(--Component-Button-Inverted-Fill-Default); - color: var(--Component-Button-Inverted-On-fill-Default); - border-radius: var(--Corner-radius-rounded); - padding: 10px; - cursor: pointer; - border-width: 0; - display: flex; - z-index: 1; - box-shadow: 0px 0px 8px 1px #0000001a; - &:hover { - background-color: var(--Component-Button-Inverted-Fill-Hover); - color: var(--Component-Button-Inverted-On-fill-Hover); + &.previous { + left: var(--Space-x2); + } + &.next { + right: var(--Space-x2); } } - - .galleryPrevButton { - left: var(--Space-x2); - } - - .galleryNextButton { - right: var(--Space-x2); - } } diff --git a/packages/design-system/lib/components/Lightbox/Gallery/index.tsx b/packages/design-system/lib/components/Lightbox/Gallery/index.tsx index ec5ab81d7..b5839e64c 100644 --- a/packages/design-system/lib/components/Lightbox/Gallery/index.tsx +++ b/packages/design-system/lib/components/Lightbox/Gallery/index.tsx @@ -10,6 +10,7 @@ import { Typography } from '../../Typography' import Image from '../../Image' +import { cx } from 'class-variance-authority' import { LightboxImage } from '..' import styles from './gallery.module.css' @@ -147,18 +148,30 @@ export default function Gallery({ /> - - - + - +
diff --git a/packages/design-system/lib/components/TextLink/textLink.module.css b/packages/design-system/lib/components/TextLink/textLink.module.css index 4be5925cb..07b617ccc 100644 --- a/packages/design-system/lib/components/TextLink/textLink.module.css +++ b/packages/design-system/lib/components/TextLink/textLink.module.css @@ -4,6 +4,11 @@ justify-content: center; gap: var(--Space-x05); padding: var(--Space-x025) 0; + + &:focus-visible { + outline: 2px solid var(--Border-Interactive-Focus); + outline-offset: 2px; + } } .disabled { @@ -31,6 +36,10 @@ &:hover { opacity: 0.7; } + + &:focus-visible { + outline-color: var(--Border-Inverted); + } } .theme-interactive-default:not(.disabled) {