-
Notifications
You must be signed in to change notification settings - Fork 296
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
As per the last time this item was discussed, I spent a while trying to use svelte-select and a few other off-the-shelf select components to work, but ultimately they all had the same problem as we had with pulling images: even after tying to style them, I couldn't get any of them to fit into our current styling well enough that I was comfortable submitting a PR. Two other minor things to note: - Most components used some form of item[] instead of child option elements. This might be useful in the future (and could be added here) but would have required more migration up front. - We don't have any immediate plans to expose this component in the UI library, but using a third-party library would have excluded that possibility. So, here it is instead: I used the code from the Typeahead component, mixing in bits from Tooltip and other places to create a custom styled Dropdown component. I tried to keep required migration to a minimum, but some tests needed to be updated and I switched onInput to svelte 5 style. Added to UI package and added a basic storybook story to test. There are >15 places in renderer where we use an HTML select which would be migrated in future PRs. Signed-off-by: Tim deBoer <[email protected]>
- Loading branch information
1 parent
98917d7
commit 48a1cf9
Showing
6 changed files
with
400 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,113 @@ | ||
/********************************************************************** | ||
* Copyright (C) 2024 Red Hat, Inc. | ||
* | ||
* Licensed under the Apache License, Version 2.0 (the "License"); | ||
* you may not use this file except in compliance with the License. | ||
* You may obtain a copy of the License at | ||
* | ||
* http://www.apache.org/licenses/LICENSE-2.0 | ||
* | ||
* Unless required by applicable law or agreed to in writing, software | ||
* distributed under the License is distributed on an "AS IS" BASIS, | ||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
* See the License for the specific language governing permissions and | ||
* limitations under the License. | ||
* | ||
* SPDX-License-Identifier: Apache-2.0 | ||
***********************************************************************/ | ||
import '@testing-library/jest-dom/vitest'; | ||
|
||
import { render, screen } from '@testing-library/svelte'; | ||
import userEvent from '@testing-library/user-event'; | ||
import { expect, test } from 'vitest'; | ||
|
||
import Dropdown from './Dropdown.svelte'; | ||
import DropdownTest from './DropdownTest.svelte'; | ||
|
||
test('a button is created', async () => { | ||
render(Dropdown); | ||
|
||
const input = screen.getByRole('button'); | ||
expect(input).toBeInTheDocument(); | ||
}); | ||
|
||
test('initial value is visible', async () => { | ||
render(Dropdown, { | ||
value: 'a value', | ||
}); | ||
|
||
const input = screen.getByRole('button'); | ||
expect(input).toBeInTheDocument(); | ||
expect(input).toHaveTextContent('a value'); | ||
}); | ||
|
||
test('disabling changes state and styling', async () => { | ||
render(Dropdown, { | ||
disabled: true, | ||
}); | ||
|
||
const input = screen.getByRole('button'); | ||
expect(input).toBeInTheDocument(); | ||
expect(input).toBeDisabled(); | ||
expect(input).toHaveClass('text-[color:var(--pd-input-field-disabled-text)]'); | ||
expect(input.parentElement).toHaveClass('border-b-[var(--pd-input-field-stroke-readonly)]'); | ||
}); | ||
|
||
test('initial focus is not set by default', async () => { | ||
render(Dropdown); | ||
|
||
const input = screen.getByRole('button'); | ||
expect(input).not.toHaveFocus(); | ||
}); | ||
|
||
test('should be able to navigate with keys', async () => { | ||
render(DropdownTest); | ||
const input = screen.getByRole('button'); | ||
expect(input).toBeInTheDocument(); | ||
expect(input).toHaveTextContent('initial value'); | ||
input.focus(); | ||
|
||
let item = screen.queryByRole('button', { name: 'A' }); | ||
expect(item).toBeNull(); | ||
|
||
// open dropdown (selects A) | ||
await userEvent.keyboard('[ArrowDown]'); | ||
item = screen.queryByRole('button', { name: 'A' }); | ||
expect(item).not.toBeNull(); | ||
|
||
// confirm A is highlighted | ||
expect(item).toHaveClass('bg-[var(--pd-dropdown-item-hover-bg)]'); | ||
expect(item).toHaveClass('text-[var(--pd-dropdown-item-hover-text)]'); | ||
|
||
// and B is not | ||
item = screen.queryByRole('button', { name: 'B' }); | ||
expect(item).not.toBeNull(); | ||
|
||
// confirm A is highlighted | ||
expect(item).not.toHaveClass('bg-[var(--pd-dropdown-item-hover-bg)]'); | ||
expect(item).not.toHaveClass('text-[var(--pd-dropdown-item-hover-text)]'); | ||
|
||
// select A, closes dropdown and updates selection | ||
await userEvent.keyboard('[Enter]'); | ||
|
||
item = screen.queryByRole('button', { name: 'B' }); | ||
expect(item).toBeNull(); | ||
expect(input).toHaveTextContent('A'); | ||
|
||
// reopen and page down to select the last option (C) | ||
await userEvent.keyboard('[ArrowDown]'); | ||
await userEvent.keyboard('[PageDown]'); | ||
|
||
// confirm C is now highlighted | ||
item = screen.queryByRole('button', { name: 'C' }); | ||
expect(item).not.toBeNull(); | ||
expect(item).toHaveClass('bg-[var(--pd-dropdown-item-hover-bg)]'); | ||
expect(item).toHaveClass('text-[var(--pd-dropdown-item-hover-text)]'); | ||
|
||
await userEvent.keyboard('[Enter]'); | ||
|
||
// dropdown is closed and we now have value C | ||
item = screen.queryByRole('button', { name: 'B' }); | ||
expect(item).toBeNull(); | ||
expect(input).toHaveTextContent('C'); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,210 @@ | ||
<script lang="ts"> | ||
import { faCaretDown, faCheck } from '@fortawesome/free-solid-svg-icons'; | ||
import Fa from 'svelte-fa'; | ||
export let id: string | undefined = undefined; | ||
export let name: string | undefined = undefined; | ||
// eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
export let value: any = ''; | ||
export let disabled: boolean = false; | ||
// eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
export let onInput: (val: any) => void = () => {}; | ||
interface Option { | ||
// eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
value: any; | ||
label: string; | ||
} | ||
let opened: boolean = false; | ||
let selectLabel: string = ''; | ||
let options: Option[] = []; | ||
let highlightIndex: number = -1; | ||
let pageStep: number = 10; | ||
let comp: HTMLElement; | ||
$: selectLabel = options.find(o => o.value === value)?.label ?? value; | ||
function onKeyDown(e: KeyboardEvent): void { | ||
switch (e.key) { | ||
case 'ArrowDown': | ||
onDownKey(e); | ||
break; | ||
case 'PageDown': | ||
onPageDownKey(e); | ||
break; | ||
case 'ArrowUp': | ||
onUpKey(e); | ||
break; | ||
case 'PageUp': | ||
onPageUpKey(e); | ||
break; | ||
case 'Escape': | ||
onEscKey(e); | ||
break; | ||
case 'Enter': | ||
onEnterKey(e); | ||
break; | ||
} | ||
} | ||
function onUpKey(e: KeyboardEvent): void { | ||
if (opened) { | ||
if (highlightIndex > 0) { | ||
highlightIndex--; | ||
} else if (highlightIndex === 0) { | ||
highlightIndex = -1; | ||
close(); | ||
} | ||
} | ||
e.preventDefault(); | ||
} | ||
function onPageUpKey(e: KeyboardEvent): void { | ||
if (opened) { | ||
highlightIndex = Math.max(0, highlightIndex - pageStep); | ||
e.preventDefault(); | ||
} | ||
} | ||
function onDownKey(e: KeyboardEvent): void { | ||
if (opened) { | ||
if (highlightIndex < options.length - 1) { | ||
highlightIndex++; | ||
} | ||
} else { | ||
open(); | ||
} | ||
e.preventDefault(); | ||
} | ||
function onPageDownKey(e: KeyboardEvent): void { | ||
if (opened) { | ||
highlightIndex = Math.min(options.length - 1, highlightIndex + pageStep); | ||
e.preventDefault(); | ||
} | ||
} | ||
function onEscKey(e: KeyboardEvent): void { | ||
if (opened) { | ||
close(); | ||
e.stopPropagation(); | ||
} | ||
} | ||
function onEnterKey(e: KeyboardEvent): void { | ||
if (opened && highlightIndex >= 0) { | ||
onSelect(e, options[highlightIndex].value); | ||
e.stopPropagation(); | ||
} else { | ||
close(); | ||
} | ||
} | ||
function onEnter(i: number): void { | ||
highlightIndex = i; | ||
} | ||
// eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
function onSelect(e: Event, newValue: any): void { | ||
onInput(newValue); | ||
value = newValue; | ||
close(); | ||
e.preventDefault(); | ||
} | ||
function toggleOpen(): void { | ||
if (opened) { | ||
close(); | ||
} else { | ||
open(); | ||
} | ||
} | ||
function open(): void { | ||
opened = true; | ||
highlightIndex = 0; | ||
} | ||
function close(): void { | ||
opened = false; | ||
} | ||
// eslint-disable-next-line @typescript-eslint/no-explicit-any | ||
function buildOptions(node: HTMLSelectElement): any { | ||
options = [...node.options].map(o => ({ value: o.value, label: o.textContent ?? '' })); | ||
return { | ||
destroy(): void { | ||
options = []; | ||
}, | ||
}; | ||
} | ||
function onWindowClick(e: Event): void { | ||
if (opened && e.target instanceof Node && !comp.contains(e.target)) { | ||
close(); | ||
} | ||
} | ||
</script> | ||
|
||
<svelte:window on:click={onWindowClick} /> | ||
|
||
<div | ||
class="flex flex-row grow items-center px-1 py-1 group bg-[var(--pd-input-field-bg)] border-[1px] border-transparent min-w-24 relative {$$props.class || | ||
''}" | ||
class:not(focus-within):hover:bg-[var(--pd-input-field-hover-bg)]={!disabled} | ||
class:focus-within:bg-[var(--pd-input-field-focused-bg)]={!disabled} | ||
class:focus-within:rounded-md={!disabled} | ||
class:focus-within:border-[var(--pd-input-field-hover-stroke)]={!disabled} | ||
class:border-b-[var(--pd-input-field-stroke)]={!disabled} | ||
class:hover:border-b-[var(--pd-input-field-hover-stroke)]={!disabled} | ||
class:border-b-[var(--pd-input-field-stroke-readonly)]={disabled} | ||
aria-invalid={$$props['aria-invalid']} | ||
aria-label={$$props['aria-label']} | ||
bind:this={comp}> | ||
<button | ||
class="flex flex-row w-full outline-0 bg-[var(--pd-input-field-bg)] placeholder:text-[color:var(--pd-input-field-placeholder-text)] items-center text-start" | ||
class:text-[color:var(--pd-input-field-focused-text)]={!disabled} | ||
class:text-[color:var(--pd-input-field-disabled-text)]={disabled} | ||
class:group-hover:bg-[var(--pd-input-field-hover-bg)]={!disabled} | ||
class:group-focus-within:bg-[var(--pd-input-field-hover-bg)]={!disabled} | ||
class:group-hover-placeholder:text-[color:var(--pd-input-field-placeholder-text)]={!disabled} | ||
disabled={disabled} | ||
id={id} | ||
name={name} | ||
on:click={toggleOpen} | ||
on:keydown={onKeyDown}> | ||
<span class="grow">{selectLabel}</span> | ||
<div | ||
class:text-[var(--pd-input-field-stroke)]={!disabled} | ||
class:text-[var(--pd-input-field-disabled-text)]={!disabled} | ||
class:group-hover:text-[var(--pd-input-field-hover-stroke)]={!disabled}> | ||
<Fa icon={faCaretDown} /> | ||
</div> | ||
</button> | ||
|
||
{#if opened} | ||
<div | ||
class="absolute top-full right-0 z-10 w-full max-h-80 rounded-md bg-[var(--pd-dropdown-bg)] border-[var(--pd-input-field-hover-stroke)] border-[1px] overflow-y-auto whitespace-nowrap"> | ||
{#each options as option, i} | ||
<button | ||
on:keydown={onKeyDown} | ||
on:mouseenter={(): void => onEnter(i)} | ||
on:click={(e): void => onSelect(e, option.value)} | ||
class="flex flex-row w-full select-none px-2 py-1 items-center text-start" | ||
class:autofocus={i === 0} | ||
class:bg-[var(--pd-dropdown-item-hover-bg)]={highlightIndex === i} | ||
class:text-[var(--pd-dropdown-item-hover-text)]={highlightIndex === i}> | ||
<div class="min-w-4 max-w-4"> | ||
{#if option.value === value}<Fa icon={faCheck} />{/if} | ||
</div> | ||
<div class="grow">{option.label}</div> | ||
</button> | ||
{/each} | ||
</div> | ||
{/if} | ||
|
||
<select use:buildOptions class="hidden" bind:value={value}> | ||
<slot /> | ||
</select> | ||
</div> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
<script lang="ts"> | ||
import Dropdown from './Dropdown.svelte'; | ||
</script> | ||
|
||
<Dropdown value="initial value"> | ||
<option value="a">A</option> | ||
<option value="b">B</option> | ||
<option value="c">C</option> | ||
</Dropdown> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.