Repository: speee/jsx-slack
Pull request #61 (2019-09-28 14:41:58)

Add <Input> layout block and block element
This PR has implemented a part of #57.

jsx-slack will provide `<Input>` component for 2 usages. A one is for layout block as same as an original spec in Slack API, and the other is focusing to the compatibility with HTML as like as existing `<Input>` component for Dialog support.

## `<Input>` layout block

`<Input>` layout block should contain one of available elements such as `<Select>`.

```jsx
<Modal title="My App">
  <Input label="User" hint="Please select one of users." required>
    <UsersSelect placeholder="Choose user..." />
  </Input>
</Modal>
```

## `<Input>` block element

For compatibility of HTML, we adopted to use `<Input>` component as an element instead of providing `<PlainTextInput>` element component.

```jsx
<Modal title="My App">
  <Input label="Title" name="title" maxLength={80} required />
</Modal>
```

At the moment, `multiline` prop is not implemented in `<Input>`. It would be provided in another component like `<Textarea>`.


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9eb33f3..5e45441 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,7 @@
 
 - [Multi-select menus](https://api.slack.com/reference/block-kit/block-elements#multi_select) ([#56](https://github.com/speee/jsx-slack/issues/56), [#58](https://github.com/speee/jsx-slack/pull/58))
 - `<Modal>` container component ([#60](https://github.com/speee/jsx-slack/pull/60))
+- `<Input>` component for layout block and block element ([#61](https://github.com/speee/jsx-slack/pull/61))
 
 ### Changed
 
diff --git a/demo/schema.js b/demo/schema.js
index 5bc5ee6..1a662b7 100644
--- a/demo/schema.js
+++ b/demo/schema.js
@@ -60,7 +60,7 @@ const schema = {
     ],
   },
 
-  // Block Kit component
+  // Block Kit components
   Section: {
     attrs: blockCommonAttrs,
     children: [
@@ -102,7 +102,7 @@ const schema = {
     children: [],
   },
 
-  // Interactive components
+  // Block elements
   Button: {
     attrs: {
       value: null,
diff --git a/docs/jsx-components-for-block-kit.md b/docs/jsx-components-for-block-kit.md
index 25153cc..2d75146 100644
--- a/docs/jsx-components-for-block-kit.md
+++ b/docs/jsx-components-for-block-kit.md
@@ -55,6 +55,8 @@ api.views.open({
 - `callbackId` (optional): An identifier for this modal to recognize it in various events. (255 characters maximum)
 - `externalId` (optional): A unique ID for all views on a per-team basis.
 
+> :information_source: Slack requires the submit text when modal has component for inputs, so jsx-slack would set the text "Submit" as the default value of `submit` prop if you are setting no text together with using input components.
+
 ## Layout blocks
 
 ### [`<Section>`: Section Block](https://api.slack.com/reference/messaging/blocks#section)
@@ -92,7 +94,7 @@ The content of `<Section>` may include one of an accessory component. A defined
 
 ##### Accessory components
 
-- [`<Image>` / `<img>`](#image-image-block)
+- [`<Image>`](#image-image-block)
 - [`<Button>`](#button-button-element)
 - [`<Select>`](#select-select-menu-with-static-options)
 - [`<ExternalSelect>`](#externalselect-select-menu-with-external-data-source)
@@ -171,7 +173,7 @@ Display an image block. It has well-known props like `<img>` HTML element. In fa
 
 ### [`<Actions>`: Actions Block](https://api.slack.com/reference/messaging/blocks#actions)
 
-A block to hold [interactive elements](#interactive-elements). Slack allows a maximum of 25 interactive elements in `<Actions>` (But recommends to place up to 5 elements).
+A block to hold interactive elements provided by [block elements](#block-elements). Slack allows a maximum of 25 interactive elements in `<Actions>` (But recommends to place up to 5 elements).
 
 #### Props
 
@@ -235,9 +237,42 @@ Display a remote file that was added to Slack workspace. [Learn about adding rem
 - `id` / `blockId` (optional): A string of unique identifier of block.
 - `source` (optional): Override `source` field. At the moment, you should not take care this because only the default value `remote` is available.
 
-## Interactive elements
+### [`<Input>`: Input Block](https://api.slack.com/reference/messaging/blocks#input) <a name="input-block" id="input-block">(Only for modal)</a>
+
+Display one of input elements for collecting information from users. _This block is only for `<Modal>`._
+
+If you want to use `<Input>` as layout block, you have to place the one of availables as a child.
+
+```jsx
+<Modal title="My App">
+  <Input label="User" hint="Please select one of users." required>
+    <UsersSelect placeholder="Choose user..." />
+  </Input>
+</Modal>
+```
 
-Some blocks may include the interactive component to exchange info with Slack app.
+[<img src="https://raw.githubusercontent.com/speee/jsx-slack/master/docs/preview-btn.svg?sanitize=true" width="240" />](https://api.slack.com/tools/block-kit-builder?blocks=%5B%7B%22type%22%3A%22input%22%2C%22hint%22%3A%7B%22type%22%3A%22plain_text%22%2C%22text%22%3A%22Please%20select%20one%20of%20users.%22%2C%22emoji%22%3Atrue%7D%2C%22label%22%3A%7B%22type%22%3A%22plain_text%22%2C%22text%22%3A%22User%22%2C%22emoji%22%3Atrue%7D%2C%22optional%22%3Atrue%2C%22element%22%3A%7B%22type%22%3A%22users_select%22%2C%22placeholder%22%3A%7B%22type%22%3A%22plain_text%22%2C%22text%22%3A%22Choose%20user...%22%2C%22emoji%22%3Atrue%7D%7D%7D%5D&mode=modal)
+
+#### Props (Layout block)
+
+- `label` (**required**): The label string for the element.
+- `id` / `blockId` (optional): A string of unique identifier of block.
+- `hint` (optional): Specify a helpful text appears under the element.
+- `required` (optional): A boolean prop to specify whether any value must be filled when user confirms modal. `false` by default for HTML compatibility, and _notice that it is different from Slack's default._
+
+#### Available elements as a child
+
+- [`<Select>`](#select-select-menu-with-static-options)
+- [`<ExternalSelect>`](#externalselect-select-menu-with-external-data-source)
+- [`<UsersSelect>`](#usersselect-select-menu-with-user-list)
+- [`<ConversationsSelect>`](#conversationsselect-select-menu-with-conversations-list)
+- [`<ChannelsSelect>`](#channelsselect-select-menu-with-channel-list)
+
+In jsx-slack, [`<Input>` component also can use as the block element for plain text input in modal.](#input-element)
+
+## Block elements
+
+Some blocks may include block elements. e.g. the interactive component to exchange info with Slack app.
 
 ### [`<Button>`: Button element](https://api.slack.com/reference/messaging/block-elements#button)
 
@@ -512,11 +547,39 @@ An easy way to let the user selecting any date is using `<DatePicker>` component
 - `initialDate` (optional): An initially selected date. It allows `YYYY-MM-DD` formatted string, UNIX timestamp in millisecond, and JavaScript [`Date`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date) instance.
 - `confirm` (optional): [`<Confirm>` element](#confirm-confirmation-dialog) to show confirmation dialog.
 
+### [`<Input>`: Plain-text input element](https://api.slack.com/reference/block-kit/block-elements#input) <a name="input-element" id="input-element">(Only for modal)</a>
+
+`<Input>` block element is for placing a single-line input form within `<Modal>`. It has a interface similar to `<input>` HTML element.
+
+It can place as children of `<Modal>` directly.
+
+```jsx
+<Modal title="My App">
+  <Input label="Title" name="title" maxLength={80} required />
+</Modal>
+```
+
+[<img src="https://raw.githubusercontent.com/speee/jsx-slack/master/docs/preview-btn.svg?sanitize=true" width="240" />](https://api.slack.com/tools/block-kit-builder?blocks=%5B%7B%22type%22%3A%22input%22%2C%22label%22%3A%7B%22type%22%3A%22plain_text%22%2C%22text%22%3A%22Title%22%2C%22emoji%22%3Atrue%7D%2C%22optional%22%3Afalse%2C%22element%22%3A%7B%22type%22%3A%22plain_text_input%22%2C%22action_id%22%3A%22title%22%2C%22max_length%22%3A80%7D%7D%5D&mode=modal)
+
+> Internally this is syntactic sugar for [`<Input>` block](#input-block) with a plain-text input.
+
+#### Props (Block element)
+
+- `label` (**required**): The label string for the element.
+- `id` / `blockId` (optional): A string of unique identifier for [`<Input>` block](#input-block).
+- `name` / `actionId` (optional): A string of unique identifier for the action.
+- `title`/ `hint` (optional): Specify a helpful text appears under the element.
+- `placeholder` (optional): Specify a text string appears within the content of input is empty. (150 characters maximum)
+- `required` (optional): A boolean prop to specify whether any value must be filled when user confirms modal.
+- `value` (optional): An initial value for plain-text input.
+- `maxLength` (optional): The maximum number of characters allowed for the input element. It must up to 3000 character.
+- `minLength` (optional): The minimum number of characters allowed for the input element.
+
 ## Components for [composition objects](https://api.slack.com/reference/messaging/composition-objects)
 
 ### [`<Confirm>`: Confirmation dialog](https://api.slack.com/reference/messaging/composition-objects#confirm)
 
-Define confirmation dialog. Some interactive elements can open confirmation dialog when selected, by passing `<Confirm>` to `confirm` prop.
+Define confirmation dialog. Interactive elements can open confirmation dialog when selected, by passing `<Confirm>` to `confirm` prop.
 
 ```jsx
 <Blocks>
diff --git a/src/block-kit/Actions.tsx b/src/block-kit/Actions.tsx
index f8264be..1a9b5b2 100644
--- a/src/block-kit/Actions.tsx
+++ b/src/block-kit/Actions.tsx
@@ -3,10 +3,10 @@ import { ActionsBlock } from '@slack/types'
 import { JSXSlack } from '../jsx'
 import { ArrayOutput, ObjectOutput } from '../utils'
 import { BlockComponentProps } from './Blocks'
-import { ButtonProps } from './interactive/Button'
-import { SingleSelectPropsBase } from './interactive/Select'
-import { OverflowProps } from './interactive/Overflow'
-import { DatePickerProps } from './interactive/DatePicker'
+import { ButtonProps } from './elements/Button'
+import { SingleSelectPropsBase } from './elements/Select'
+import { OverflowProps } from './elements/Overflow'
+import { DatePickerProps } from './elements/DatePicker'
 
 interface ActionsProps extends BlockComponentProps {
   children: JSXSlack.Children<
diff --git a/src/block-kit/Blocks.tsx b/src/block-kit/Blocks.tsx
index 583c459..f2bffe1 100644
--- a/src/block-kit/Blocks.tsx
+++ b/src/block-kit/Blocks.tsx
@@ -12,19 +12,35 @@ export interface BlockComponentProps {
   id?: string
 }
 
+export enum InternalBlockType {
+  modal = 'modal',
+}
+
+export const blockTypeSymbol = Symbol('jsx-slack-block-type')
+
 export const Blocks: JSXSlack.FC<BlocksProps> = props => {
+  const internalType: InternalBlockType | undefined = props[blockTypeSymbol]
+
   const normalized = wrap(props.children).map(child => {
-    if (child && isNode(child) && typeof child.type === 'string') {
-      // Aliasing intrinsic elements to Block component
-      switch (child.type) {
-        case 'hr':
-          return <Divider {...child.props}>{...child.children}</Divider>
-        case 'img':
-          return <Image {...child.props}>{...child.children}</Image>
-        case 'section':
-          return <Section {...child.props}>{...child.children}</Section>
-        default:
-          throw new Error('<Blocks> allows only including Block component.')
+    if (child && isNode(child)) {
+      if (typeof child.type === 'string') {
+        // Aliasing intrinsic elements to Block component
+        switch (child.type) {
+          case 'hr':
+            return <Divider {...child.props}>{...child.children}</Divider>
+          case 'img':
+            return <Image {...child.props}>{...child.children}</Image>
+          case 'section':
+            return <Section {...child.props}>{...child.children}</Section>
+          default:
+            throw new Error('<Blocks> allows only including Block component.')
+        }
+      } else if (child.type === JSXSlack.NodeType.object) {
+        // Check layout blocks
+        if (internalType === undefined && child.props.type === 'input')
+          throw new Error(
+            '<Input> block cannot place in <Blocks> container for messaging.'
+          )
       }
     }
     return child
@@ -32,3 +48,7 @@ export const Blocks: JSXSlack.FC<BlocksProps> = props => {
 
   return <ArrayOutput>{normalized}</ArrayOutput>
 }
+
+export const BlocksInternal: JSXSlack.FC<
+  BlocksProps & { [blockTypeSymbol]?: InternalBlockType }
+> = props => Blocks(props)
diff --git a/src/block-kit/Input.tsx b/src/block-kit/Input.tsx
new file mode 100644
index 0000000..119f688
--- /dev/null
+++ b/src/block-kit/Input.tsx
@@ -0,0 +1,73 @@
+/** @jsx JSXSlack.h */
+import { InputBlock } from '@slack/types'
+import { JSXSlack } from '../jsx'
+import { ObjectOutput, coerceToInteger } from '../utils'
+import { BlockComponentProps } from './Blocks'
+import { plainText } from './composition/utils'
+import { PlainTextInput } from './elements/PlainTextInput'
+
+interface InputCommonProps extends BlockComponentProps {
+  hint?: string
+  label: string
+  required?: boolean
+}
+
+interface InputBlockProps extends InputCommonProps {
+  children: JSXSlack.Node<{}>
+
+  // Disallow defining attributes for component usage
+  title?: undefined
+  actionId?: undefined
+  name?: undefined
+  placeholder?: undefined
+  value?: undefined
+  maxLength?: undefined
+  minLength?: undefined
+}
+
+interface InputComponentProps extends InputCommonProps {
+  children?: undefined
+
+  title?: string // => InputBlockProps.hint (Alias)
+  actionId?: string // => PlainTextInput.actionId
+  name?: string // => PlainTextInput.actionId (Alias)
+  placeholder?: string // => PlainTextInput.placeholder
+  value?: string // => PlainTextInput.initialValue
+  maxLength?: number // => PlainTextInput.maxLength
+  minLength?: number // => PlainTextInput.minLength
+}
+
+type InputProps = InputBlockProps | InputComponentProps
+
+const InputComponent: JSXSlack.FC<InputComponentProps> = props => (
+  <Input
+    blockId={props.blockId}
+    hint={props.hint || props.title}
+    id={props.id}
+    label={props.label}
+    required={props.required}
+  >
+    <PlainTextInput
+      actionId={props.actionId || props.name}
+      initialValue={props.value}
+      maxLength={coerceToInteger(props.maxLength)}
+      minLength={coerceToInteger(props.minLength)}
+      placeholder={props.placeholder}
+    />
+  </Input>
+)
+
+export const Input: JSXSlack.FC<InputProps> = props => {
+  if (props.children === undefined) return InputComponent(props)
+
+  return (
+    <ObjectOutput<InputBlock>
+      type="input"
+      block_id={props.id || props.blockId}
+      hint={props.hint ? plainText(props.hint) : undefined}
+      label={plainText(props.label)}
+      optional={!props.required}
+      element={JSXSlack(props.children)}
+    />
+  )
+}
diff --git a/src/block-kit/Modal.tsx b/src/block-kit/Modal.tsx
index 9620f81..413bc9c 100644
--- a/src/block-kit/Modal.tsx
+++ b/src/block-kit/Modal.tsx
@@ -2,7 +2,12 @@
 import { View } from '@slack/types'
 import { JSXSlack } from '../jsx'
 import { ObjectOutput } from '../utils'
-import { Blocks, BlockComponentProps } from './Blocks'
+import {
+  BlocksInternal,
+  BlockComponentProps,
+  blockTypeSymbol,
+  InternalBlockType,
+} from './Blocks'
 import { plainText } from './composition/utils'
 
 // TODO: Use original View type when supported fields for API on @slack/types
@@ -23,21 +28,37 @@ export interface ModalProps {
   title: string
 }
 
-export const Modal: JSXSlack.FC<ModalProps> = props => (
-  <ObjectOutput<ViewForAPI>
-    blocks={JSXSlack(<Blocks children={props.children} />)}
-    callback_id={props.callbackId}
-    clear_on_close={
-      props.clearOnClose !== undefined ? props.clearOnClose : undefined
-    }
-    close={props.close ? plainText(props.close) : undefined}
-    external_id={props.externalId}
-    notify_on_close={
-      props.notifyOnClose !== undefined ? props.notifyOnClose : undefined
-    }
-    private_metadata={props.privateMetadata}
-    submit={props.submit ? plainText(props.submit) : undefined}
-    title={plainText(props.title)}
-    type="modal"
-  />
-)
+const submitText = plainText('Submit')
+
+export const Modal: JSXSlack.FC<ModalProps> = props => {
+  const blocks = JSXSlack(
+    <BlocksInternal
+      {...{ [blockTypeSymbol]: InternalBlockType.modal }}
+      children={props.children}
+    />
+  )
+
+  // "submit" field is required when using input block
+  const defaultSubmit = blocks.some(b => b.type === 'input')
+    ? submitText
+    : undefined
+
+  return (
+    <ObjectOutput<ViewForAPI>
+      type="modal"
+      title={plainText(props.title)}
+      callback_id={props.callbackId}
+      external_id={props.externalId}
+      submit={props.submit ? plainText(props.submit) : defaultSubmit}
+      close={props.close ? plainText(props.close) : undefined}
+      private_metadata={props.privateMetadata}
+      clear_on_close={
+        props.clearOnClose !== undefined ? props.clearOnClose : undefined
+      }
+      notify_on_close={
+        props.notifyOnClose !== undefined ? props.notifyOnClose : undefined
+      }
+      blocks={blocks}
+    />
+  )
+}
diff --git a/src/block-kit/composition/utils.ts b/src/block-kit/composition/utils.ts
index 27ea6b4..6a3b471 100644
--- a/src/block-kit/composition/utils.ts
+++ b/src/block-kit/composition/utils.ts
@@ -1,9 +1,12 @@
 import { MrkdwnElement, PlainTextElement } from '@slack/types'
 
-export const plainText = (text: string): PlainTextElement => ({
+export const plainText = (
+  text: string,
+  opts: { emoji?: boolean } = { emoji: true }
+): PlainTextElement => ({
   type: 'plain_text',
   text,
-  emoji: true, // TODO: Controlable emoji
+  emoji: !!opts.emoji,
 })
 
 export const mrkdwn = (mrkdwnText: string): MrkdwnElement => ({
diff --git a/src/block-kit/interactive/Button.tsx b/src/block-kit/elements/Button.tsx
similarity index 100%
rename from src/block-kit/interactive/Button.tsx
rename to src/block-kit/elements/Button.tsx
diff --git a/src/block-kit/interactive/DatePicker.tsx b/src/block-kit/elements/DatePicker.tsx
similarity index 100%
rename from src/block-kit/interactive/DatePicker.tsx
rename to src/block-kit/elements/DatePicker.tsx
diff --git a/src/block-kit/interactive/Overflow.tsx b/src/block-kit/elements/Overflow.tsx
similarity index 100%
rename from src/block-kit/interactive/Overflow.tsx
rename to src/block-kit/elements/Overflow.tsx
diff --git a/src/block-kit/elements/PlainTextInput.tsx b/src/block-kit/elements/PlainTextInput.tsx
new file mode 100644
index 0000000..6b0d3be
--- /dev/null
+++ b/src/block-kit/elements/PlainTextInput.tsx
@@ -0,0 +1,32 @@
+/** @jsx JSXSlack.h */
+import { PlainTextInput as SlackPlainTextInput } from '@slack/types'
+import { JSXSlack } from '../../jsx'
+import { ObjectOutput } from '../../utils'
+import { plainText } from '../composition/utils'
+
+export interface PlainTextInputProps {
+  actionId?: string
+  children?: undefined
+  placeholder?: string
+  initialValue?: string
+  multiline?: boolean
+  maxLength?: number
+  minLength?: number
+}
+
+export const PlainTextInput: JSXSlack.FC<PlainTextInputProps> = props => (
+  <ObjectOutput<SlackPlainTextInput>
+    type="plain_text_input"
+    action_id={props.actionId}
+    placeholder={
+      // Placeholder for input HTML element should disable emoji conversion
+      props.placeholder
+        ? plainText(props.placeholder, { emoji: false })
+        : undefined
+    }
+    initial_value={props.initialValue}
+    multiline={props.multiline}
+    max_length={props.maxLength}
+    min_length={props.minLength}
+  />
+)
diff --git a/src/block-kit/interactive/Select.tsx b/src/block-kit/elements/Select.tsx
similarity index 100%
rename from src/block-kit/interactive/Select.tsx
rename to src/block-kit/elements/Select.tsx
diff --git a/src/block-kit/index.ts b/src/block-kit/index.ts
index bcc2d7b..9b15faf 100644
--- a/src/block-kit/index.ts
+++ b/src/block-kit/index.ts
@@ -8,10 +8,11 @@ export { Context } from './Context'
 export { Divider } from './Divider'
 export { File } from './File'
 export { Image } from './Image'
+export { Input } from './Input'
 export { Section, Field } from './Section'
 
-// Interactive components
-export { Button } from './interactive/Button'
+// Block elements
+export { Button } from './elements/Button'
 export {
   Select,
   SelectFragment,
@@ -21,9 +22,12 @@ export {
   UsersSelect,
   ConversationsSelect,
   ChannelsSelect,
-} from './interactive/Select'
-export { Overflow, OverflowItem } from './interactive/Overflow'
-export { DatePicker } from './interactive/DatePicker'
+} from './elements/Select'
+export { Overflow, OverflowItem } from './elements/Overflow'
+export { DatePicker } from './elements/DatePicker'
+
+// PlainTextInput won't provide because Input block has an usage as component.
+// export { PlainTextInput } from './elements/PlainTextInput'
 
 // Composition objects
 export { Confirm } from './composition/Confirm'
diff --git a/src/dialog/Select.tsx b/src/dialog/Select.tsx
index c6ea60c..c082477 100644
--- a/src/dialog/Select.tsx
+++ b/src/dialog/Select.tsx
@@ -5,7 +5,7 @@ import {
   OptgroupInternal,
   Option,
   OptionInternal,
-} from '../block-kit/interactive/Select'
+} from '../block-kit/elements/Select'
 import { JSXSlack } from '../jsx'
 import { ObjectOutput, coerceToInteger } from '../utils'
 import { validateElement } from './Dialog'


Test patch:
diff --git a/test/index.tsx b/test/index.tsx
index c003651..86b3616 100644
--- a/test/index.tsx
+++ b/test/index.tsx
@@ -4,6 +4,7 @@ import {
   DividerBlock,
   FileBlock,
   ImageBlock,
+  InputBlock,
   SectionBlock,
   StaticSelect,
   Option as SlackOption,
@@ -25,6 +26,7 @@ import JSXSlack, {
   File,
   Fragment,
   Image,
+  Input,
   Modal,
   Optgroup,
   Option,
@@ -41,14 +43,28 @@ beforeEach(() => JSXSlack.exactMode(false))
 describe('jsx-slack', () => {
   describe('Container components', () => {
     describe('<Blocks>', () => {
-      it('throws error when <Blocks> has unexpected element', () =>
+      it('throws error when <Blocks> has unexpected element', () => {
         expect(() =>
           JSXSlack(
             <Blocks>
               <b>unexpected</b>
             </Blocks>
           )
-        ).toThrow())
+        ).toThrow()
+
+        // <Input> block cannot use in message
+        expect(() =>
+          JSXSlack(
+            <Blocks>
+              <Input label="Select">
+                <Select>
+                  <Option value="test">test</Option>
+                </Select>
+              </Input>
+            </Blocks>
+          )
+        ).toThrow()
+      })
     })
 
     describe('<Modal>', () => {
@@ -996,6 +1012,83 @@ describe('jsx-slack', () => {
           )
         ).toStrictEqual([{ ...file, source: 'local' }]))
     })
+
+    describe('<Input>', () => {
+      describe('as layout block', () => {
+        it('outputs input block with wrapped element', () => {
+          const select = (
+            <Select>
+              <Option value="test">test</Option>
+            </Select>
+          )
+
+          const expected: InputBlock = {
+            type: 'input',
+            block_id: 'input-id',
+            label: { type: 'plain_text', text: 'Select', emoji: true },
+            hint: { type: 'plain_text', text: 'foobar', emoji: true },
+            optional: true,
+            element: JSXSlack(select),
+          }
+
+          const { blocks } = JSXSlack(
+            <Modal title="test">
+              <Input id="input-id" label="Select" hint="foobar">
+                {select}
+              </Input>
+            </Modal>
+          )
+
+          expect(blocks).toStrictEqual([expected])
+        })
+      })
+
+      describe('as block element for plain-text input', () => {
+        it('outputs input block with plain-text input element', () => {
+          const { blocks } = JSXSlack(
+            <Modal title="test">
+              <Input actionId="action" blockId="foo" hint="bar" label="Input" />
+            </Modal>
+          )
+
+          const expected: InputBlock = {
+            type: 'input',
+            block_id: 'foo',
+            label: { type: 'plain_text', text: 'Input', emoji: true },
+            hint: { type: 'plain_text', text: 'bar', emoji: true },
+            optional: true,
+            element: {
+              type: 'plain_text_input',
+              action_id: 'action',
+            },
+          }
+
+          expect(blocks).toStrictEqual([expected])
+
+          // HTML-compatible aliases
+          expect(
+            JSXSlack(
+              <Modal title="test">
+                <Input id="foo" label="Input" name="action" title="bar" />
+              </Modal>
+            ).blocks
+          ).toStrictEqual(blocks)
+
+          // Placeholder must disable emoji
+          const { blocks: blocksPlaceholder } = JSXSlack(
+            <Modal title="test">
+              <Input label="placeholder" placeholder="Hi 😃" />
+            </Modal>
+          )
+
+          expect(blocksPlaceholder[0].element.placeholder).toStrictEqual({
+            type: 'plain_text',
+            text: 'Hi 😃',
+            emoji: false,
+          })
+        })
+      })
+    })
   })
 
   describe('<Escape> component', () => {