Skip to content

Commit

Permalink
feat: dropdown component
Browse files Browse the repository at this point in the history
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
deboer-tim committed Oct 3, 2024
1 parent 98917d7 commit 48a1cf9
Show file tree
Hide file tree
Showing 6 changed files with 400 additions and 0 deletions.
4 changes: 4 additions & 0 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@
"types": "./dist/alert/ErrorMessage.svelte.d.ts",
"svelte": "./dist/alert/ErrorMessage.svelte"
},
"./Dropdown": {
"types": "./dist/dropdown/Dropdown.svelte.d.ts",
"svelte": "./dist/dropdown/Dropdown.svelte"
},
"./DropdownMenu": {
"types": "./dist/dropdownMenu/index.d.ts",
"svelte": "./dist/dropdownMenu/index.js"
Expand Down
113 changes: 113 additions & 0 deletions packages/ui/src/lib/dropdown/Dropdown.spec.ts
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');
});
210 changes: 210 additions & 0 deletions packages/ui/src/lib/dropdown/Dropdown.svelte
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>
9 changes: 9 additions & 0 deletions packages/ui/src/lib/dropdown/DropdownTest.svelte
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>
2 changes: 2 additions & 0 deletions packages/ui/src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type { ButtonType } from './button/Button';
import Button from './button/Button.svelte';
import CloseButton from './button/CloseButton.svelte';
import Checkbox from './checkbox/Checkbox.svelte';
import Dropdown from './dropdown/Dropdown.svelte';
import DropdownMenu from './dropdownMenu';
import Input from './inputs/Input.svelte';
import SearchInput from './inputs/SearchInput.svelte';
Expand Down Expand Up @@ -49,6 +50,7 @@ export {
Checkbox,
CloseButton,
DetailsPage,
Dropdown,
DropdownMenu,
EmptyScreen,
ErrorMessage,
Expand Down
Loading

0 comments on commit 48a1cf9

Please sign in to comment.