diff --git a/docs/components/input.md b/docs/components/input.md index 6f15e1627..926d56c96 100644 --- a/docs/components/input.md +++ b/docs/components/input.md @@ -101,63 +101,21 @@ Use the `prefix` and `suffix` slots to add icons. ### Labels -Use the `label` attribute to give the input an accessible label. +Use the `label` attribute to give the input an accessible label. For labels that contain HTML, use the `label` slot instead. ```html preview - -
- + ``` ### Help Text -Add descriptive help text to an input with the `help-text` slot. +Add descriptive help text to an input with the `help-text` attribute. For help texts that contain HTML, use the `help-text` slot instead. ```html preview - -
What would you like people to call you?
-
-``` - -### Inputs with Dropdowns - -Dropdowns can be used in the `prefix` or `suffix` slot to make inputs more versatile. Make sure to use the `hoist` prop so the dropdown breaks out of the input's overflow. - -```html preview -
- - - - Home - - Home - Mobile - Work - - -
- Please enter a phone number where we can reach you. -
-
-
- - - - + ``` [component-metadata:sl-input] diff --git a/docs/components/select.md b/docs/components/select.md index 9c36637fe..82a2056e2 100644 --- a/docs/components/select.md +++ b/docs/components/select.md @@ -163,7 +163,7 @@ The `value` prop is bound to the current selection. As the selection changes, so ### Labels -Use the `label` attribute to give the select an accessible label. +Use the `label` attribute to give the select an accessible label. For labels that contain HTML, use the `label` slot instead. ```html preview @@ -175,15 +175,16 @@ Use the `label` attribute to give the select an accessible label. ### Help Text -Add descriptive help text to an input with the `help-text` slot. +Add descriptive help text to a select with the `help-text` attribute. For help texts that contain HTML, use the `help-text` slot instead. ```html preview - + Novice Intermediate Advanced - -
Please tell us your skill level.
``` diff --git a/docs/components/textarea.md b/docs/components/textarea.md index 4ab95e5a9..d486e2b8c 100644 --- a/docs/components/textarea.md +++ b/docs/components/textarea.md @@ -52,7 +52,7 @@ Use the `size` attribute to change a textarea's size. ### Labels -Use the `label` attribute to give the textarea an accessible label. +Use the `label` attribute to give the textarea an accessible label. For labels that contain HTML, use the `label` slot instead. ```html preview @@ -60,11 +60,13 @@ Use the `label` attribute to give the textarea an accessible label. ### Help Text -Add descriptive help text to a textarea with the `help-text` slot. +Add descriptive help text to a textarea with the `help-text` attribute. For help texts that contain HTML, use the `help-text` slot instead. ```html preview - -
Please tell us what you think.
+ ``` diff --git a/docs/getting-started/changelog.md b/docs/getting-started/changelog.md index 697e30238..6e63a7a4a 100644 --- a/docs/getting-started/changelog.md +++ b/docs/getting-started/changelog.md @@ -21,6 +21,7 @@ _During the beta period, these restrictions may be relaxed in the event of a mis - 🚨 BREAKING CHANGE: Removed `copy-button` part from `sl-color-picker` since copying is now done by clicking the preview - Added `getFormattedValue()` method to `sl-color-picker` so you can retrieve the current value in any format - Added visual separators between solid buttons in `sl-button-group` +- Added `help-text` prop to `sl-input`, `sl-textarea`, and `sl-select` - Fixed a bug where moving the mouse while `sl-dropdown` is closing would remove focus from the trigger - Fixed a bug where `sl-menu-item` didn't set a default color in the dark theme - Fixed a bug where `sl-color-picker` preview wouldn't update in Safari @@ -31,6 +32,7 @@ _During the beta period, these restrictions may be relaxed in the event of a mis - Improved accessibility in `sl-tooltip` by allowing escape to dismiss it [#219](https://github.com/shoelace-style/shoelace/issues/219) - Improved slot detection in `sl-card`, `sl-dialog`, and `sl-drawer` - Made `@types/resize-observer-browser` a dependency so users don't have to install it manually +- Refactored internal label + help text logic into a functional component used by `sl-input`, `sl-textarea`, and `sl-select` - Removed `sl-blur` and `sl-focus` events from `sl-menu` since menus can't have focus as of 2.0.0-beta.22 - Updated `sl-spinner` so the indicator is more obvious - Updated to Bootstrap Icons 1.2.1 diff --git a/src/components.d.ts b/src/components.d.ts index 0782e91df..c6ba0f02a 100644 --- a/src/components.d.ts +++ b/src/components.d.ts @@ -676,6 +676,10 @@ export namespace Components { * Set to true to disable the input. */ "disabled": boolean; + /** + * The input's help text. Alternatively, you can use the help-text slot. + */ + "helpText": string; /** * The input's inputmode attribute. */ @@ -685,7 +689,7 @@ export namespace Components { */ "invalid": boolean; /** - * The input's label. + * The input's label. Alternatively, you can use the label slot. */ "label": string; /** @@ -998,6 +1002,10 @@ export namespace Components { * Set to true to disable the select control. */ "disabled": boolean; + /** + * The select's help text. Alternatively, you can use the help-text slot. + */ + "helpText": string; /** * Enable this option to prevent the panel from being clipped when the component is placed inside a container with `overflow: auto|scroll`. */ @@ -1007,7 +1015,7 @@ export namespace Components { */ "invalid": boolean; /** - * The select's label. + * The select's label. Alternatively, you can use the label slot. */ "label": string; /** @@ -1190,6 +1198,10 @@ export namespace Components { * Set to true to disable the textarea. */ "disabled": boolean; + /** + * The textarea's help text. Alternatively, you can use the help-text slot. + */ + "helpText": string; /** * The textarea's inputmode attribute. */ @@ -1199,7 +1211,7 @@ export namespace Components { */ "invalid": boolean; /** - * The textarea's label. + * The textarea's label. Alternatively, you can use the label slot. */ "label": string; /** @@ -2374,6 +2386,10 @@ declare namespace LocalJSX { * Set to true to disable the input. */ "disabled"?: boolean; + /** + * The input's help text. Alternatively, you can use the help-text slot. + */ + "helpText"?: string; /** * The input's inputmode attribute. */ @@ -2383,7 +2399,7 @@ declare namespace LocalJSX { */ "invalid"?: boolean; /** - * The input's label. + * The input's label. Alternatively, you can use the label slot. */ "label"?: string; /** @@ -2676,6 +2692,10 @@ declare namespace LocalJSX { * Set to true to disable the select control. */ "disabled"?: boolean; + /** + * The select's help text. Alternatively, you can use the help-text slot. + */ + "helpText"?: string; /** * Enable this option to prevent the panel from being clipped when the component is placed inside a container with `overflow: auto|scroll`. */ @@ -2685,7 +2705,7 @@ declare namespace LocalJSX { */ "invalid"?: boolean; /** - * The select's label. + * The select's label. Alternatively, you can use the label slot. */ "label"?: string; /** @@ -2872,6 +2892,10 @@ declare namespace LocalJSX { * Set to true to disable the textarea. */ "disabled"?: boolean; + /** + * The textarea's help text. Alternatively, you can use the help-text slot. + */ + "helpText"?: string; /** * The textarea's inputmode attribute. */ @@ -2881,7 +2905,7 @@ declare namespace LocalJSX { */ "invalid"?: boolean; /** - * The textarea's label. + * The textarea's label. Alternatively, you can use the label slot. */ "label"?: string; /** diff --git a/src/components/input/input.scss b/src/components/input/input.scss index 147fb9797..af4ad413b 100644 --- a/src/components/input/input.scss +++ b/src/components/input/input.scss @@ -1,6 +1,5 @@ @import 'component'; -@import 'form-control-label'; -@import 'form-control-help-text'; +@import '../../functional-components/form-control/form-control'; /** * @prop --focus-ring: The focus ring style to use when the control receives focus, a `box-shadow` property. diff --git a/src/components/input/input.tsx b/src/components/input/input.tsx index f2e4954cd..a07d672b1 100644 --- a/src/components/input/input.tsx +++ b/src/components/input/input.tsx @@ -1,4 +1,5 @@ import { Component, Element, Event, EventEmitter, Method, Prop, State, Watch, h } from '@stencil/core'; +import FormControl from '../../functional-components/form-control/form-control'; import { hasSlot } from '../../utilities/slot'; let id = 0; @@ -13,10 +14,10 @@ let id = 0; * @slot clear-icon - An icon to use in lieu of the default clear icon. * @slot show-password-icon - An icon to use in lieu of the default show password icon. * @slot hide-password-icon - An icon to use in lieu of the default hide password icon. - * @slot help-text - Help text that describes how to use the input. + * @slot help-text - Help text that describes how to use the input. Alternatively, you can use the help-text prop. * * @part base - The component's base wrapper. - * @part form-control - The form control that wraps the label and the input. + * @part form-control - The form control that wraps the label, input, and help-text. * @part label - The input label. * @part input - The input control. * @part prefix - The input prefix container. @@ -40,7 +41,8 @@ export class Input { @Element() host: HTMLSlInputElement; @State() hasFocus = false; - @State() hasLabel = false; + @State() hasHelpTextSlot = false; + @State() hasLabelSlot = false; @State() isPasswordVisible = false; /** The input's type. */ @@ -58,9 +60,12 @@ export class Input { /** Set to true to draw a pill-style input with rounded edges. */ @Prop({ reflect: true }) pill = false; - /** The input's label. */ + /** The input's label. Alternatively, you can use the label slot. */ @Prop() label = ''; + /** The input's help text. Alternatively, you can use the help-text slot. */ + @Prop() helpText = ''; + /** The input's placeholder text. */ @Prop() placeholder: string; @@ -123,7 +128,7 @@ export class Input { @Watch('label') handleLabelChange() { - this.detectLabel(); + this.handleSlotChange(); } @Watch('value') @@ -147,7 +152,6 @@ export class Input { @Event({ eventName: 'sl-blur' }) slBlur: EventEmitter; connectedCallback() { - this.detectLabel = this.detectLabel.bind(this); this.handleChange = this.handleChange.bind(this); this.handleInput = this.handleInput.bind(this); this.handleInvalid = this.handleInvalid.bind(this); @@ -155,10 +159,17 @@ export class Input { this.handleFocus = this.handleFocus.bind(this); this.handleClearClick = this.handleClearClick.bind(this); this.handlePasswordToggle = this.handlePasswordToggle.bind(this); + this.handleSlotChange = this.handleSlotChange.bind(this); + + this.host.shadowRoot.addEventListener('slotchange', this.handleSlotChange); } componentWillLoad() { - this.detectLabel(); + this.handleSlotChange(); + } + + disconnectedCallback() { + this.host.shadowRoot.removeEventListener('slotchange', this.handleSlotChange); } /** Sets focus on the input. */ @@ -219,10 +230,6 @@ export class Input { this.invalid = !this.input.checkValidity(); } - detectLabel() { - this.hasLabel = this.label.length > 0 || hasSlot(this.host, 'label'); - } - handleChange() { this.value = this.input.value; this.slChange.emit(); @@ -261,32 +268,23 @@ export class Input { this.isPasswordVisible = !this.isPasswordVisible; } + handleSlotChange() { + this.hasHelpTextSlot = hasSlot(this.host, 'help-text'); + this.hasLabelSlot = hasSlot(this.host, 'label'); + } + render() { return ( -
- -
- -
- -
-
+ ); } } diff --git a/src/components/select/select.scss b/src/components/select/select.scss index 738fc1c45..4c1cc435b 100644 --- a/src/components/select/select.scss +++ b/src/components/select/select.scss @@ -1,6 +1,5 @@ @import 'component'; -@import 'form-control-label'; -@import 'form-control-help-text'; +@import '../../functional-components/form-control/form-control'; @import 'mixins/hidden'; @import 'mixins/hide-scrollbar'; diff --git a/src/components/select/select.tsx b/src/components/select/select.tsx index cb923de54..423541331 100644 --- a/src/components/select/select.tsx +++ b/src/components/select/select.tsx @@ -1,4 +1,5 @@ import { Component, Element, Event, EventEmitter, Method, Prop, State, Watch, h } from '@stencil/core'; +import FormControl from '../../functional-components/form-control/form-control'; import { getTextContent } from '../../utilities/slot'; import { hasSlot } from '../../utilities/slot'; @@ -14,7 +15,7 @@ let id = 0; * * @part base - The component's base wrapper. * @part clear-button - The input's clear button, exported from . - * @part form-control - The form control that wraps the label and the input. + * @part form-control - The form control that wraps the label, input, and help text. * @part help-text - The select's help text. * @part icon - The select's icon. * @part label - The select's label. @@ -41,7 +42,8 @@ export class Select { @Element() host: HTMLSlSelectElement; @State() hasFocus = false; - @State() hasLabel = false; + @State() hasHelpTextSlot = false; + @State() hasLabelSlot = false; @State() isOpen = false; @State() items = []; @State() displayLabel = ''; @@ -80,9 +82,12 @@ export class Select { /** Set to true to draw a pill-style select with rounded edges. */ @Prop() pill = false; - /** The select's label. */ + /** The select's label. Alternatively, you can use the label slot. */ @Prop() label = ''; + /** The select's help text. Alternatively, you can use the help-text slot. */ + @Prop() helpText = ''; + /** The select's required attribute. */ @Prop() required = false; @@ -101,7 +106,7 @@ export class Select { @Watch('label') handleLabelChange() { - this.detectLabel(); + this.handleSlotChange(); } @Watch('multiple') @@ -128,7 +133,6 @@ export class Select { @Event({ eventName: 'sl-blur' }) slBlur: EventEmitter; connectedCallback() { - this.detectLabel = this.detectLabel.bind(this); this.handleBlur = this.handleBlur.bind(this); this.handleFocus = this.handleFocus.bind(this); this.handleClearClick = this.handleClearClick.bind(this); @@ -139,10 +143,12 @@ export class Select { this.handleMenuSelect = this.handleMenuSelect.bind(this); this.handleSlotChange = this.handleSlotChange.bind(this); this.handleTagInteraction = this.handleTagInteraction.bind(this); + + this.host.shadowRoot.addEventListener('slotchange', this.handleSlotChange); } componentWillLoad() { - this.detectLabel(); + this.handleSlotChange(); } componentDidLoad() { @@ -153,6 +159,10 @@ export class Select { requestAnimationFrame(() => this.syncItemsFromValue()); } + disconnectedCallback() { + this.host.shadowRoot.removeEventListener('slotchange', this.handleSlotChange); + } + /** Checks for validity and shows the browser's validation message if the control is invalid. */ @Method() async reportValidity() { @@ -166,10 +176,6 @@ export class Select { this.invalid = !this.input.checkValidity(); } - detectLabel() { - this.hasLabel = this.label.length > 0 || hasSlot(this.host, 'label'); - } - getItemLabel(item: HTMLSlMenuItemElement) { const slot = item.shadowRoot.querySelector('slot:not([name])') as HTMLSlotElement; return getTextContent(slot); @@ -283,6 +289,8 @@ export class Select { } handleSlotChange() { + this.hasHelpTextSlot = hasSlot(this.host, 'help-text'); + this.hasLabelSlot = hasSlot(this.host, 'label'); this.syncItemsFromValue(); this.reportDuplicateItemValues(); } @@ -384,32 +392,17 @@ export class Select { const hasSelection = this.multiple ? this.value.length > 0 : this.value !== ''; return ( -
- - (this.dropdown = el)} @@ -492,21 +485,7 @@ export class Select { - -
- -
-
+ ); } } diff --git a/src/components/textarea/textarea.scss b/src/components/textarea/textarea.scss index 5da7184f9..3c752e6d4 100644 --- a/src/components/textarea/textarea.scss +++ b/src/components/textarea/textarea.scss @@ -1,6 +1,5 @@ @import 'component'; -@import 'form-control-label'; -@import 'form-control-help-text'; +@import '../../functional-components/form-control/form-control'; :host { display: block; diff --git a/src/components/textarea/textarea.tsx b/src/components/textarea/textarea.tsx index 7ad4b8a8b..02d092bc1 100644 --- a/src/components/textarea/textarea.tsx +++ b/src/components/textarea/textarea.tsx @@ -1,4 +1,5 @@ import { Component, Element, Event, EventEmitter, Method, Prop, State, Watch, h } from '@stencil/core'; +import FormControl from '../../functional-components/form-control/form-control'; import { hasSlot } from '../../utilities/slot'; let id = 0; @@ -11,7 +12,7 @@ let id = 0; * @slot help-text - Help text that describes how to use the input. * * @part base - The component's base wrapper. - * @part form-control - The form control that wraps the textarea and label. + * @part form-control - The form control that wraps the label, textarea, and help text. * @part label - The textarea label. * @part textarea - The textarea control. * @part help-text - The textarea help text. @@ -23,7 +24,7 @@ let id = 0; shadow: true }) export class Textarea { - textareaId = `textarea-${++id}`; + inputId = `textarea-${++id}`; labelId = `textarea-label-${id}`; helpTextId = `textarea-help-text-${id}`; resizeObserver: ResizeObserver; @@ -32,7 +33,8 @@ export class Textarea { @Element() host: HTMLSlTextareaElement; @State() hasFocus = false; - @State() hasLabel = false; + @State() hasHelpTextSlot = false; + @State() hasLabelSlot = false; /** The textarea's size. */ @Prop({ reflect: true }) size: 'small' | 'medium' | 'large' = 'medium'; @@ -43,9 +45,12 @@ export class Textarea { /** The textarea's value attribute. */ @Prop({ mutable: true, reflect: true }) value = ''; - /** The textarea's label. */ + /** The textarea's label. Alternatively, you can use the label slot. */ @Prop() label = ''; + /** The textarea's help text. Alternatively, you can use the help-text slot. */ + @Prop() helpText = ''; + /** The textarea's placeholder text. */ @Prop() placeholder: string; @@ -108,7 +113,7 @@ export class Textarea { @Watch('label') handleLabelChange() { - this.detectLabel(); + this.handleSlotChange(); } @Watch('rows') @@ -122,15 +127,17 @@ export class Textarea { } connectedCallback() { - this.detectLabel = this.detectLabel.bind(this); this.handleChange = this.handleChange.bind(this); this.handleInput = this.handleInput.bind(this); this.handleBlur = this.handleBlur.bind(this); this.handleFocus = this.handleFocus.bind(this); + this.handleSlotChange = this.handleSlotChange.bind(this); + + this.host.shadowRoot.addEventListener('slotchange', this.handleSlotChange); } componentWillLoad() { - this.detectLabel(); + this.handleSlotChange(); } componentDidLoad() { @@ -141,6 +148,7 @@ export class Textarea { disconnectedCallback() { this.resizeObserver.unobserve(this.textarea); + this.host.shadowRoot.removeEventListener('slotchange', this.handleSlotChange); } /** Sets focus on the textarea. */ @@ -202,10 +210,6 @@ export class Textarea { this.invalid = !this.textarea.checkValidity(); } - detectLabel() { - this.hasLabel = this.label.length > 0 || hasSlot(this.host, 'label'); - } - handleChange() { this.slChange.emit(); } @@ -226,6 +230,11 @@ export class Textarea { this.slFocus.emit(); } + handleSlotChange() { + this.hasLabelSlot = hasSlot(this.host, 'label'); + this.hasHelpTextSlot = hasSlot(this.host, 'help-text'); + } + setTextareaHeight() { if (this.resize === 'auto') { this.textarea.style.height = 'auto'; @@ -237,29 +246,16 @@ export class Textarea { render() { return ( -
-
(this.textarea = el)} - id={this.textareaId} + id={this.inputId} class="textarea__control" name={this.name} placeholder={this.placeholder} @@ -308,21 +304,7 @@ export class Textarea { onBlur={this.handleBlur} />
- -
- -
-
+ ); } } diff --git a/src/functional-components/form-control/form-control.scss b/src/functional-components/form-control/form-control.scss new file mode 100644 index 000000000..d22103a94 --- /dev/null +++ b/src/functional-components/form-control/form-control.scss @@ -0,0 +1,54 @@ +.form-control { + .form-control__label { + display: none; + } + + .form-control__help-text { + display: none; + } +} + +// Label +.form-control--has-label { + .form-control__label { + display: inline-block; + color: var(--sl-input-label-color); + margin-bottom: var(--sl-spacing-xxx-small); + } + + &.form-control--small .form-control__label { + font-size: var(--sl-input-label-font-size-small); + } + + &.form-control--medium .form-control__label { + font-size: var(--sl-input-label-font-size-medium); + } + + &.form-control--large .form-control_label { + font-size: var(--sl-input-label-font-size-large); + } +} + +// Help text +.form-control--has-help-text { + .form-control__help-text { + display: block; + color: var(--sl-input-help-text-color); + + ::slotted(*) { + margin-top: var(--sl-spacing-xxx-small); + } + } + + &.form-control--small .form-control__help-text { + font-size: var(--sl-input-help-text-font-size-small); + } + + &.form-control--medium .form-control__help-text { + font-size: var(--sl-input-help-text-font-size-medium); + } + + &.form-control--large .form-control__help-text { + font-size: var(--sl-input-help-text-font-size-large); + } +} diff --git a/src/functional-components/form-control/form-control.tsx b/src/functional-components/form-control/form-control.tsx new file mode 100644 index 000000000..3a3f7045b --- /dev/null +++ b/src/functional-components/form-control/form-control.tsx @@ -0,0 +1,73 @@ +import { h } from '@stencil/core'; + +export interface FormControlProps { + /** The input id, used to map the input to the label */ + inputId: string; + + /** The size of the form control */ + size: 'small' | 'medium' | 'large'; + + /** The label id, used to map the label to the input */ + labelId?: string; + + /** The label text (if the label slot isn't used) */ + label?: string; + + /** Whether or not a label slot has been provided. */ + hasLabelSlot?: boolean; + + /** The help text id, used to map the input to the help text */ + helpTextId?: string; + + /** The help text (if the help-text slot isn't used) */ + helpText?: string; + + /** Whether or not a help text slot has been provided. */ + hasHelpTextSlot?: boolean; + + /** A function that gets called when the label is clicked. */ + onLabelClick?: (event: MouseEvent) => void; +} + +const FormControl = (props: FormControlProps, children) => { + const hasLabel = props.label ? true : props.hasLabelSlot; + const hasHelpText = props.helpText ? true : props.hasHelpTextSlot; + + return ( +
+ + +
{children}
+ +
+ {props.helpText} +
+
+ ); +}; + +export default FormControl; diff --git a/src/styles/form-control-help-text.scss b/src/styles/form-control-help-text.scss deleted file mode 100644 index d4b8a1014..000000000 --- a/src/styles/form-control-help-text.scss +++ /dev/null @@ -1,23 +0,0 @@ -.help-text { - color: var(--sl-input-help-text-color); - - &.help-text--small { - font-size: var(--sl-input-help-text-font-size-small); - } - - &.help-text--medium { - font-size: var(--sl-input-help-text-font-size-medium); - } - - &.help-text--large { - font-size: var(--sl-input-help-text-font-size-large); - } - - &.help-text--invalid { - color: var(--sl-input-help-text-color-invalid); - } - - ::slotted(*) { - margin-top: var(--sl-spacing-xxx-small); - } -} diff --git a/src/styles/form-control-label.scss b/src/styles/form-control-label.scss deleted file mode 100644 index 83684a009..000000000 --- a/src/styles/form-control-label.scss +++ /dev/null @@ -1,29 +0,0 @@ -.form-control { - .label { - display: none; - } -} - -.form-control--has-label { - .label { - display: inline-block; - color: var(--sl-input-label-color); - margin-bottom: var(--sl-spacing-xxx-small); - - &.label--small { - font-size: var(--sl-input-label-font-size-small); - } - - &.label--medium { - font-size: var(--sl-input-label-font-size-medium); - } - - &.label--large { - font-size: var(--sl-input-label-font-size-large); - } - - &.label--invalid { - color: var(--sl-input-label-color-invalid); - } - } -}