Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SegmentedControl improvements: keyboard interactions, composition, icon #149

Merged
merged 13 commits into from
Feb 17, 2025
19 changes: 13 additions & 6 deletions src/components/actions/Button/Button.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
display: inline-flex;
flex-flow: row wrap;
align-items: center;
gap: 0.3ch;
gap: bk.$spacing-1;

@include bk.font(bk.$font-family-display, bk.$font-weight-semibold);
text-transform: uppercase;
Expand Down Expand Up @@ -176,15 +176,22 @@
}
}
}
&:not(:disabled, .bk-button--disabled, .bk-button--nonactive):active {
scale: 0.98;
}

@media (prefers-reduced-motion: no-preference) {
transition: none 150ms ease-in-out;
transition-property: border, background, color;
transition:
border 150ms ease-in-out,
background-color 150ms ease-in-out,
color 150ms ease-in-out,
scale 100ms ease-in;
}

/* & > :global(.icon) { font-size: 1.2rem; } */
& > :global(.icon) { font-size: 1em; }

/* https://css-tricks.com/css-cascade-layers/#aa-reverting-important-layers */
/* &:is(:global(.unstyled), #specificity#hack) { all: revert-layer; } */
// Experiment: implementing `unstyled` using `revert-layer` rather than by removing the class names
// https://css-tricks.com/css-cascade-layers/#aa-reverting-important-layers
// &:is(:global(.unstyled), #specificity#hack) { all: revert-layer; }
}
}
8 changes: 8 additions & 0 deletions src/components/actions/Button/Button.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,14 @@ export const VariantCard: Story = {
),
};

export const ButtonWithIcon: Story = {
...PrimaryStory,
args: {
label: 'I have an icon',
icon: 'check',
},
};

