Merged in feat/SW-3636-storybook-structure (pull request #3309)

feat(SW-3636): Storybook structure

* New sections in Storybook sidebar

* Group Storybook content files and add token files for spacing, border radius and shadows


Approved-by: Joakim Jäderberg
This commit is contained in:
Rasmus Langvad
2025-12-08 12:35:14 +00:00
parent 177c2e7176
commit ca6cc5ab6c
83 changed files with 1272 additions and 525 deletions

View File

@@ -0,0 +1,269 @@
/* eslint-disable formatjs/no-literal-string-in-jsx */
import { useState } from 'react'
import copy from 'copy-to-clipboard'
import { kebabify } from '../../generate/utils'
export type ThemeValue = Record<'resolved' | 'alias', string | number>
export type Theme = Record<string, ThemeValue>
export type ThemeOption = {
name: string
displayName: string
theme: Theme
}
export type ColorsProps = {
theme?: Theme
themes?: ThemeOption[]
defaultThemeName?: string
}
import styles from './colors.module.css'
function getContrastColor(bgColor: string) {
const r = parseInt(bgColor.substring(1, 3), 16)
const g = parseInt(bgColor.substring(3, 5), 16)
const b = parseInt(bgColor.substring(5, 7), 16)
let a = parseInt(bgColor.substring(7, 9), 16)
if (isNaN(a)) {
a = 255
}
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
if (luminance > 0.5) {
return '#000'
} else {
if (a < 255 / 2) {
return '#000'
}
return '#fff'
}
}
function copyToClipboard(
text: string,
setCopied: (key: string) => void,
clearCopied: () => void,
key: string
) {
copy(text)
setCopied(key)
setTimeout(() => {
clearCopied()
}, 2000)
}
export function Colors({
theme: propTheme,
themes,
defaultThemeName,
}: ColorsProps) {
const [selectedThemeName, setSelectedThemeName] = useState<string>(
defaultThemeName || themes?.[0]?.name || ''
)
const [copiedKey, setCopiedKey] = useState<string | null>(null)
const currentTheme =
propTheme ||
themes?.find((t) => t.name === selectedThemeName)?.theme ||
themes?.[0]?.theme
if (!currentTheme) {
return <div>No theme available</div>
}
const grouping: Record<string, Theme> = {}
for (const [k, v] of Object.entries(currentTheme)) {
if (typeof v.resolved === 'string' && v.resolved.startsWith('#')) {
const key = k.replace(/\/[^/]+$/, '')
if (!grouping[key]) {
grouping[key] = {}
}
grouping[key][k] = v
}
}
return (
<div className={styles.container}>
<div className={styles.header}>
{themes && themes.length > 0 && (
<div className={styles.themeSelector}>
<label htmlFor="theme-select" className={styles.themeLabel}>
Theme:
</label>
<select
id="theme-select"
className={styles.themeSelect}
value={selectedThemeName}
onChange={(e) => setSelectedThemeName(e.target.value)}
>
{themes.map((t) => (
<option key={t.name} value={t.name}>
{t.displayName}
</option>
))}
</select>
</div>
)}
<div className={styles.jumpTo}>
<label htmlFor="group-select" className={styles.groupLabel}>
Jump to:
</label>
<select
id="group-select"
className={styles.groupSelect}
onChange={(e) => {
const el = document.getElementById(e.target.value)
el?.scrollIntoView({
behavior: 'smooth',
block: 'start',
})
}}
>
<option value="">- Select a grouping -</option>
{Object.keys(grouping)
.sort((a, b) => a.localeCompare(b))
.map((title) => {
return (
<option key={title} value={kebabify(title)}>
{title}
</option>
)
})}
</select>
</div>
<div className={styles.tip}>
<span className={styles.tipIcon}>💡</span>
Click any value to copy to clipboard
</div>
</div>
<div className={styles.groups}>
{Object.entries(grouping)
.sort((a, b) => {
return a[0].localeCompare(b[0])
})
.map(([title, values]) => {
return (
<div className={styles.group} key={title}>
<h2 id={kebabify(title)} className={styles.title}>
{title}
</h2>
<div className={styles.values}>
{Object.entries(values).map(([k, v]) => {
const tokenKey = `${title}-${k}`
return (
<div className={styles.value} key={k}>
<div className={styles.colorCard}>
<div
className={styles.colorSwatch}
style={{
color: getContrastColor(v.resolved.toString()),
backgroundColor: v.resolved.toString(),
}}
onClick={() => {
copyToClipboard(
`var(--${kebabify(k)})`,
setCopiedKey,
() => setCopiedKey(null),
tokenKey
)
}}
title="Click to copy CSS variable"
>
<div className={styles.colorValue}>
{v.resolved.toString()}
</div>
</div>
<div className={styles.tokenInfo}>
<div className={styles.tokenRow}>
<span className={styles.tokenLabel}>Figma:</span>
<code
className={styles.tokenCode}
onClick={() => {
copyToClipboard(
k,
setCopiedKey,
() => setCopiedKey(null),
tokenKey
)
}}
title="Click to copy"
>
{k}
</code>
</div>
<div className={styles.tokenRow}>
<span className={styles.tokenLabel}>CSS:</span>
<code
className={styles.tokenCode}
onClick={() => {
copyToClipboard(
kebabify(k),
setCopiedKey,
() => setCopiedKey(null),
tokenKey
)
}}
title="Click to copy"
>
{kebabify(k)}
</code>
</div>
{v.alias ? (
<div className={styles.tokenRow}>
<span className={styles.tokenLabel}>
Alias:
</span>
<code
className={styles.tokenCode}
onClick={() => {
copyToClipboard(
v.alias.toString(),
setCopiedKey,
() => setCopiedKey(null),
tokenKey
)
}}
title="Click to copy"
>
{v.alias}
</code>
</div>
) : null}
<div className={styles.tokenRow}>
<span className={styles.tokenLabel}>Value:</span>
<code
className={styles.tokenCode}
onClick={() => {
copyToClipboard(
v.resolved.toString(),
setCopiedKey,
() => setCopiedKey(null),
tokenKey
)
}}
title="Click to copy"
>
{v.resolved}
</code>
</div>
</div>
</div>
</div>
)
})}
</div>
</div>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,11 @@
import { Meta } from '@storybook/addon-docs/blocks'
import { CornerRadius } from './CornerRadius'
import { base } from '../../lib/tokens'
<Meta title="Tokens/Corner Radius" />
# Corner Radius
<CornerRadius theme={base} />

View File

@@ -0,0 +1,99 @@
/* eslint-disable formatjs/no-literal-string-in-jsx */
import copy from 'copy-to-clipboard'
import { kebabify } from '../../generate/utils'
import tableStyles from './tokens.module.css'
type ThemeValue = {
resolved: string | number
alias?: string | number
}
type Theme = Record<string, ThemeValue>
type CornerRadiusProps = {
theme: Theme
}
function copyToClipboard(text: string) {
copy(text)
}
export function CornerRadius({ theme }: CornerRadiusProps) {
// Filter corner radius tokens
const cornerRadiusTokens: Theme = {}
for (const [k, v] of Object.entries(theme)) {
if (k.startsWith('Corner radius/')) {
cornerRadiusTokens[k] = v as ThemeValue
}
}
// Sort by value
const sortedTokens = Object.entries(cornerRadiusTokens).sort((a, b) => {
const aValue = typeof a[1].resolved === 'number' ? a[1].resolved : 0
const bValue = typeof b[1].resolved === 'number' ? b[1].resolved : 0
return aValue - bValue
})
return (
<div>
<div className={tableStyles.tableContainer}>
<table className={tableStyles.table}>
<thead className={tableStyles.tableHeader}>
<tr>
<th className={tableStyles.tableHeaderCell}>Token</th>
<th className={tableStyles.tableHeaderCell}>Pixels</th>
<th className={tableStyles.tableHeaderCell}>
Visual representation
</th>
</tr>
</thead>
<tbody>
{sortedTokens.map(([k, v]) => {
const value = typeof v.resolved === 'number' ? v.resolved : 0
const valuePx = `${value}px`
return (
<tr key={k} className={tableStyles.tableRow}>
<td className={tableStyles.tableCell}>
<code
className={tableStyles.tokenName}
onClick={() => {
copyToClipboard(`var(--${kebabify(k)})`)
}}
title="Click to copy CSS variable"
>
{kebabify(k)}
</code>
</td>
<td className={tableStyles.tableCell}>
<code
className={tableStyles.value}
onClick={() => {
copyToClipboard(valuePx)
}}
title="Click to copy"
>
{valuePx}
</code>
</td>
<td className={tableStyles.tableCell}>
<div
className={tableStyles.borderRadiusPreview}
style={{
borderRadius: `${value}px`,
}}
>
{valuePx}
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -0,0 +1,11 @@
import { Meta } from '@storybook/addon-docs/blocks'
import { Shadow } from './Shadow'
import { base } from '../../lib/tokens'
<Meta title="Tokens/Shadow" />
# Shadow
<Shadow theme={base} />

View File

@@ -0,0 +1,106 @@
/* eslint-disable formatjs/no-literal-string-in-jsx */
import copy from 'copy-to-clipboard'
import { kebabify } from '../../generate/utils'
import tableStyles from './tokens.module.css'
type ThemeValue = {
resolved: string | number
alias?: string | number
}
type Theme = Record<string, ThemeValue>
type ShadowProps = {
theme: Theme
}
function copyToClipboard(text: string) {
copy(text)
}
export function Shadow({ theme }: ShadowProps) {
// Filter shadow tokens
const shadowTokens: Theme = {}
for (const [k, v] of Object.entries(theme)) {
if (k.startsWith('BoxShadow-')) {
shadowTokens[k] = v as ThemeValue
}
}
// Sort by level
const sortedTokens = Object.entries(shadowTokens).sort((a, b) => {
const aLevel = parseInt(a[0].match(/\d+/)?.[0] || '0')
const bLevel = parseInt(b[0].match(/\d+/)?.[0] || '0')
return aLevel - bLevel
})
return (
<div>
<div className={tableStyles.tableContainer}>
<table className={tableStyles.table}>
<thead className={tableStyles.tableHeader}>
<tr>
<th className={tableStyles.tableHeaderCell}>Token</th>
<th className={tableStyles.tableHeaderCell}>Level</th>
<th className={tableStyles.tableHeaderCell}>Value</th>
<th className={tableStyles.tableHeaderCell}>
Visual representation
</th>
</tr>
</thead>
<tbody>
{sortedTokens.map(([k, v]) => {
const shadowValue =
typeof v.resolved === 'string' ? v.resolved : ''
const level = k.match(/\d+/)?.[0] || '0'
return (
<tr key={k} className={tableStyles.tableRow}>
<td className={tableStyles.tableCell}>
<code
className={tableStyles.tokenName}
onClick={() => {
copyToClipboard(`var(--${kebabify(k)})`)
}}
title="Click to copy CSS variable"
>
{kebabify(k)}
</code>
</td>
<td className={tableStyles.tableCell}>
<span className={tableStyles.value}>Level {level}</span>
</td>
<td className={tableStyles.tableCell}>
<code
className={tableStyles.value}
onClick={() => {
copyToClipboard(shadowValue)
}}
title="Click to copy"
style={{
fontSize: '0.75rem',
wordBreak: 'break-all',
}}
>
{shadowValue}
</code>
</td>
<td className={tableStyles.tableCell}>
<div
className={tableStyles.shadowPreview}
style={{
boxShadow: shadowValue,
}}
/>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -0,0 +1,11 @@
import { Meta } from '@storybook/addon-docs/blocks'
import { Spacing } from './Spacing'
import { base } from '../../lib/tokens'
<Meta title="Tokens/Spacing" />
# Spacing
<Spacing theme={base} />

View File

@@ -0,0 +1,128 @@
/* eslint-disable formatjs/no-literal-string-in-jsx */
import copy from 'copy-to-clipboard'
import { kebabify } from '../../generate/utils'
import tableStyles from './tokens.module.css'
type ThemeValue = {
resolved: string | number
alias?: string | number
}
type Theme = Record<string, ThemeValue>
type SpacingProps = {
theme: Theme
}
function copyToClipboard(text: string) {
copy(text)
}
// Extract base unit multiplier from token name
function getBaseUnitMultiplier(tokenName: string): string {
// Token names are like "Space/x0", "Space/x025", "Space/x05", "Space/x1", "Space/x15", etc.
const match = tokenName.match(/Space\/x(\d+)/)
if (!match) return '0x'
const num = match[1]
// Handle special cases
if (num === '0') return '0x'
if (num === '025') return '0.25x'
if (num === '05') return '0.5x'
if (num === '15') return '1.5x'
// For other numbers, they're already the multiplier (x1 = 1x, x2 = 2x, etc.)
return `${num}x`
}
export function Spacing({ theme }: SpacingProps) {
// Filter spacing tokens
const spacingTokens: Theme = {}
for (const [k, v] of Object.entries(theme)) {
if (k.startsWith('Space/')) {
spacingTokens[k] = v as ThemeValue
}
}
// Sort by value
const sortedTokens = Object.entries(spacingTokens).sort((a, b) => {
const aValue = typeof a[1].resolved === 'number' ? a[1].resolved : 0
const bValue = typeof b[1].resolved === 'number' ? b[1].resolved : 0
return aValue - bValue
})
return (
<div>
<div className={tableStyles.tableContainer}>
<table className={tableStyles.table}>
<thead className={tableStyles.tableHeader}>
<tr>
<th className={tableStyles.tableHeaderCell}>Token</th>
<th className={tableStyles.tableHeaderCell}>
Base unit multiplier
</th>
<th className={tableStyles.tableHeaderCell}>Pixels</th>
<th className={tableStyles.tableHeaderCell}>
Visual representation
</th>
</tr>
</thead>
<tbody>
{sortedTokens.map(([k, v]) => {
const value = typeof v.resolved === 'number' ? v.resolved : 0
const valuePx = `${value}px`
const multiplier = getBaseUnitMultiplier(k)
return (
<tr key={k} className={tableStyles.tableRow}>
<td className={tableStyles.tableCell}>
<code
className={tableStyles.tokenName}
onClick={() => {
copyToClipboard(`var(--${kebabify(k)})`)
}}
title="Click to copy CSS variable"
>
{kebabify(k)}
</code>
</td>
<td className={tableStyles.tableCell}>
<span className={tableStyles.value}>{multiplier}</span>
</td>
<td className={tableStyles.tableCell}>
<code
className={tableStyles.value}
onClick={() => {
copyToClipboard(valuePx)
}}
title="Click to copy"
>
{valuePx}
</code>
</td>
<td className={tableStyles.tableCell}>
<div className={tableStyles.visualBarContainer}>
<span className={tableStyles.visualBarLabel}>
{valuePx}
</span>
<div
className={tableStyles.visualBar}
style={{
width: `${value}px`,
minWidth: value > 0 ? '2px' : '0',
}}
/>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -0,0 +1,11 @@
import { Meta } from '@storybook/addon-docs/blocks'
import { Colors } from './Colors'
import { base } from '../../lib/tokens'
<Meta title="Tokens/Colors/Base" />
# Colors: Base
<Colors theme={base} />

View File

@@ -0,0 +1,274 @@
.container {
display: flex;
flex-direction: column;
gap: 2rem;
padding: 1rem 0;
}
.header {
position: sticky;
top: 0;
background: #fff;
padding: 1.25rem 1.5rem;
z-index: 100;
border: 1px solid #e5e7eb;
border-radius: 12px;
box-shadow:
0 1px 3px 0 rgba(0, 0, 0, 0.1),
0 1px 2px 0 rgba(0, 0, 0, 0.06);
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
align-items: center;
backdrop-filter: blur(8px);
background-color: rgba(255, 255, 255, 0.95);
}
.themeSelector {
display: flex;
align-items: center;
gap: 0.75rem;
}
.themeLabel,
.groupLabel {
font-weight: 600;
font-size: 0.875rem;
color: #374151;
white-space: nowrap;
}
.themeSelect,
.groupSelect {
padding: 0.5rem 0.75rem;
border-radius: 8px;
border: 1px solid #d1d5db;
font-size: 0.875rem;
background: #fff;
color: #111827;
cursor: pointer;
transition: all 0.2s ease;
min-width: 200px;
}
.themeSelect:hover,
.groupSelect:hover {
border-color: #9ca3af;
}
.themeSelect:focus,
.groupSelect:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.jumpTo {
display: flex;
align-items: center;
gap: 0.75rem;
}
.tip {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem !important;
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
padding: 0.625rem 1rem;
border-radius: 8px;
border: 1px solid #fbbf24;
color: #92400e;
font-weight: 500;
margin-left: auto;
}
.tipIcon {
font-size: 1rem;
}
.groups {
display: flex;
flex-direction: column;
gap: 3rem;
}
.title {
font-weight: 700;
font-size: 1.5rem;
color: #111827;
margin: 0 0 1.5rem 0;
padding-top: 80px;
scroll-margin-top: 100px;
}
.values {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 1.5rem;
}
.value {
width: 100%;
}
.colorCard {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 12px;
overflow: hidden;
transition: all 0.2s ease;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
}
.colorCard:hover {
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
.colorSwatch {
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-height: 120px;
width: 100%;
cursor: pointer;
transition: all 0.2s ease;
background-image:
linear-gradient(
45deg,
rgba(0, 0, 0, var(--opacity)) 25%,
transparent 25%,
transparent 75%,
rgba(0, 0, 0, var(--opacity)) 75%,
rgba(0, 0, 0, var(--opacity)) 0
),
linear-gradient(
45deg,
rgba(0, 0, 0, var(--opacity)) 25%,
transparent 25%,
transparent 75%,
rgba(0, 0, 0, var(--opacity)) 75%,
rgba(0, 0, 0, var(--opacity)) 0
);
background-position:
0px 0,
8px 8px;
background-size:
16px 16px,
16px 16px;
}
.colorSwatch:hover {
filter: brightness(0.95);
}
.colorSwatch:active {
transform: scale(0.98);
}
.colorValue {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 0.875rem;
font-weight: 600;
padding: 0.5rem 1rem;
background: rgba(0, 0, 0, 0.1);
border-radius: 6px;
backdrop-filter: blur(4px);
letter-spacing: 0.025em;
}
.tokenInfo {
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.tokenRow {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
}
.tokenLabel {
font-weight: 600;
font-size: 12px;
color: #6b7280;
min-width: 60px;
flex-shrink: 0;
}
.tokenCode {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 12px;
background: #f9fafb;
padding: 0.375rem 0.625rem;
border-radius: 6px;
color: #111827;
cursor: pointer;
transition: all 0.15s ease;
flex: 1;
word-break: break-all;
border: 1px solid transparent;
display: inline-block;
}
.tokenCode:hover {
background: #f3f4f6;
border-color: #d1d5db;
transform: translateX(2px);
}
.tokenCode.tokenValue {
font-weight: 600;
font-size: 0.875rem;
}
@keyframes copiedPulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.02);
}
100% {
transform: scale(1);
}
}
/* Responsive adjustments */
@media (max-width: 768px) {
.header {
flex-direction: column;
align-items: stretch;
}
.themeSelector,
.jumpTo {
width: 100%;
}
.themeSelect,
.groupSelect {
width: 100%;
}
.tip {
margin-left: 0;
width: 100%;
justify-content: center;
}
.values {
grid-template-columns: 1fr;
}
.title {
padding-top: 20px;
scroll-margin-top: 20px;
}
}

View File

@@ -0,0 +1,11 @@
import { Meta } from '@storybook/addon-docs/blocks'
import { Colors } from '../Colors'
import { downtownCamper } from '../../../lib/tokens'
<Meta title="Tokens/Colors/Downtown Camper" />
# Colors: Downtown Camper
<Colors theme={downtownCamper} />

View File

@@ -0,0 +1,11 @@
import { Meta } from '@storybook/addon-docs/blocks'
import { Colors } from '../Colors'
import { grandHotel } from '../../../lib/tokens'
<Meta title="Tokens/Colors/Grand Hotel" />
# Colors: Grand Hotel
<Colors theme={grandHotel} />

View File

@@ -0,0 +1,11 @@
import { Meta } from '@storybook/addon-docs/blocks'
import { Colors } from '../Colors'
import { haymarket } from '../../../lib/tokens'
<Meta title="Tokens/Colors/Haymarket" />
# Colors: Haymarket
<Colors theme={haymarket} />

View File

@@ -0,0 +1,11 @@
import { Meta } from '@storybook/addon-docs/blocks'
import { Colors } from '../Colors'
import { hotelNorge } from '../../../lib/tokens'
<Meta title="Tokens/Colors/Hotel Norge" />
# Colors: Hotel Norge
<Colors theme={hotelNorge} />

View File

@@ -0,0 +1,40 @@
import { Meta } from '@storybook/addon-docs/blocks'
import { Colors } from '../Colors'
import {
base,
scandic,
scandicGo,
downtownCamper,
haymarket,
marski,
hotelNorge,
grandHotel,
theDock,
} from '../../../lib/tokens'
<Meta title="Tokens/Colors" />
# Colors
Select a theme to view all available color tokens. Click on any value to copy it to your clipboard.
<Colors
themes={[
{ name: 'base', displayName: 'Base', theme: base },
{ name: 'scandic', displayName: 'Scandic', theme: scandic },
{ name: 'scandicGo', displayName: 'Scandic Go', theme: scandicGo },
{
name: 'downtownCamper',
displayName: 'Downtown Camper',
theme: downtownCamper,
},
{ name: 'haymarket', displayName: 'Haymarket', theme: haymarket },
{ name: 'marski', displayName: 'Marski', theme: marski },
{ name: 'hotelNorge', displayName: 'Hotel Norge', theme: hotelNorge },
{ name: 'grandHotel', displayName: 'Grand Hotel', theme: grandHotel },
{ name: 'theDock', displayName: 'The Dock', theme: theDock },
]}
defaultThemeName="scandic"
/>

View File

@@ -0,0 +1,11 @@
import { Meta } from '@storybook/addon-docs/blocks'
import { Colors } from '../Colors'
import { marski } from '../../../lib/tokens'
<Meta title="Tokens/Colors/Marski" />
# Colors: Marski
<Colors theme={marski} />

View File

@@ -0,0 +1,11 @@
import { Meta } from '@storybook/addon-docs/blocks'
import { Colors } from '../Colors'
import { scandic } from '../../../lib/tokens'
<Meta title="Tokens/Colors/Scandic" />
# Colors: Scandic
<Colors theme={scandic} />

View File

@@ -0,0 +1,11 @@
import { Meta } from '@storybook/addon-docs/blocks'
import { Colors } from '../Colors'
import { scandicGo } from '../../../lib/tokens'
<Meta title="Tokens/Colors/Scandic Go" />
# Colors: Scandic Go
<Colors theme={scandicGo} />

View File

@@ -0,0 +1,11 @@
import { Meta } from '@storybook/addon-docs/blocks'
import { Colors } from '../Colors'
import { theDock } from '../../../lib/tokens'
<Meta title="Tokens/Colors/The Dock" />
# Colors: The Dock
<Colors theme={theDock} />

View File

@@ -0,0 +1,43 @@
import { Meta } from '@storybook/addon-docs/blocks'
<Meta title="Introduction" />
# Scandic Hotels Design System ✨
The Scandic Hotels Design System is a collection of components, patterns, and utilities that are used to build the Scandic Hotels websites and apps.
## Storybook structure
- Tokens
- Core Components
- Product Components
- Patterns
- Compositions
## File structure
```
[Component]
├── [component].module.css # The CSS for the component
├── [Component].stories.tsx # Storybook stories for the component
├── [Component].tsx # The main component file
├── index.tsx # Entrypoint for the component exports
├── types.ts # TypeScript typings for the component
└── variants.ts # Class Variance Authority configuration for variants of the component
```
## Components
Each component of the design system is defined in `lib/components`.
Each component has an `index.tsx` file that exports the component and its optional subcomponents. Subcomponents are components that are meant to be used together/nested with the component.
The components that are considered public API from a consumer standpoint **must** have Storybook stories that showcases and documents their use. It should at least contain one default story that showcases the component by itself in its default state. More stories are added to showcase other variants or usages of the component.
The typings for each components live in their respective `types.ts` file inside the component folder.
## Styling
Styling is done with CSS modules.
Variants are implemented with [Class Variance Authority](https://cva.style/).

View File

@@ -0,0 +1,141 @@
.tableContainer {
width: 100%;
overflow-x: auto;
margin-top: 1.5rem;
}
.table {
width: 100%;
border-collapse: collapse;
background: #fff;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
}
.tableHeader {
background: #f9fafb;
border-bottom: 2px solid #e5e7eb;
}
.tableHeaderCell {
padding: 0.75rem 1rem;
text-align: left;
font-weight: 600;
font-size: 0.875rem;
color: #374151;
border-right: 1px solid #e5e7eb;
}
.tableHeaderCell:last-child {
border-right: none;
}
.tableRow {
border-bottom: 1px solid #e5e7eb;
transition: background-color 0.15s ease;
}
.tableRow:hover {
background-color: #f9fafb;
}
.tableRow:last-child {
border-bottom: none;
}
.tableCell {
padding: 0.75rem 1rem;
font-size: 0.875rem;
color: #111827;
border-right: 1px solid #e5e7eb;
vertical-align: middle;
}
.tableCell:last-child {
border-right: none;
}
.tokenName {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
background: #f3f4f6;
padding: 0.25rem 0.5rem;
border-radius: 4px;
display: inline-block;
cursor: pointer;
transition: all 0.15s ease;
border: 1px solid transparent;
}
.tokenName:hover {
background: #e5e7eb;
border-color: #d1d5db;
}
.value {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
color: #6b7280;
cursor: pointer;
transition: color 0.15s ease;
}
.value:hover {
color: #111827;
}
.visualBar {
height: 24px;
background: #3b82f6;
border-radius: 4px;
min-width: 2px;
transition: all 0.15s ease;
}
.visualBarContainer {
display: flex;
align-items: center;
gap: 0.75rem;
}
.visualBarLabel {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 0.75rem;
color: #6b7280;
min-width: 40px;
text-align: right;
}
.shadowPreview {
width: 60px;
height: 60px;
background: #fff;
border-radius: 8px;
border: 1px solid #e5e7eb;
display: flex;
align-items: center;
justify-content: center;
}
.borderRadiusPreview {
width: 60px;
height: 60px;
background: #3b82f6;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 0.75rem;
font-weight: 600;
}
@media (max-width: 768px) {
.tableContainer {
overflow-x: scroll;
}
.tableHeaderCell,
.tableCell {
padding: 0.5rem 0.75rem;
font-size: 0.8125rem;
}
}