Skip to content

Commit 0e70724

Browse files
feat(select): add start and end slots (#28563)
Issue number: Part of #26297 --------- <!-- Please do not submit updates to dependencies unless it fixes an issue. --> <!-- Please try to limit your pull request to one type (bugfix, feature, etc). Submit multiple pull requests if needed. --> ## What is the current behavior? <!-- Please describe the current behavior that you are modifying. --> With the modern form control syntax, it is not possible to add icon buttons or other decorators to the sides of `ion-select`, as you can with `ion-item`. ## What is the new behavior? <!-- Please describe the behavior or changes that are being added by this PR. --> `start` and `end` slots added. While making this change, I also tweaked the CSS selectors responsible for translating the label above the input with `"stacked"` and `"floating"` placements, merging this logic into a single class managed by the TSX. I needed to add a new class for whether slot content is present, so the selectors were getting unwieldy otherwise. Docs PR TBA; I plan on knocking out all three components at once when the features are all complete, to make dev builds easier to manage. ## Does this introduce a breaking change? - [ ] Yes - [x] No <!-- If this introduces a breaking change, please describe the impact and migration path for existing applications below. --> ## Other information <!-- Any other information that is important to this PR such as screenshots of how the component looks before and after the change. --> --------- Co-authored-by: ionitron <[email protected]>
1 parent de36cc8 commit 0e70724

File tree

34 files changed

+476
-33
lines changed

34 files changed

+476
-33
lines changed

core/src/components/select/select.ios.scss

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,11 @@
2121
color: #{$text-color-step-350};
2222
}
2323

24-
// Select Native Wrapper
24+
// Select Inner Wrapper
2525
// ----------------------------------------------------------------
2626