export const AsyncButton: Story = {
...PrimaryStory,
args: {
Expand Down
6 changes: 6 additions & 0 deletions src/components/actions/Button/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { timeout } from '../../../util/time.ts';
import { classNames as cx, type ComponentProps } from '../../../util/componentUtil.ts';
import * as React from 'react';

import { type IconName, Icon } from '../../graphics/Icon/Icon.tsx';
import { Spinner } from '../../graphics/Spinner/Spinner.tsx';

import cl from './Button.module.scss';
Expand All @@ -29,6 +30,9 @@ export type ButtonProps = React.PropsWithChildren<Omit<ComponentProps<'button'>,
*/
label?: undefined | string,

/** An icon to show before the label. Optional. */
icon?: undefined | IconName,

/** The kind of button, from higher prominance to lower. */
kind?: undefined | 'primary' | 'secondary' | 'tertiary',

Expand Down Expand Up @@ -63,6 +67,7 @@ export const Button = (props: ButtonProps) => {
unstyled = false,
trimmed = false,
label,
icon,
kind = 'tertiary',
variant = 'normal',
disabled = false,
Expand Down Expand Up @@ -117,6 +122,7 @@ export const Button = (props: ButtonProps) => {
return (
<>
{isPending && <Spinner className="icon" inline/>}
{icon && <Icon className="icon" icon={icon}/>}
{label}
</>
);
Expand Down
8 changes: 8 additions & 0 deletions src/components/actions/IconButton/IconButton.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,13 @@
&.inline {
display: inline-flex;
}

&:not(:disabled):active {
scale: 0.95;
}

@media (prefers-reduced-motion: no-preference) {
transition: scale 100ms ease-in;
}
}
}
3 changes: 2 additions & 1 deletion src/components/actions/Link/Link.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export default {
args: {
unstyled: false,
label: 'Link',
onClick: event => { event.preventDefault(); },
},
render: (args) => <Link {...args}/>,
} satisfies Meta<LinkArgs>;
Expand All @@ -48,7 +49,7 @@ export const Scroll: Story = {
<>
<DummyLink id="anchor">Anchor</DummyLink>
<OverflowTester openDefault/>
<Link {...args} href="#anchor"/>
<Link {...args} href="#anchor" onClick={undefined}/>
<OverflowTester openDefault/>
</>
),
Expand Down
3 changes: 2 additions & 1 deletion src/components/actions/LinkAsButton/LinkAsButton.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import * as React from 'react';
import type { Meta, StoryObj } from '@storybook/react';

import { LinkAsButton } from './LinkAsButton.tsx';
import { OverflowTester } from '../../../util/storybook/OverflowTester.tsx';


type LinkAsButtonArgs = React.ComponentProps<typeof LinkAsButton>;
Expand All @@ -26,6 +25,7 @@ export default {
label: 'Link',
href: 'https://fortanix.com',
target: '_blank',
onClick: event => { event.preventDefault(); },
},
render: (args) => <LinkAsButton {...args}/>,
} satisfies Meta<LinkAsButtonArgs>;
Expand Down Expand Up @@ -66,5 +66,6 @@ export const Download: Story = {
download: 'my_file.txt',
label: 'Download',
href: `data:text/plain,Lorem ipsum`,
onClick: undefined,
},
};
9 changes: 5 additions & 4 deletions src/components/containers/Banner/Banner.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -163,10 +163,11 @@ export const BannerWithThemedContent: Story = {
</p>
<div style={{ display: 'flex', gap: '2ch', marginTop: '1lh' }}>
<Button nonactive kind="primary" onPress={() => { notify.info('Clicked'); }}>Button</Button>
<SegmentedControl
options={['Test 1', 'Test 2']}
defaultValue="Test 1"
/>
<SegmentedControl size="small" defaultButtonKey="test-1" aria-label="Test segmented control">
<SegmentedControl.Button buttonKey="test-1" label="Test 1"/>
<SegmentedControl.Button buttonKey="test-2" label="Test 2"/>
<SegmentedControl.Button buttonKey="test-3" label="Test 3"/>
</SegmentedControl>
</div>
</article>
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,74 +8,103 @@
.bk-segmented-control {
@include bk.component-base(bk-segmented-control);

--bk-segmented-control-background-color: #{bk.$theme-segmented-control-background-default};
--bk-segmented-control-border-color: #{bk.$theme-segmented-control-border-unselected};
--bk-segmented-control-text-color: #{bk.$theme-segmented-control-text-unselected};

//overflow: hidden; // Note: cannot hide overflow, would truncate focus outlines

border: bk.rem-from-px(1) solid var(--bk-segmented-control-border-color);
border-radius: bk.$radius-1;
background: var(--bk-segmented-control-border-color);

display: flex;
flex-direction: row;
gap: bk.rem-from-px(1);

.bk-segmented-control__toggle {
--bk-segmented-control-toggle-background-color: #{bk.$theme-segmented-control-background-default};
--bk-segmented-control-toggle-border-color: #{bk.$theme-segmented-control-border-unselected};
--bk-segmented-control-toggle-text-color: #{bk.$theme-segmented-control-text-unselected};
// Set this to allow wrapping:
//flex-wrap: wrap;
// Note: may want to implement https://heydonworks.com/article/the-flexbox-holy-albatross-reincarnated

.bk-segmented-control__button {
// For the inner radius, subtract the border width
--inner-radius: #{calc(bk.$radius-1 - bk.rem-from-px(1))};

flex-grow: 1; // Fill full width in case `flex-wrap` is enabled

user-select: none;

padding: bk.$spacing-1 bk.$spacing-3;
background-color: var(--bk-segmented-control-toggle-background-color);
border: 1px solid var(--bk-segmented-control-toggle-border-color);
border-inline-start: 0;
background-color: var(--bk-segmented-control-background-color);

color: var(--bk-segmented-control-toggle-text-color);
font-weight: bk.$font-weight-semibold;
color: var(--bk-segmented-control-text-color);
@include bk.font($family: bk.$font-family-body, $weight: bk.$font-weight-semibold, $size: bk.$font-size-xs);
text-transform: uppercase;
}

.bk-segmented-control__item {
&:first-child {
.bk-segmented-control__toggle {
border-inline-start: 1px solid var(--bk-segmented-control-toggle-border-color);
border-radius: 2px 0 0 2px;
}
&:first-of-type {
border-start-start-radius: var(--inner-radius);
border-end-start-radius: var(--inner-radius);
}
&:last-of-type {
border-start-end-radius: var(--inner-radius);
border-end-end-radius: var(--inner-radius);
}

&:last-child {
.bk-segmented-control__toggle {
border-radius: 0 2px 2px 0;
}
display: flex;
flex-direction: row;
column-gap: bk.$spacing-1;
justify-items: center;
min-height: 1.5lh; // For case where there are icons only

> :global(.icon) {
font-size: 1em;
}
}

/* States */
&:not(.bk-segmented-control--disabled) {
.bk-segmented-control__toggle {
&[aria-checked="true"] {
--bk-segmented-control-toggle-background-color: #{bk.$theme-segmented-control-background-selected};
//--bk-segmented-control-toggle-border-color: #{bk.$theme-segmented-control-border-selected};
--bk-segmented-control-toggle-text-color: #{bk.$theme-segmented-control-text-default};
}

// Selected button
&[aria-checked="true"] {
--bk-segmented-control-background-color: #{bk.$theme-segmented-control-background-selected};
--bk-segmented-control-text-color: #{bk.$theme-segmented-control-text-default};
}

// State: hover (only for non-selected buttons)
&:is(:hover, :global(.pseudo-hover)):not([aria-checked="true"]) {
--bk-segmented-control-background-color: #{bk.$theme-segmented-control-background-hover};
}

@include bk.focus-outset;
&:is(:focus-visible, :global(.pseudo-focus-visible)) {
isolation: isolate; // Have the focused toggle render over the other toggles so that the focus is fully shown
border-radius: bk.$radius-1;
}

// State: active
&:active {
filter: brightness(90%);
}
@media (prefers-reduced-motion: no-preference) {
transition: filter 100ms ease-out;
}

// State: disabled (single button)
&.bk-segmented-control__button:disabled {
--bk-segmented-control-background-color: #{bk.$theme-segmented-control-background-disabled-2};
--bk-segmented-control-text-color: #{bk.$theme-segmented-control-text-unselected-2};

&:not(:is([aria-checked="true"])):is(:hover, :global(.pseudo-hover)) {
--bk-segmented-control-toggle-background-color: #{bk.$theme-segmented-control-background-hover};
}
cursor: not-allowed;

--bk-focus-outline-color: #{bk.$theme-segmented-control-border-focus};
@include bk.focus-outset;
&:is(:focus-visible, :global(.pseudo-focus-visible)) {
position: relative; // Have the focused toggle render over the other toggles so that the focus is fully shown
border-inline-start: 1px solid var(--bk-segmented-control-toggle-border-color);
border-radius: bk.$radius-2;
&[aria-checked="true"] {
--bk-segmented-control-background-color: #{bk.$theme-segmented-control-background-disabled};
}
}
}

// State: disabled (entire control)
&.bk-segmented-control--disabled {
.bk-segmented-control__toggle {
--bk-segmented-control-toggle-background-color: #{bk.$theme-segmented-control-background-disabled-2};
--bk-segmented-control-toggle-border-color: #{bk.$theme-segmented-control-border-disabled};
--bk-segmented-control-toggle-text-color: #{bk.$theme-segmented-control-text-unselected-2};
cursor: not-allowed;

&[aria-checked="true"] {
--bk-segmented-control-toggle-background-color: #{bk.$theme-segmented-control-background-disabled};
}
}
--bk-segmented-control-background-color: #{bk.$theme-segmented-control-background-disabled-2};
--bk-segmented-control-border-color: #{bk.$theme-segmented-control-border-disabled};
--bk-segmented-control-text-color: #{bk.$theme-segmented-control-text-unselected-2};

cursor: not-allowed;
}
}
}
Loading