Skip to content

Commit 60e6b7f

Browse files
feat: add input floating label (#2649)
Co-authored-by: Sérgio Fonseca <42741644+smfonseca@users.noreply.github.com>
1 parent f203b39 commit 60e6b7f

8 files changed

Lines changed: 151 additions & 13 deletions

File tree

.changeset/itchy-olives-shake.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@solid-design-system/components': patch
3+
'@solid-design-system/docs': patch
4+
---
5+
6+
Added `sd-input` `floating-label` attribute.

.changeset/wild-webs-win.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@solid-design-system/tokens': patch
3+
---
4+
5+
Add new font-size `text-xs`.

packages/components/src/components/input/input.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ describe('<sd-input>', () => {
4545
expect(el.inputmode).to.be.undefined;
4646
expect(el.valueAsDate).to.be.null;
4747
expect(isNaN(el.valueAsNumber)).to.be.true;
48+
expect(el.floatingLabel).to.be.false;
4849
});
4950

5051
it('should have title if title attribute is set', async () => {
@@ -102,6 +103,18 @@ describe('<sd-input>', () => {
102103
expect(labelParentElement!.contains(tooltip)).to.be.false;
103104
});
104105

106+
it('should render floating label part instead of label if floating label is active', async () => {
107+
const el = await fixture<SdInput>(html` <sd-input label="test" floating-label> </sd-input> `);
108+
109+
const labelParentElement = el.shadowRoot!.querySelector('[part~="form-control"]');
110+
const label = el.shadowRoot!.querySelector('[part~="form-control-label"]');
111+
const floatingLabel = el.shadowRoot!.querySelector('[part~="form-control-floating-label"]');
112+
113+
expect(labelParentElement).to.exist;
114+
expect(labelParentElement!.contains(label)).to.be.false;
115+
expect(labelParentElement!.contains(floatingLabel)).to.be.true;
116+
});
117+
105118
describe('value methods', () => {
106119
it('should set the value as a date when using valueAsDate', async () => {
107120
const el = await fixture<SdInput>(html` <sd-input type="date"></sd-input> `);

packages/components/src/components/input/input.ts

Lines changed: 67 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ const isFirefox = isChromium ? false : navigator.userAgent.includes('Firefox');
5454
* @csspart form-control-label - The label's wrapper.
5555
* @csspart form-control-input - The input's wrapper.
5656
* @csspart form-control-help-text - The help text's wrapper.
57+
* @csspart form-control-floating-label - The floating label text's wrapper.
5758
* @csspart base - The component's base wrapper.
5859
* @csspart border - The base part's absolutely positioned border. Allows for easy adjustment of border thickness without affecting component dimensions.
5960
* @csspart input - The internal `<input>` control.
@@ -192,6 +193,9 @@ export default class SdInput extends SolidElement implements SolidFormControl {
192193
/** Makes the input a required field. */
193194
@property({ type: Boolean, reflect: true }) required = false;
194195

196+
/** Enables the floating label behavior for the input. */
197+
@property({ attribute: 'floating-label', type: Boolean, reflect: true }) floatingLabel = false;
198+
195199
/** A regular expression pattern to validate input against. */
196200
@property() pattern: string;
197201

@@ -508,6 +512,9 @@ export default class SdInput extends SolidElement implements SolidFormControl {
508512
const hasHelpText = this.helpText ? true : !!slots['helpText'];
509513
const hasClearIcon = this.clearable && !this.readonly && (typeof this.value === 'number' || this.value.length > 0);
510514
const hasTooltip = !!slots['tooltip'];
515+
const hasIconLeft = slots['left'];
516+
const hasValue = this.value !== null && String(this.value).length > 0;
517+
const isFloatingLabelActive = this.floatingLabel && hasLabel && (this.hasFocus || hasValue || this.placeholder);
511518
// Hierarchy of input states:
512519
const inputState = this.disabled
513520
? 'disabled'
@@ -549,10 +556,25 @@ export default class SdInput extends SolidElement implements SolidFormControl {
549556
md: 'text-lg',
550557
lg: 'text-xl'
551558
}[this.size];
559+
const verticalPadding =
560+
this.size === 'lg'
561+
? !this.floatingLabel
562+
? 'py-2'
563+
: isFloatingLabelActive
564+
? 'py-3'
565+
: 'py-4'
566+
: !this.floatingLabel
567+
? 'py-1'
568+
: isFloatingLabelActive
569+
? 'py-2'
570+
: 'py-3';
571+
const floatingLabelHorizontalAlignmentWithIconLeft = { sm: 'left-[36px]', md: 'left-[44px]', lg: 'left-12' }[
572+
this.size
573+
];
552574
// Render
553575
return html`
554576
<div part="form-control" class=${cx((this.disabled || this.visuallyDisabled) && 'cursor-not-allowed')}>
555-
${hasLabel || hasTooltip
577+
${(hasLabel || hasTooltip) && !this.floatingLabel
556578
? html`<div class="flex items-center gap-1 mb-2">
557579
<label
558580
part="form-control-label"
@@ -567,7 +589,6 @@ export default class SdInput extends SolidElement implements SolidFormControl {
567589
${slots['tooltip'] ? html`<slot name="tooltip"></slot>` : ''}
568590
</div>`
569591
: null}
570-
571592
<div
572593
part="form-control-input"
573594
class=${cx(
@@ -587,12 +608,11 @@ export default class SdInput extends SolidElement implements SolidFormControl {
587608
part="base"
588609
class=${cx(
589610
'px-4 flex flex-row items-center rounded-default transition-colors ease-in-out duration-medium hover:duration-fast',
590-
// Vertical Padding
591-
this.size === 'lg' ? 'py-2' : 'py-1',
592611
// States
593612
!this.disabled && !this.readonly && !this.visuallyDisabled ? 'hover:bg-neutral-200' : '',
594613
this.readonly ? 'bg-neutral-100' : 'bg-white',
595-
inputState === 'disabled' || inputState === 'visuallyDisabled' ? 'text-neutral-500' : 'text-black'
614+
inputState === 'disabled' || inputState === 'visuallyDisabled' ? 'text-neutral-500' : 'text-black',
615+
verticalPadding
596616
)}
597617
>
598618
${slots['left']
@@ -610,8 +630,9 @@ export default class SdInput extends SolidElement implements SolidFormControl {
610630
this.visuallyDisabled || this.disabled
611631
? 'placeholder-neutral-500 cursor-not-allowed'
612632
: 'placeholder-neutral-700',
613-
this.size === 'sm' ? 'h-6' : 'h-8',
614-
textSize
633+
this.size === 'sm' ? (isFloatingLabelActive ? 'h-4' : 'h-6') : isFloatingLabelActive ? 'h-6' : 'h-8',
634+
textSize,
635+
isFloatingLabelActive && 'leading-none mt-4'
615636
)}
616637
type=${this.type === 'password' && this.passwordVisible ? 'text' : this.type}
617638
title=${this.title /* An empty title prevents browser validation tooltips from appearing on hover */}
@@ -647,6 +668,40 @@ export default class SdInput extends SolidElement implements SolidFormControl {
647668
@focus=${this.handleFocus}
648669
@blur=${this.handleBlur}
649670
/>
671+
${hasLabel && this.floatingLabel
672+
? html`
673+
<label
674+
part="form-control-floating-label"
675+
class=${cx(
676+
'pointer-events-none absolute',
677+
hasIconLeft ? floatingLabelHorizontalAlignmentWithIconLeft : 'left-4',
678+
!this.readonly && 'transition-all duration-medium ease-out',
679+
!isFloatingLabelActive || (!hasValue && (this.readonly || this.visuallyDisabled))
680+
? 'top-1/2 -translate-y-1/2'
681+
: this.size === 'lg'
682+
? 'top-2'
683+
: 'top-1'
684+
)}
685+
for="input"
686+
aria-hidden=${hasLabel ? 'false' : 'true'}
687+
>
688+
<span
689+
id="floating-label"
690+
class=${cx(
691+
'pointer-events-none leading-none',
692+
!this.readonly && 'transition-all duration-medium ease-out',
693+
!isFloatingLabelActive || (!hasValue && (this.readonly || this.visuallyDisabled))
694+
? 'text-base'
695+
: 'text-xs',
696+
(this.visuallyDisabled || this.disabled) && 'text-neutral-500',
697+
isFloatingLabelActive && !this.visuallyDisabled && !this.disabled && 'text-neutral-700'
698+
)}
699+
>
700+
${this.label}
701+
</span>
702+
</label>
703+
`
704+
: null}
650705
${hasClearIcon
651706
? html`
652707
<button
@@ -659,7 +714,10 @@ export default class SdInput extends SolidElement implements SolidFormControl {
659714
>
660715
<slot name="clear-icon">
661716
<sd-icon
662-
class=${cx('text-neutral-700', iconSize)}
717+
class=${cx(
718+
this.disabled || this.visuallyDisabled ? 'text-neutral-500' : 'text-neutral-700',
719+
iconSize
720+
)}
663721
library="_internal"
664722
name="closing-round"
665723
></sd-icon>
@@ -823,7 +881,7 @@ export default class SdInput extends SolidElement implements SolidFormControl {
823881
@apply block;
824882
}
825883
826-
:host([required]) #label::after {
884+
:host([required]) :is(#label, #floating-label)::after {
827885
content: ' *';
828886
}
829887

packages/docs/src/stories/components/input.mdx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,28 @@ Used to allow users to enter text. It can be displayed in several ways, dependin
3131
- Collect user data in forms, including names, emails, dates, and passwords.
3232
- Allow users to enter numerical values like quantities or prices.
3333
34+
#### Default Input
35+
36+
Use the “default” variant when clarity, scannability, and accessibility are your primary concerns.
37+
38+
- Long or descriptive labels, helper text, or units are needed.
39+
- Forms with many fields that users must scan quickly.
40+
- Complex fields with adornments, counters, or tooltips.
41+
42+
#### Floating Label Input
43+
44+
Use the “floating-label” variant to conserve vertical space and streamline simple forms, while still keeping the label visible.
45+
46+
- Compact layouts and simple fields.
47+
- Short labels (1–3 words); minimal helper text.
48+
- You want a clean look without duplicate placeholder text.
49+
3450
### Usage Guidelines
3551
52+
#### Variant Consistency
53+
54+
- Do not mix the two variants (default / floating label) within the same product, flow or form. Read the use cases above to know when to use each type.
55+
3656
#### Labels and Placeholders
3757
3858
- Use descriptive and concise labels.

packages/docs/src/stories/components/input.stories.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export default {
2727

2828
export const Default = {
2929
render: (args: any) => {
30-
return html`<div class="w-[250px]">${generateTemplate({ args })}</div> `;
30+
return html`<div class="w-[300px]">${generateTemplate({ args })}</div> `;
3131
}
3232
};
3333

@@ -64,6 +64,19 @@ export const Label = {
6464
`
6565
};
6666

67+
/**
68+
* Use the `floating-label` attribute to enable a floating label on the input.
69+
*
70+
* __Note:__ Floating labels only work with the `label` attribute. The `label` slot is not supported.
71+
*/
72+
export const FloatingLabel = {
73+
render: () => html`
74+
<div class="w-[350px]">
75+
<sd-input label="Floating Label" floating-label></sd-input>
76+
</div>
77+
`
78+
};
79+
6780
/**
6881
* Use the `placeholder` attribute to add a placeholder.
6982
*/

packages/docs/src/stories/components/input.test.stories.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,23 @@ export const Labels = {
9494
}
9595
};
9696

97+
/**
98+
* Use the `floating-label` attribute to enable a floating label on the input.
99+
*/
100+
export const FloatingLabel = {
101+
name: 'Floating Label',
102+
args: overrideArgs([{ type: 'attribute', name: 'floating-label', value: true }]),
103+
render: (args: any) => {
104+
return html`
105+
<div class="w-[350px]">
106+
${generateTemplate({
107+
args
108+
})}
109+
</div>
110+
`;
111+
}
112+
};
113+
97114
/**
98115
* Add descriptive help text to an input with the `help-text` attribute. For help texts that contain HTML, use the `help-text` slot instead.
99116
*/
@@ -277,7 +294,7 @@ export const StyleOnValid = {
277294

278295
for (const el of els) {
279296
await waitUntil(() => el?.shadowRoot?.querySelector('input'));
280-
await userEvent.type(el.shadowRoot!.querySelector('input')!, 'e');
297+
await userEvent.type(el.shadowRoot!.querySelector('input')!, ' ');
281298
}
282299

283300
// tab to next element to loose focus
@@ -864,6 +881,7 @@ export const Mouseless = {
864881
export const Combination = generateScreenshotStory([
865882
Default,
866883
Labels,
884+
FloatingLabel,
867885
HelpText,
868886
Placeholders,
869887
Clearable,

packages/tokens/src/tokens.json

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -637,6 +637,11 @@
637637
"type": "color",
638638
"description": "very high value fluctuation"
639639
},
640+
"text-xs": {
641+
"value": "0.75rem",
642+
"type": "fontSizes",
643+
"description": "12px"
644+
},
640645
"text-sm": {
641646
"value": "0.875rem",
642647
"type": "fontSizes",
@@ -1107,7 +1112,7 @@
11071112
"white": {
11081113
"value": "{white}",
11091114
"type": "color",
1110-
"description": "Default background color (light mode) 
Used for inverted button"
1115+
"description": "Default background color (light mode)\nUsed for inverted button"
11111116
},
11121117
"accent-300": {
11131118
"value": "{green.300}",
@@ -2189,4 +2194,4 @@
21892194
"UI Semantic"
21902195
]
21912196
}
2192-
}
2197+
}

0 commit comments

Comments
 (0)