Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/lib/holocene/combobox/combobox-option.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,21 @@
}

interface EnabledProps extends BaseProps {
active?: boolean;
selected?: boolean;
disabled?: boolean;
}

interface DisabledProps extends BaseProps {
active?: never;
disabled: true;
selected?: never;
}

type Props = EnabledProps | DisabledProps;

let {
active = false,
selected = false,
disabled = false,
label,
Expand All @@ -41,6 +44,7 @@
aria-selected={selected}
aria-disabled={disabled}
{onclick}
{active}
{selected}
{disabled}
{leading}
Expand Down
28 changes: 28 additions & 0 deletions src/lib/holocene/combobox/combobox.stories.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,34 @@
<AsyncTest id={context.id}></AsyncTest>
</Story>

<Story
name="Allow Custom Value"
args={{
options: ['English', 'English (UK)', 'German', 'French', 'Japanese'],
allowCustomValue: true,
}}
play={async ({ canvasElement, id }) => {
const canvas = within(canvasElement);
const combobox = canvas.getByTestId(id);
await userEvent.type(combobox, 'Spanish');
}}
/>

<Story
name="Multiselect Allow Custom Value"
args={{
options: ['English', 'English (UK)', 'German', 'French', 'Japanese'],
multiselect: true,
value: [],
allowCustomValue: true,
}}
play={async ({ canvasElement, id }) => {
const canvas = within(canvasElement);
const combobox = canvas.getByTestId(id);
await userEvent.type(combobox, 'Spanish');
}}
/>

<Story
name="Ghost Variant"
args={{
Expand Down
71 changes: 62 additions & 9 deletions src/lib/holocene/combobox/combobox.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
import Label from '$lib/holocene/label.svelte';
import MenuContainer from '$lib/holocene/menu/menu-container.svelte';
import Menu from '$lib/holocene/menu/menu.svelte';
import { translate } from '$lib/i18n/translate';

import Badge from '../badge.svelte';
import Button from '../button.svelte';
Expand Down Expand Up @@ -87,6 +88,7 @@
hrefDisabled?: boolean;
loading?: boolean;
loadingText?: string;
allowCustomValue?: boolean;
open?: Writable<boolean>;
maxMenuHeight?: string;
variant?: ComboboxStyles['variant'];
Expand Down Expand Up @@ -174,6 +176,7 @@
hrefDisabled = false,
loading = false,
loadingText = 'Loading more results',
allowCustomValue = false,
variant = 'default',
optionClass = '',
onchange,
Expand All @@ -189,9 +192,23 @@
let filterValue: string = $state('');
let menuElement: HTMLUListElement | null = $state(null);
let inputElement: HTMLInputElement | null = $state(null);
let inputFocused: boolean = $state(false);
let customOptions: string[] = $state([]);

const selectedOption = $derived(getSelectedOption(options));
let list = $derived(filterOptions(filterValue, options));
const allOptions = $derived(
allowCustomValue ? [...customOptions, ...options] : options,
);
const selectedOption = $derived(getSelectedOption(allOptions));
let list = $derived(filterOptions(filterValue, allOptions));
const trimmedFilterValue = $derived(filterValue.trim());
const showAddCustom = $derived(
allowCustomValue &&
trimmedFilterValue &&
!list.some(
(o) =>
getDisplayValue(o).toLowerCase() === trimmedFilterValue.toLowerCase(),
),
);
let displayValue = $derived(
!multiselect ? getDisplayValue(selectedOption) : undefined,
);
Expand Down Expand Up @@ -240,13 +257,24 @@

const resetValueAndOptions = () => {
displayValue = getDisplayValue(selectedOption);
list = options;
list = allOptions;
};

const isArrayValue = (value: string | string[]): value is string[] => {
return Array.isArray(value);
};

const addCustomValue = () => {
if (!trimmedFilterValue) return;
if (isArrayValue(value) && value.includes(trimmedFilterValue)) return;
if (!customOptions.includes(trimmedFilterValue)) {
customOptions = [trimmedFilterValue, ...customOptions];
}
handleSelectOption(trimmedFilterValue);
filterValue = '';
displayValue = '';
};

const isStringOption = (option: string | T): option is string => {
return typeof option === 'string';
};
Expand Down Expand Up @@ -382,8 +410,12 @@
focusFirstOption();
break;
case 'Enter':
openList();
focusFirstOption();
if (showAddCustom) {
addCustomValue();
} else {
openList();
focusFirstOption();
}
break;
case 'ArrowUp':
case 'ArrowRight':
Expand All @@ -396,9 +428,14 @@

const handleFocus: FocusEventHandler<HTMLInputElement> = (event) => {
event.stopPropagation();
inputFocused = true;
openList();
};

const handleBlur: FocusEventHandler<HTMLInputElement> = () => {
inputFocused = false;
};

const handleInput: FormEventHandler<HTMLInputElement> = (event) => {
event.stopPropagation();
if (!$open) $open = true;
Expand Down Expand Up @@ -477,7 +514,7 @@
>
{#if multiselect && isArrayValue(value) && value.length > 0}
{#if displayChips}
{#each value.slice(0, chipLimit) as v}
{#each value.slice(0, chipLimit) as v, i (i)}
<Chip
onremove={() => removeOption(v)}
removeButtonLabel={removeChipLabel}>{v}</Chip
Expand Down Expand Up @@ -518,6 +555,7 @@
aria-required={required}
aria-autocomplete="list"
onfocus={handleFocus}
onblur={handleBlur}
oninput={handleInput}
onkeydown={handleInputKeydown}
onclick={handleInputClick}
Expand Down Expand Up @@ -590,7 +628,7 @@
>
{#if multiselect && isArrayValue(value)}
<ComboboxOption
disabled={value.length === options.length}
disabled={value.length === allOptions.length}
onclick={selectAll}
label={selectAllLabel}
/>
Expand All @@ -602,15 +640,30 @@
<MenuDivider />
{/if}

{#each list as option}
{#if showAddCustom}
<ComboboxOption
active={inputFocused}
onclick={addCustomValue}
label="{translate('common.add')} {trimmedFilterValue}"
>
{#snippet leading()}
<Icon name="add" />
{/snippet}
</ComboboxOption>
{#if list.length > 0}
<MenuDivider />
{/if}
{/if}

{#each list as option, i (i)}
<ComboboxOption
onclick={() => handleSelectOption(option)}
selected={isSelected(option, value)}
label={getDisplayValue(option)}
class={optionClass}
/>
{:else}
{#if loading === false}
{#if !showAddCustom && loading === false}
<ComboboxOption disabled label={noResultsText} />
{/if}
{/each}
Expand Down
8 changes: 8 additions & 0 deletions src/lib/holocene/menu/menu-item.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import { MENU_CONTEXT, type MenuContext } from './menu-container.svelte';

export interface BaseProps {
active?: boolean;
selected?: boolean;
destructive?: boolean;
disabled?: boolean;
Expand Down Expand Up @@ -47,6 +48,7 @@

const {
class: className,
active = false,
selected,
destructive = false,
disabled = false,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • ⚠️ Type 'null' is not assignable to type 'string'.

Expand Down Expand Up @@ -137,6 +139,7 @@
centered ? 'justify-center' : 'justify-between',
className,
)}
class:active
class:disabled
class:hoverable
aria-hidden={disabled ? 'true' : 'false'}
Expand Down Expand Up @@ -168,6 +171,7 @@
class:disabled
class:selected
class:hoverable
class:active
aria-hidden={disabled ? 'true' : 'false'}
aria-disabled={disabled}
tabindex={disabled ? -1 : 0}
Expand Down Expand Up @@ -200,6 +204,10 @@
.menu-item {
@apply cursor-pointer border border-transparent text-sm focus-visible:border-inverse focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/70 dark:focus-visible:border-interactive;

&.active {
@apply bg-interactive-secondary-hover;
}

&.hoverable {
@apply hover:surface-interactive-secondary focus-visible:surface-interactive-secondary;
}
Expand Down
5 changes: 4 additions & 1 deletion src/lib/i18n/locales/en/nexus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export const Strings = {
'Add a link to your repo or instructions to help other users in this account use this endpoint.',
'description-placeholder':
'//Provide a readme for users to use this endpoint',
'no-description': 'No description provided.',
handler: 'Handler',
'delete-endpoint': 'Delete Endpoint',
'delete-modal-title': 'Delete Nexus Endpoint?',
Expand All @@ -37,9 +38,11 @@ export const Strings = {
'endpoint-name-hint-with-dash':
'Endpoint name must start with A-Z or a-z and can only contain A-Z, a-z, 0-9 or -',
'access-policy': 'Access Policy',
'allowed-caller-namespaces': 'Allowed caller Namespaces',
'allowed-caller-namespaces': 'Allowed Caller Namespaces',
'no-allowed-caller-namespaces': 'No Allowed Caller Namespaces',
'allowed-caller-namespaces-description':
'Namespace(s) that are allowed to call this Endpoint.',
'allowed-caller-namespaces-error': 'Please select at least one Namespace.',
'select-namespaces': 'Select Namespace(s)',
'selected-namespaces_one': '{{count}} Namespace selected',
'selected-namespaces_other': '{{count}} Namespaces selected',
Expand Down
Loading
Loading