27-
:host(.select-label-placement-stacked) .native-wrapper,
28-
:host(.select-label-placement-floating) .native-wrapper {
27+
:host(.select-label-placement-stacked) .select-wrapper-inner,
28+
:host(.select-label-placement-floating) .select-wrapper-inner {
2929
width: calc(100% - $select-ios-icon-size - $select-icon-margin-start);
3030
}
3131

core/src/components/select/select.md.outline.scss

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -102,10 +102,7 @@
102102
/**
103103
* This makes the label sit above the select.
104104
*/
105-
:host(.select-expanded.select-fill-outline.select-label-placement-floating) .label-text-wrapper,
106-
:host(.ion-focused.select-fill-outline.select-label-placement-floating) .label-text-wrapper,
107-
:host(.has-value.select-fill-outline.select-label-placement-floating) .label-text-wrapper,
108-
:host(.select-fill-outline.select-label-placement-stacked) .label-text-wrapper {
105+
:host(.label-floating.select-fill-outline) .label-text-wrapper {
109106
@include transform(translateY(-32%), scale(#{$form-control-label-stacked-scale}));
110107
@include margin(0);
111108

@@ -252,9 +249,6 @@
252249
* the floating/stacked label. We simulate this "cut out"
253250
* by removing the top border from the notch fragment.
254251
*/
255-
:host(.select-expanded.select-fill-outline.select-label-placement-floating) .select-outline-notch,
256-
:host(.ion-focused.select-fill-outline.select-label-placement-floating) .select-outline-notch,
257-
:host(.has-value.select-fill-outline.select-label-placement-floating) .select-outline-notch,
258-
:host(.select-fill-outline.select-label-placement-stacked) .select-outline-notch {
252+
:host(.label-floating.select-fill-outline) .select-outline-notch {
259253
border-top: none;
260254
}

core/src/components/select/select.md.scss

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -138,11 +138,11 @@
138138
--border-radius: 16px;
139139
}
140140

141-
// Select Native Wrapper
141+
// Select Inner Wrapper
142142
// ----------------------------------------------------------------
143143

144-
:host(.select-label-placement-stacked) .native-wrapper,
145-
:host(.select-label-placement-floating) .native-wrapper {
144+
:host(.select-label-placement-stacked) .select-wrapper-inner,
145+
:host(.select-label-placement-floating) .select-wrapper-inner {
146146
width: calc(100% - $select-md-icon-size - $select-icon-margin-start);
147147
}
148148

core/src/components/select/select.md.solid.scss

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -68,10 +68,7 @@
6868
// Select Label
6969
// ----------------------------------------------------------------
7070

71-
:host(.select-fill-solid.select-label-placement-stacked) .label-text-wrapper,
72-
:host(.select-expanded.select-fill-solid.select-label-placement-floating) .label-text-wrapper,
73-
:host(.ion-focused.select-fill-solid.select-label-placement-floating) .label-text-wrapper,
74-
:host(.has-value.select-fill-solid.select-label-placement-floating) .label-text-wrapper {
71+
:host(.label-floating.select-fill-solid) .label-text-wrapper {
7572
/**
7673
* Label text should not extend
7774
* beyond the bounds of the select.

core/src/components/select/select.scss

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,13 @@ button {
157157
@include margin(0, 0, 0, $select-icon-margin-start);
158158

159159
position: relative;
160+
161+
/**
162+
* Prevent the icon from shrinking when the label and/or
163+
* selected item text is long enough to fill the rest of
164+
* the container.
165+
*/
166+
flex-shrink: 0;
160167
}
161168

162169
/**
@@ -259,6 +266,25 @@ button {
259266
transition: opacity 150ms cubic-bezier(0.4, 0, 0.2, 1);
260267
}
261268

269+
.select-wrapper-inner {
270+
display: flex;
271+
272+
align-items: center;
273+
274+
overflow: hidden;
275+
}
276+
277+
:host(.select-label-placement-stacked) .select-wrapper-inner,
278+
:host(.select-label-placement-floating) .select-wrapper-inner {
279+
/**
280+
* When using a stacked/floating label, the inner wrapper is
281+
* stacked vertically under the label container. This line
282+
* ensures that the inner wrapper fills all the remaining height
283+
* of the component.
284+
*/
285+
flex-grow: 1;
286+
}
287+
262288
// Select Highlight
263289
// ----------------------------------------------------------------
264290

@@ -519,11 +545,23 @@ button {
519545
* The placeholder should be hidden when the label
520546
* is on top of the select. This prevents the label
521547
* from overlapping any placeholder value.
548+
*
549+
* TODO(FW-5592): Remove :not(.label-floating) piece
522550
*/
523-
:host(.select-label-placement-floating) .native-wrapper .select-placeholder {
551+
:host(.select-label-placement-floating:not(.label-floating)) .native-wrapper .select-placeholder {
524552
opacity: 0;
525553
}
526554

555+
/**
556+
* We don't use .label-floating here because that would
557+
* also include the case where the label is floating due
558+
* to content in the start/end slot. We want the opacity
559+
* to remain at the default in this case, since the select
560+
* isn't being actively interacted with.
561+
*
562+
* TODO(FW-5592): Change entire selector to:
563+
* :host(.label-floating.select-label-placement-floating) .native-wrapper .select-placeholder
564+
*/
527565
:host(.select-expanded.select-label-placement-floating) .native-wrapper .select-placeholder,
528566
:host(.ion-focused.select-label-placement-floating) .native-wrapper .select-placeholder,
529567
:host(.has-value.select-label-placement-floating) .native-wrapper .select-placeholder {
@@ -533,10 +571,7 @@ button {
533571
/**
534572
* This makes the label sit above the input.
535573
*/
536-
:host(.select-label-placement-stacked) .label-text-wrapper,
537-
:host(.select-expanded.select-label-placement-floating) .label-text-wrapper,
538-
:host(.ion-focused.select-label-placement-floating) .label-text-wrapper,
539-
:host(.has-value.select-label-placement-floating) .label-text-wrapper {
574+
:host(.label-floating) .label-text-wrapper {
540575
@include transform(translateY(50%), scale(#{$form-control-label-stacked-scale}));
541576

542577
/**
@@ -545,3 +580,23 @@ button {
545580
*/
546581
max-width: calc(100% / #{$form-control-label-stacked-scale});
547582
}
583+
584+
// Start/End Slots
585+
// ----------------------------------------------------------------
586+
587+
::slotted([slot="start"]), ::slotted([slot="end"]) {
588+
/**
589+
* Prevent the slots from shrinking when the label and/or
590+
* selected item text is long enough to fill the rest of
591+
* the container.
592+
*/
593+
flex-shrink: 0;
594+
}
595+
596+
::slotted([slot="start"]) {
597+
margin-inline-end: $form-control-label-margin;
598+
}
599+
600+
::slotted([slot="end"]) {
601+
margin-inline-start: $form-control-label-margin;
602+
}

core/src/components/select/select.tsx

Lines changed: 55 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ import type { SelectChangeEventDetail, SelectInterface, SelectCompareFn } from '
3333
* @virtualProp {"ios" | "md"} mode - The mode determines which platform styles to use.
3434
*
3535
* @slot label - The label text to associate with the select. Use the `labelPlacement` property to control where the label is placed relative to the select. Use this if you need to render a label with custom HTML.
36+
* @slot start - Content to display at the leading edge of the select.
37+
* @slot end - Content to display at the trailing edge of the select.
3638
*
3739
* @part placeholder - The text displayed in the select when there is no value.
3840
* @part text - The displayed value of the select.
@@ -762,8 +764,22 @@ export class Select implements ComponentInterface {
762764
}
763765

764766
private onClick = (ev: UIEvent) => {
765-
this.setFocus();
766-
this.open(ev);
767+
const target = ev.target as HTMLElement;
768+
const closestSlot = target.closest('[slot="start"], [slot="end"]');
769+
770+
if (target === this.el || closestSlot === null) {
771+
this.setFocus();
772+
this.open(ev);
773+
} else {
774+
/**
775+
* Prevent clicks to the start/end slots from opening the select.
776+
* We ensure the target isn't this element in case the select is slotted
777+
* in, for example, an item. This would prevent the select from ever
778+
* being opened since the element itself has slot="start"/"end".
779+
*/
780+
ev.stopPropagation();
781+
ev.preventDefault();
782+
}
767783
};
768784

769785
private onFocus = () => {
@@ -864,8 +880,31 @@ export class Select implements ComponentInterface {
864880
const inItem = hostContext('ion-item', this.el);
865881
const shouldRenderHighlight = mode === 'md' && fill !== 'outline' && !inItem;
866882

883+
const hasValue = this.hasValue();
884+
const hasStartEndSlots = el.querySelector('[slot="start"], [slot="end"]') !== null;
885+
867886
renderHiddenInput(true, el, name, parseValue(value), disabled);
868887

888+
/**
889+
* If the label is stacked, it should always sit above the select.
890+
* For floating labels, the label should move above the select if
891+
* the select has a value, is open, or has anything in either
892+
* the start or end slot.
893+
*
894+
* If there is content in the start slot, the label would overlap
895+
* it if not forced to float. This is also applied to the end slot
896+
* because with the default or solid fills, the select is not
897+
* vertically centered in the container, but the label is. This
898+
* causes the slots and label to appear vertically offset from each
899+
* other when the label isn't floating above the input. This doesn't
900+
* apply to the outline fill, but this was not accounted for to keep
901+
* things consistent.
902+
*
903+
* TODO(FW-5592): Remove hasStartEndSlots condition
904+
*/
905+
const labelShouldFloat =
906+
labelPlacement === 'stacked' || (labelPlacement === 'floating' && (hasValue || isExpanded || hasStartEndSlots));
907+
869908
return (
870909
<Host
871910
onClick={this.onClick}
@@ -876,7 +915,8 @@ export class Select implements ComponentInterface {
876915
'select-disabled': disabled,
877916
'select-expanded': isExpanded,
878917
'has-expanded-icon': expandedIcon !== undefined,
879-
'has-value': this.hasValue(),
918+
'has-value': hasValue,
919+
'label-floating': labelShouldFloat,
880920
'has-placeholder': placeholder !== undefined,
881921
'ion-focusable': true,
882922
[`select-${rtl}`]: true,
@@ -888,17 +928,23 @@ export class Select implements ComponentInterface {
888928
>
889929
<label class="select-wrapper" id="select-label">
890930
{this.renderLabelContainer()}
891-
<div class="native-wrapper" ref={(el) => (this.nativeWrapperEl = el)} part="container">
892-
{this.renderSelectText()}
931+
<div class="select-wrapper-inner">
932+
<slot name="start"></slot>
933+
<div class="native-wrapper" ref={(el) => (this.nativeWrapperEl = el)} part="container">
934+
{this.renderSelectText()}
935+
{this.renderListbox()}
936+
</div>
937+
<slot name="end"></slot>
893938
{!hasFloatingOrStackedLabel && this.renderSelectIcon()}
894-
{this.renderListbox()}
895939
</div>
896940
{/**
897941
* The icon in a floating/stacked select
898942
* must be centered with the entire select,
899-
* not just the native control. As a result,
900-
* we need to render the icon outside of
901-
* the native wrapper.
943+
* while the start/end slots and native control
944+
* are vertically offset in the default or
945+
* solid fills. As a result, we render the
946+
* icon outside the inner wrapper, which holds
947+
* those components.
902948
*/}
903949
{hasFloatingOrStackedLabel && this.renderSelectIcon()}
904950
{shouldRenderHighlight && <div class="select-highlight"></div>}

core/src/components/select/test/a11y/index.html

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,16 @@ <h1>Select - a11y</h1>
4141
<ion-select-option value="oranges">Oranges</ion-select-option>
4242
</ion-select>
4343

44+
<ion-select label="My Label" label-placement="floating">
45+
<ion-icon slot="start" name="pizza" aria-hidden="true"></ion-icon>
46+
<ion-select-option value="apples">Apples</ion-select-option>
47+
<ion-select-option value="bananas">Bananas</ion-select-option>
48+
<ion-select-option value="oranges">Oranges</ion-select-option>
49+
<ion-button slot="end" aria-label="button">
50+
<ion-icon slot="icon-only" name="lock-closed" aria-hidden="true"></ion-icon>
51+
</ion-button>
52+
</ion-select>
53+
4454
<ion-item>
4555
<ion-select label="My Label" value="apples">
4656
<ion-select-option value="apples">Apples</ion-select-option>

core/src/components/select/test/label/select.e2e.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ configs().forEach(({ title, screenshot, config }) => {
171171
test('label should appear on top of the select when the select is expanded', async ({ page }) => {
172172
await page.setContent(
173173
`
174-
<ion-select class="select-expanded" label="Label" label-placement="floating" placeholder="Select a Fruit">
174+
<ion-select class="select-expanded label-floating" label="Label" label-placement="floating" placeholder="Select a Fruit">
175175
<ion-select-option value="apples">Apples</ion-select-option>
176176
</ion-select>
177177
`,

0 commit comments

Comments
 (0)