Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .github/workflows/deploy-storybook.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
name: Deploy Storybook to GitHub Pages

on:
push:
branches:
- main

permissions:
contents: read
pages: write
id-token: write

concurrency:
group: pages
cancel-in-progress: true

jobs:
deploy:
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm

- name: Install dependencies
run: npm ci

- name: Build Storybook
run: npm run build-storybook

- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: storybook-static

- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
61 changes: 59 additions & 2 deletions .storybook/preview.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,53 @@ import { CoreUiThemeProvider } from '../src/lib/next';
import { coreUIAvailableThemes } from '../src/lib/style/theme';
import { Wrapper } from '../stories/common';
import { ToastProvider } from '../src/lib';
import { DocsContainer } from '@storybook/blocks';
import { addons } from '@storybook/preview-api';
import { create } from '@storybook/theming';

const darkStorybookTheme = create({
base: 'dark',
fontBase: '"Open Sans", sans-serif',
fontCode: 'monospace',
brandTitle: 'Core UI',
brandUrl: './',
brandImage: './logo-core-ui.png',
brandTarget: '_self',
});

const lightStorybookTheme = create({
base: 'light',
fontBase: '"Open Sans", sans-serif',
fontCode: 'monospace',
brandTitle: 'Core UI',
brandUrl: './',
brandImage: './logo-core-ui.png',
brandTarget: '_self',
});

const LIGHT_THEMES = new Set(['artescaLight']);

let _currentThemeName = 'darkRebrand';

const ThemedDocsContainer = ({ context, children }) => {
const [themeName, setThemeName] = React.useState(() => _currentThemeName);

React.useEffect(() => {
const channel = addons.getChannel();
const handler = ({ globals }) => {
if (globals.theme) setThemeName(globals.theme);
};
channel.on('GLOBALS_UPDATED', handler);
return () => channel.off('GLOBALS_UPDATED', handler);
}, []);

const sbTheme = LIGHT_THEMES.has(themeName) ? lightStorybookTheme : darkStorybookTheme;
return (
<DocsContainer context={context} theme={sbTheme}>
{children}
</DocsContainer>
);
};

