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

Tag component + InputField story using it #28

Merged
merged 21 commits into from
Nov 21, 2024
Merged
Show file tree
Hide file tree
Changes from 14 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
4 changes: 4 additions & 0 deletions .storybook/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ const preview = {
'Icon',
'Spinner',
],
'text',
[
'Tag',
],
'containers',
[
'Panel',
Expand Down
2 changes: 1 addition & 1 deletion src/components/forms/controls/Radio/Radio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export type RadioProps = ComponentProps<'input'> & {
unstyled?: undefined | boolean,
};
/**
* A simple Radio control, just the &lt;input type="radio"&gt; and nothing else..
* A simple Radio control, just the &lt;input type="radio"&gt; and nothing else.
*/
export const Radio = (props: RadioProps) => {
const {
Expand Down
24 changes: 21 additions & 3 deletions src/components/forms/fields/InputField/InputField.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,36 @@
@layer baklava.components {
.bk-input-field {
@include bk.component-base(bk-input-field);

display: flex;
flex-direction: column;
gap: 6px;

.bk-input-field__label {
@include bk.font(bk.$font-family-body, bk.$font-weight-semibold);
cursor: default;
}

.bk-input-field__control {
--empty: ; // Prevent empty class from being removed
}
}

.bk-input-field__container {
display: flex;
flex-direction: row;
gap: bk.$spacing-2;
}

.bk-input-field--with-tags {
border-bottom: 1px solid bk.$theme-form-rule-default;

&:focus-within {
border-bottom-color: bk.$theme-form-rule-focused;

input {
outline: none !important;
}
}
}
}
32 changes: 28 additions & 4 deletions src/components/forms/fields/InputField/InputField.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,7 @@ export default {
render: (args) => <InputField {...args}/>,
} satisfies Meta<InputArgs>;


export const Standard: Story = {
};
export const Standard: Story = {};

export const InvalidInput: Story = {
args: {
Expand All @@ -52,6 +50,32 @@ export const InvalidInput: Story = {
await userEvent.type(input, 'invalid');
await delay(100);
await userEvent.keyboard('{Enter}');
// biome-ignore lint/style/noNonNullAssertion: we know there is a form on this story
await fireEvent.submit(input.closest('form')!);
},
},
};

export const InputWithTags: Story = {
name: 'Input with tags (enter creates new tag, backspace erases tags)',
render: () => {
const [tags, setTags] = React.useState<Array<string>>(['Tag Title', 'Tag Title 2']);
const [inputText, setInputText] = React.useState<string>('Example');

const handleUpdate = (newInputText: string) => {
setInputText(newInputText);
};
const handleUpdateTags = (newTags: string[]) => {
setTags(newTags);
};

return (
<InputField
tags={tags}
value={inputText}
onUpdate={handleUpdate}
onUpdateTags={handleUpdateTags}
placeholder=""
/>
);
}
};
86 changes: 73 additions & 13 deletions src/components/forms/fields/InputField/InputField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@

import { classNames as cx, type ComponentProps } from '../../../../util/componentUtil.ts';
import * as React from 'react';
import { useFormStatus } from 'react-dom';

import { useFormContext } from '../../context/Form/Form.tsx';
import { Input } from '../../controls/Input/Input.tsx';
import { Tag } from '../../../text/Tag/Tag.tsx';

import cl from './InputField.module.scss';

Expand All @@ -17,15 +17,24 @@ export { cl as InputFieldClassNames };
export type InputFieldProps = ComponentProps<'input'> & {
/** Whether this component should be unstyled. */
unstyled?: undefined | boolean,

/** Label for the input. */
label?: undefined | React.ReactNode,

/** Props for the `<label>` element, if `label` is defined. */
labelProps?: undefined | ComponentProps<'label'>,

/** Props for the wrapper element. */
wrapperProps?: undefined | ComponentProps<'div'>,

/** Tags to be displayed inside the input field */
tags?: undefined | string[],

/** Callback to update the input value. Internally hooks to onChange */
onUpdate?: undefined | ((arg0: string) => void),

/** Callback to update the tags. Internally hooks to onKeyUp */
onUpdateTags?: undefined | ((arg0: string[]) => void),
nighto marked this conversation as resolved.
Show resolved Hide resolved
};
/**
* Input field.
Expand All @@ -36,19 +45,62 @@ export const InputField = (props: InputFieldProps) => {
label,
labelProps = {},
wrapperProps = {},
tags = [],
onUpdate = null,
onUpdateTags = null,
nighto marked this conversation as resolved.
Show resolved Hide resolved
...inputProps
} = props;

const controlId = React.useId();
const formContext = useFormContext();
//const formStatus = useFormStatus();


const injectedInputProps = {
...inputProps,
unstyled: tags && tags.length > 0,
};

const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
// first handle supplied onChange, if exists
if (inputProps.onChange) {
inputProps.onChange(e);
}
// then return value to onUpdate
if (onUpdate) {
onUpdate(e.target.value);
}
};

const onKeyUp = (e: React.KeyboardEvent<HTMLInputElement>) => {
// first handle supplied onKeyUp, if exists
if (inputProps.onKeyUp) {
inputProps.onKeyUp(e);
}
// then return value to onUpdateTags
if (onUpdateTags && onUpdate) {
const { value } = inputProps;
if (e.key === 'Backspace' && value === '') {
onUpdateTags(tags.slice(0,-1));
}
if (e.key === 'Enter' && value !== '') {
nighto marked this conversation as resolved.
Show resolved Hide resolved
onUpdateTags([...tags as string[], value as string]);
nighto marked this conversation as resolved.
Show resolved Hide resolved
onUpdate('');
}
}
};

const onRemoveTag = (index: number) => {
if (onUpdateTags) {
onUpdateTags(tags.filter((_, idx) => idx !== index));
}
};

return (
<div
{...wrapperProps}
className={cx(
'bk',
{ [cl['bk-input-field']]: !unstyled },
{ [cl['bk-input-field--with-tags']]: tags && tags.length > 0 },
wrapperProps.className,
)}
>
Expand All @@ -61,12 +113,20 @@ export const InputField = (props: InputFieldProps) => {
{label}
</label>
}
<Input
{...inputProps}
id={controlId}
form={formContext.formId}
className={cx(cl['bk-input-field__control'], inputProps.className)}
/>
<div className={cl['bk-input-field__container']}>
{tags && (
// biome-ignore lint/suspicious/noArrayIndexKey: no other unique identifier available
tags.map((tag, idx) => <Tag key={idx} content={tag} onRemove={() => onRemoveTag(idx)}/>)
)}
<Input
{...injectedInputProps}
id={controlId}
form={formContext.formId}
className={cx(cl['bk-input-field__control'], inputProps.className)}
onChange={onChange}
onKeyUp={onKeyUp}
/>
</div>
</div>
);
};
33 changes: 33 additions & 0 deletions src/components/text/Tag/Tag.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/* Copyright (c) Fortanix, Inc.
|* This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of
|* the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. */

@use '../../../styling/defs.scss' as bk;

@layer baklava.components {
.bk-tag {
@include bk.component-base(bk-tag);

background: bk.$theme-tag-background-default;
border-radius: bk.$size-2;
color: bk.$theme-tag-text-default;
display: flex;
align-items: center;
font-size: bk.$font-size-xs;
padding: bk.$size-2 bk.$spacing-2 bk.$size-3;

&.bk-tag--with-close-button {
padding-right: 0;
}

.bk-tag__icon {
--icon-size: 7px;

width: var(--icon-size);
height: var(--icon-size);
color: bk.$theme-tag-icon-default;
cursor: pointer;
padding: bk.$size-2 bk.$spacing-2 bk.$size-2 bk.$spacing-2;
}
}
}
38 changes: 38 additions & 0 deletions src/components/text/Tag/Tag.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/* Copyright (c) Fortanix, Inc.
|* This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of
|* the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import type { Meta, StoryObj } from '@storybook/react';

import * as React from 'react';

import { Tag } from './Tag.tsx';


type TagArgs = React.ComponentProps<typeof Tag>;
type Story = StoryObj<TagArgs>;

export default {
component: Tag,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
argTypes: {
},
args: {
content: 'Tag Title',
},
render: (args) => <Tag {...args}/>,
} satisfies Meta<TagArgs>;


export const TagStory: Story = {
name: 'Tag',
};

export const TagWithCloseButton: Story = {
args: {
onRemove: () => console.log('clicked on close button'),
},
};
53 changes: 53 additions & 0 deletions src/components/text/Tag/Tag.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/* Copyright (c) Fortanix, Inc.
|* This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of
|* the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import { classNames as cx, type ComponentProps } from '../../../util/componentUtil.ts';
import * as React from 'react';

import { Icon } from '../../graphics/Icon/Icon.tsx';

import cl from './Tag.module.scss';


export { cl as TagClassNames };

export type TagProps = ComponentProps<'div'> & {
/** Whether this component should be unstyled. */
unstyled?: undefined | boolean,

/** Some content to be displayed inside the tag. */
content: React.ReactNode,

/** Callback to remove the tag. If set, display a close icon, otherwise it is hidden. */
onRemove?: () => void,
};

/**
* A tag component.
*/
export const Tag = (props: TagProps) => {
const {
unstyled = false,
content = null,
onRemove,
...propsRest
} = props;

return (
<div
{...propsRest}
className={cx(
'bk',
{ [cl['bk-tag']]: !unstyled },
{ [cl['bk-tag--with-close-button']]: !!onRemove },
propsRest.className,
)}
>
{content}
{onRemove && (
<Icon icon="cross" className={cl['bk-tag__icon']} onClick={onRemove}/>
)}
</div>
);
};
2 changes: 2 additions & 0 deletions src/styling/variables.scss
Original file line number Diff line number Diff line change
Expand Up @@ -158,8 +158,10 @@ $spacing-16: math.div(192, 14) * 1rem !default; // ~192px
$spacing-17: math.div(224, 14) * 1rem !default; // ~224px
$spacing-18: math.div(256, 14) * 1rem !default; // ~256px

// these sizes do not match Figma variables
$size-1: math.div(1, 14) * 1rem !default; // ~1px
$size-2: math.div(2, 14) * 1rem !default; // ~2px
$size-3: math.div(3, 14) * 1rem !default; // ~3px


//
Expand Down