Skip to content

Commit bbf99c8

Browse files
authored
Merge pull request #28 from fortanix/feature/tag
Tag component + InputField story using it
2 parents 5580c76 + 97d4ac6 commit bbf99c8

File tree

12 files changed

+388
-16
lines changed

12 files changed

+388
-16
lines changed

.storybook/preview.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ const preview = {
4141
'Icon',
4242
'Spinner',
4343
],
44+
'text',
45+
[
46+
'Tag',
47+
],
4448
'containers',
4549
[
4650
'Panel',

src/components/forms/controls/Radio/Radio.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export type RadioProps = ComponentProps<'input'> & {
1515
unstyled?: undefined | boolean,
1616
};
1717
/**
18-
* A simple Radio control, just the &lt;input type="radio"&gt; and nothing else..
18+
* A simple Radio control, just the &lt;input type="radio"&gt; and nothing else.
1919
*/
2020
export const Radio = (props: RadioProps) => {
2121
const {

src/components/forms/fields/InputField/InputField.module.scss

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,16 @@
77
@layer baklava.components {
88
.bk-input-field {
99
@include bk.component-base(bk-input-field);
10-
10+
1111
display: flex;
1212
flex-direction: column;
1313
gap: 6px;
14-
14+
1515
.bk-input-field__label {
1616
@include bk.font(bk.$font-family-body, bk.$font-weight-semibold);
1717
cursor: default;
1818
}
19-
19+
2020
.bk-input-field__control {
2121
--empty: ; // Prevent empty class from being removed
2222
}

src/components/forms/fields/InputField/InputField.stories.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,7 @@ export default {
3434
render: (args) => <InputField {...args}/>,
3535
} satisfies Meta<InputArgs>;
3636

37-
38-
export const Standard: Story = {
39-
};
37+
export const Standard: Story = {};
4038

4139
export const InvalidInput: Story = {
4240
args: {
@@ -52,6 +50,7 @@ export const InvalidInput: Story = {
5250
await userEvent.type(input, 'invalid');
5351
await delay(100);
5452
await userEvent.keyboard('{Enter}');
53+
// biome-ignore lint/style/noNonNullAssertion: we know there is a form on this story
5554
await fireEvent.submit(input.closest('form')!);
56-
},
55+
},
5756
};

src/components/forms/fields/InputField/InputField.tsx

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,26 +4,26 @@
44

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

98
import { useFormContext } from '../../context/Form/Form.tsx';
109
import { Input } from '../../controls/Input/Input.tsx';
10+
import { Tag } from '../../../text/Tag/Tag.tsx';
1111

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

1414

1515
export { cl as InputFieldClassNames };
1616

17-
export type InputFieldProps = ComponentProps<'input'> & {
17+
export type InputFieldProps = Omit<ComponentProps<'input'>, 'value'> & {
1818
/** Whether this component should be unstyled. */
1919
unstyled?: undefined | boolean,
20-
20+
2121
/** Label for the input. */
2222
label?: undefined | React.ReactNode,
23-
23+
2424
/** Props for the `<label>` element, if `label` is defined. */
2525
labelProps?: undefined | ComponentProps<'label'>,
26-
26+
2727
/** Props for the wrapper element. */
2828
wrapperProps?: undefined | ComponentProps<'div'>,
2929
};
@@ -38,11 +38,10 @@ export const InputField = (props: InputFieldProps) => {
3838
wrapperProps = {},
3939
...inputProps
4040
} = props;
41-
41+
4242
const controlId = React.useId();
4343
const formContext = useFormContext();
44-
//const formStatus = useFormStatus();
45-
44+
4645
return (
4746
<div
4847
{...wrapperProps}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/* Copyright (c) Fortanix, Inc.
2+
|* This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of
3+
|* the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
@use '../../../../styling/defs.scss' as bk;
6+
7+
@layer baklava.components {
8+
.bk-input-field-with-tags {
9+
@include bk.component-base(input-field-with-tags);
10+
11+
display: flex;
12+
flex-direction: column;
13+
gap: 6px;
14+
padding-bottom: 1px;
15+
border-bottom: 1px solid bk.$theme-form-rule-default;
16+
17+
.bk-input-field-with-tags__label {
18+
@include bk.font(bk.$font-family-body, bk.$font-weight-semibold);
19+
cursor: default;
20+
}
21+
22+
.bk-input-field-with-tags__control {
23+
--empty: ; // Prevent empty class from being removed
24+
}
25+
26+
&:focus-within {
27+
border-bottom-color: bk.$theme-form-rule-focused;
28+
29+
input {
30+
outline: none !important;
31+
}
32+
}
33+
}
34+
35+
.bk-input-field-with-tags__container {
36+
display: flex;
37+
flex-flow: row wrap;
38+
gap: bk.$spacing-2;
39+
}
40+
41+
.bk-input-field-with-tags__input-container {
42+
flex-grow: 1;
43+
44+
input {
45+
width: 100%;
46+
}
47+
}
48+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/* Copyright (c) Fortanix, Inc.
2+
|* This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of
3+
|* the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
import type { Meta, StoryObj } from '@storybook/react';
6+
import * as React from 'react';
7+
8+
import { Form } from '../../context/Form/Form.tsx';
9+
import { Card } from '../../../containers/Card/Card.tsx';
10+
11+
import { InputFieldWithTags } from './InputFieldWithTags.tsx';
12+
13+
14+
type InputArgs = React.ComponentProps<typeof InputFieldWithTags>;
15+
type Story = StoryObj<InputArgs>;
16+
17+
export default {
18+
component: InputFieldWithTags,
19+
parameters: {
20+
layout: 'centered',
21+
design: {
22+
type: 'figma',
23+
url: 'https://www.figma.com/design/ymWCnsGfIsC2zCz17Ur11Z/Design-System-UX?node-id=3606-101183&node-type=instance&m=dev',
24+
}
25+
},
26+
tags: ['autodocs'],
27+
argTypes: {
28+
},
29+
args: {
30+
label: 'Test',
31+
placeholder: 'Example',
32+
},
33+
decorators: [
34+
Story => <Form><Story/></Form>,
35+
],
36+
} satisfies Meta<InputArgs>;
37+
38+
export const InputWithTags: Story = {
39+
name: 'Input with tags (enter creates new tag, backspace erases tags)',
40+
render: () => {
41+
const [tags, setTags] = React.useState<Array<string>>(['Tag Title', 'Tag Title 2']);
42+
const [inputText, setInputText] = React.useState<string>('Example');
43+
44+
const handleUpdate = (newInputText: string) => {
45+
setInputText(newInputText);
46+
};
47+
const handleUpdateTags = (newTags: string[]) => {
48+
setTags(newTags);
49+
};
50+
51+
return (
52+
<Card>
53+
<InputFieldWithTags
54+
tags={tags}
55+
value={inputText}
56+
onUpdate={handleUpdate}
57+
onUpdateTags={handleUpdateTags}
58+
placeholder=""
59+
/>
60+
</Card>
61+
);
62+
}
63+
};
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
/* Copyright (c) Fortanix, Inc.
2+
|* This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of
3+
|* the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
import { classNames as cx, type ComponentProps } from '../../../../util/componentUtil.ts';
6+
import * as React from 'react';
7+
8+
import { useFormContext } from '../../context/Form/Form.tsx';
9+
import { Input } from '../../controls/Input/Input.tsx';
10+
import { Tag } from '../../../text/Tag/Tag.tsx';
11+
12+
import cl from './InputFieldWithTags.module.scss';
13+
14+
15+
export { cl as InputFieldWithTagsClassNames };
16+
17+
export type InputFieldWithTagsProps = Omit<ComponentProps<'input'>, 'value'> & {
18+
/** Whether this component should be unstyled. */
19+
unstyled?: undefined | boolean,
20+
21+
/** Label for the input. */
22+
label?: undefined | React.ReactNode,
23+
24+
/** Props for the `<label>` element, if `label` is defined. */
25+
labelProps?: undefined | ComponentProps<'label'>,
26+
27+
/** Props for the wrapper element. */
28+
wrapperProps?: undefined | ComponentProps<'div'>,
29+
30+
/** Value of the input field */
31+
value?: undefined | string,
32+
33+
/** Tags to be displayed inside the input field */
34+
tags?: undefined | string[],
35+
36+
/** Callback to update the input value. Internally hooks to onChange */
37+
onUpdate?: undefined | ((value: string) => void),
38+
39+
/** Callback to update the tags. Internally hooks to onKeyDown */
40+
onUpdateTags?: undefined | ((tags: string[]) => void),
41+
};
42+
/**
43+
* Input field with tags. Enter creates a new tag, backspace erases last tag.
44+
*/
45+
export const InputFieldWithTags = (props: InputFieldWithTagsProps) => {
46+
const {
47+
unstyled = false,
48+
label,
49+
labelProps = {},
50+
wrapperProps = {},
51+
value = '',
52+
tags = [],
53+
onUpdate,
54+
onUpdateTags,
55+
...inputProps
56+
} = props;
57+
58+
const controlId = React.useId();
59+
const formContext = useFormContext();
60+
61+
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
62+
// first handle supplied onChange, if exists
63+
if (inputProps.onChange) {
64+
inputProps.onChange(e);
65+
}
66+
// then return value to onUpdate
67+
if (onUpdate) {
68+
onUpdate(e.target.value);
69+
}
70+
};
71+
72+
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
73+
// first handle supplied onKeyDown, if exists
74+
if (inputProps.onKeyDown) {
75+
inputProps.onKeyDown(e);
76+
}
77+
// then return value to onUpdateTags
78+
if (onUpdateTags && onUpdate) {
79+
if (e.key === 'Backspace' && value === '') {
80+
onUpdateTags(tags.slice(0,-1));
81+
}
82+
if (e.key === 'Enter' && value !== '') {
83+
onUpdateTags([...tags, value.trim()]);
84+
onUpdate('');
85+
}
86+
}
87+
};
88+
89+
const onRemoveTag = (index: number) => {
90+
if (onUpdateTags) {
91+
onUpdateTags(tags.filter((_, idx) => idx !== index));
92+
}
93+
};
94+
95+
return (
96+
<div
97+
{...wrapperProps}
98+
className={cx(
99+
'bk',
100+
{ [cl['bk-input-field-with-tags']]: !unstyled },
101+
wrapperProps.className,
102+
)}
103+
>
104+
{label &&
105+
<label
106+
htmlFor={controlId}
107+
{...labelProps}
108+
className={cx(cl['bk-input-field-with-tags__label'], labelProps.className)}
109+
>
110+
{label}
111+
</label>
112+
}
113+
<div className={cl['bk-input-field-with-tags__container']}>
114+
{tags && (
115+
// biome-ignore lint/suspicious/noArrayIndexKey: no other unique identifier available
116+
tags.map((tag, idx) => <Tag key={idx} content={tag} onRemove={() => onRemoveTag(idx)}/>)
117+
)}
118+
<div className={cl['bk-input-field-with-tags__input-container']}>
119+
<Input
120+
{...inputProps}
121+
unstyled={true}
122+
id={controlId}
123+
form={formContext.formId}
124+
className={cx(cl['bk-input-field-with-tags__control'], inputProps.className)}
125+
onChange={onChange}
126+
onKeyDown={onKeyDown}
127+
value={value}
128+
/>
129+
</div>
130+
</div>
131+
</div>
132+
);
133+
};
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/* Copyright (c) Fortanix, Inc.
2+
|* This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of
3+
|* the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
@use '../../../styling/defs.scss' as bk;
6+
7+
@layer baklava.components {
8+
.bk-tag {
9+
@include bk.component-base(bk-tag);
10+
11+
background: bk.$theme-tag-background-default;
12+
border-radius: bk.$size-2;
13+
color: bk.$theme-tag-text-default;
14+
display: flex;
15+
align-items: center;
16+
font-size: bk.$font-size-xs;
17+
padding: bk.$size-2 bk.$spacing-2 bk.$size-3;
18+
19+
&.bk-tag--with-close-button {
20+
padding-right: 0;
21+
}
22+
23+
.bk-tag__icon {
24+
--icon-size: 7px;
25+
26+
width: var(--icon-size);
27+
height: var(--icon-size);
28+
color: bk.$theme-tag-icon-default;
29+
cursor: pointer;
30+
padding: bk.$size-2 bk.$spacing-2 bk.$size-2 bk.$spacing-2;
31+
}
32+
}
33+
}

0 commit comments

Comments
 (0)