export const globalTypes = {
theme: {
Expand Down Expand Up @@ -38,6 +85,7 @@ export const globalTypes = {
},
};
const withThemeProvider = (Story, context) => {
_currentThemeName = context.globals.theme ?? 'darkRebrand';
const theme = coreUIAvailableThemes[context.globals.theme];
const { background } = context.globals;
const { viewMode } = context;
Expand All @@ -47,7 +95,7 @@ const withThemeProvider = (Story, context) => {
{/* Wrapper to make the stories take the full screen but not in docs */}
<div style={viewMode === 'story' ? { height: 100 + 'vh' } : null}>
<ToastProvider>
<Wrapper style={{ backgroundColor: background }}>
<Wrapper style={{ backgroundColor: background, ...(context.parameters.fullPage ? { padding: 0 } : {}) }}>
<Story {...context} />
</Wrapper>
</ToastProvider>
Expand All @@ -62,7 +110,7 @@ export const decorators = [withThemeProvider];
export const parameters = {
layout: 'fullscreen',
docs: {
toc: { headingSelector: 'h2,h3', title: 'Table of Contents' },
container: ThemedDocsContainer,
},
controls: {
//All props with color in name will automatically have a control 'color'
Expand All @@ -84,11 +132,20 @@ export const parameters = {
'Style',
'Guidelines',
'Templates',
[
'Maestro Deployments', ['Guideline', 'Default', 'Stories', '*'],
],
'Components',
[
'Navigation',
'Data Display',
'Inputs',
[
['Checkbox', ['Guideline', '*']],
['Toggle', ['Guideline', '*']],
['Radio', ['Guideline', '*']],
['Select', ['Guideline', '*']],
],
['Feedback', [['Modal', ['Guideline', '*']]]],
'Progress & loading',
'Styling',
Expand Down
122 changes: 122 additions & 0 deletions src/lib/components/radio/Radio.component.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { ChangeEvent, InputHTMLAttributes, forwardRef } from 'react';
import styled from 'styled-components';
import { spacing, Stack } from '../../spacing';
import { Text } from '../text/Text.component';
import { FocusVisibleStyle } from '../buttonv2/Buttonv2.component';

const getDotSvgUrl = (color: string) => {
const encodedColor = color.replace('#', '%23');

Check failure

Code scanning / CodeQL

Incomplete string escaping or encoding High

This replaces only the first occurrence of '#'.

Copilot Autofix

AI 11 days ago

In general, to fix this kind of problem you should avoid ad‑hoc string replacement for escaping and instead either (1) use a standard encoder such as encodeURIComponent on the interpolated value, or (2) if you must use replace, use a global regular expression so that all occurrences are handled (/pattern/g), and ensure you also escape backslashes and any other relevant meta‑characters for the target context.

For this specific case, the safest and simplest fix without changing visible functionality is to ensure that every # in color is percent‑encoded before inserting it into the SVG data URL. The minimal change is to replace color.replace('#', '%23') with color.replace(/#/g, '%23'), which will encode all # characters while preserving the overall behavior for standard hex colors. An even more robust alternative would be const encodedColor = encodeURIComponent(color);, but that would also encode characters like (, ), and ,, which might alter behavior if the function is (now or in the future) passed CSS color formats that include those characters; so the least‑intrusive, targeted fix is to just make the existing replacement global.

All changes are confined to src/lib/components/radio/Radio.component.tsx, within the getDotSvgUrl helper. No new imports or additional helper methods are required.

Suggested changeset 1
src/lib/components/radio/Radio.component.tsx

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/lib/components/radio/Radio.component.tsx b/src/lib/components/radio/Radio.component.tsx
--- a/src/lib/components/radio/Radio.component.tsx
+++ b/src/lib/components/radio/Radio.component.tsx
@@ -5,7 +5,7 @@
 import { FocusVisibleStyle } from '../buttonv2/Buttonv2.component';
 
 const getDotSvgUrl = (color: string) => {
-  const encodedColor = color.replace('#', '%23');
+  const encodedColor = color.replace(/#/g, '%23');
   return `url('data:image/svg+xml,%3Csvg width="13" height="13" viewBox="0 0 13 13" fill="none" xmlns="http://www.w3.org/2000/svg"%3E%3Ccircle cx="6.5" cy="6.5" r="3" fill="${encodedColor}"/%3E%3C/svg%3E')`;
 };
 
EOF
@@ -5,7 +5,7 @@
import { FocusVisibleStyle } from '../buttonv2/Buttonv2.component';

const getDotSvgUrl = (color: string) => {
const encodedColor = color.replace('#', '%23');
const encodedColor = color.replace(/#/g, '%23');
return `url('data:image/svg+xml,%3Csvg width="13" height="13" viewBox="0 0 13 13" fill="none" xmlns="http://www.w3.org/2000/svg"%3E%3Ccircle cx="6.5" cy="6.5" r="3" fill="${encodedColor}"/%3E%3C/svg%3E')`;
};

Copilot is powered by AI and may make mistakes. Always verify output.
return `url('data:image/svg+xml,%3Csvg width="13" height="13" viewBox="0 0 13 13" fill="none" xmlns="http://www.w3.org/2000/svg"%3E%3Ccircle cx="6.5" cy="6.5" r="3" fill="${encodedColor}"/%3E%3C/svg%3E')`;
};

export type Props = {
label?: string;
name: string;
value: string;
checked?: boolean;
disabled?: boolean;
onChange?: (e: ChangeEvent<HTMLInputElement>) => void;
} & Omit<InputHTMLAttributes<HTMLInputElement>, 'type'>;

const Radio = forwardRef<HTMLInputElement, Props>(
({ disabled, checked, label, name, value, onChange, ...rest }, ref) => {
return (
<StyledRadio checked={checked} disabled={disabled}>
<Stack gap="r12" style={{ alignItems: 'baseline' }}>
<RadioInput
type="radio"
name={name}
value={value}
checked={checked}
disabled={disabled}
onChange={onChange}
ref={ref}
{...rest}
/>
{label && <Text>{label}</Text>}
</Stack>
</StyledRadio>
);
},
);

export { Radio };

const RadioInput = styled.input`
align-self: center;
`;

const StyledRadio = styled.label<{
disabled?: boolean;
checked?: boolean;
}>`
display: inline-flex;
${(props) => (props.disabled ? 'opacity: 0.5;' : '')}

[type='radio'] {
width: 1.125rem;
height: 1.125rem;
color: ${(props) => props.theme.textPrimary};
vertical-align: middle;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
background: none;
border: 0;
outline: 0;
flex-grow: 0;
border-radius: 50%;
background-color: ${(props) => props.theme.backgroundLevel1};
transition: background 300ms;
cursor: pointer;
}

[type='radio']::before {
content: '';
color: transparent;
display: block;
width: inherit;
height: inherit;
border-radius: 50%;
border: 0;
background-color: transparent;
background-size: contain;
box-shadow: inset 0 0 0 ${spacing.r1} ${(props) => props.theme.textSecondary};
}

[type='radio']:checked {
background-color: ${(props) => props.theme.selectedActive};
}

[type='radio']:checked::before {
box-shadow: none;
background-image: ${(props) => getDotSvgUrl(props.theme.textPrimary)};
background-repeat: no-repeat;
background-position: center;
}

[type='radio']:hover {
${(props) =>
!props.disabled && `background-color: ${props.theme.highlight};`}
}

[type='radio']:hover::before {
${(props) =>
!props.disabled &&
`box-shadow: inset 0 0 0 ${spacing.r1} ${props.theme.selectedActive};`}
}

[type='radio']:focus-visible:enabled {
${FocusVisibleStyle}
}

[type='radio']:checked:disabled {
cursor: not-allowed;
background-color: ${(props) => props.theme.selectedActive};
}

[type='radio']:not(:checked):disabled {
cursor: not-allowed;
background-color: ${(props) => props.theme.textSecondary};
}
`;
67 changes: 39 additions & 28 deletions stories/Checkbox/checkbox.guideline.mdx
Original file line number Diff line number Diff line change
@@ -1,55 +1,66 @@
import {
Meta,
Story,
Canvas,
Primary,
Controls,
Unstyled,
Source,
} from '@storybook/blocks';
import { Checkbox } from '../../src/lib/components/checkbox/Checkbox.component';

import * as CheckboxStories from './checkbox.stories';

<Meta of={CheckboxStories} name="Guideline" />

<style>{`
.sbdocs-content h2 { margin-top: 5rem; }
.sbdocs-content h3 { margin-top: 3rem; }
.sbdocs-content .sb-story { margin-bottom: 0.5rem; }
.sbdocs-content .sbdocs-preview { margin-bottom: 1.5rem; }
`}</style>

# Checkbox

Checkboxes are used to select one or more options in a list.
Checkboxes let users enable an option or select one or more items from a list.

For guidance on when to use Checkbox vs Toggle vs Radio vs Select, see [Guidelines/Selection Controls](?path=/docs/guidelines-selection-controls--stories).

## Patterns

### Enable / disable an option

The primary use case. The label sits on the left and describes what enabling the option does. The checkbox sits on the right.

<Canvas of={CheckboxStories.OptionCheckbox} layout="fullscreen" sourceState="none" />

Label rules:
- Use a verb phrase: "Enable compression", "Allow public access", "Send notifications"
- The label describes the active (checked) state
- Avoid nouns alone: "Compression" or "Notifications" are ambiguous

### Multi-select in tables

## Usage
The primary multi-select use case is row selection in a table. This is handled by `Table.MultiSelectableContent`, which manages the checkboxes internally — the Checkbox component is not used directly here.

Unlike the radio element it is possible to select more than one option. \
A tick/check indicates that the element is selected. \
<Canvas of={CheckboxStories.IndeterminateUseCase} layout="fullscreen" sourceState="none" />

<Canvas of={CheckboxStories.ChoiceCheckbox} layout="fullscreen" />
The header checkbox controls the full selection: checking it selects all rows, unchecking it deselects all. When some rows are selected, the header checkbox enters the indeterminate state.

It is possible to use the checkbox to enable or disable an option:
## States

<Canvas of={CheckboxStories.OptionCheckbox} layout="fullscreen" />
<Canvas of={CheckboxStories.AllStates} layout="fullscreen" sourceState="none" />

## State Variations
All five visual states from left to right: unchecked, checked, indeterminate, disabled, disabled checked.

### Indeterminate State
### Indeterminate

Apart from checked and unchecked, checkboxes can be in a third state : indeterminate. \
When a checkbox has sub-options checkboxes, this state indicates that some of the sub-options are checked. \
Clicking on the main checkbox select or unselect all the sub-options boxes.
The indeterminate state indicates a partial selection: some children are checked, but not all. It appears on the header checkbox in a table when some rows are selected. It is never used as an initial or standalone state.

<Canvas of={CheckboxStories.IndeterminateUseCase} layout="fullscreen" />
<Canvas of={CheckboxStories.IndeterminateCheckbox} layout="fullscreen" sourceState="none" />

### Disabled state
### Disabled

Checkboxes can be disabled, making it impossible to change the box state. \
A not-allowed cursor inform the user about the unavaibility of the action.
A disabled checkbox prevents the user from changing its state. Use when the option is unavailable due to permissions, dependencies, or system state. A not-allowed cursor informs the user that the action is unavailable.

<Canvas
of={CheckboxStories.DisabledCheckboxes}
<Canvas of={CheckboxStories.DisabledCheckboxes} layout="fullscreen" sourceState="none" />

layout="fullscreen"
/>
When the reason for the disabled state is not obvious from context, add a tooltip to explain it.

### Playground
<Canvas of={CheckboxStories.DisabledWithReason} layout="fullscreen" sourceState="none" />

<Canvas of={CheckboxStories.Playground} layout="fullscreen" />
<Controls of={CheckboxStories.Playground} />
Loading
Loading