Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat pro 1274 pinkv2 combobox #225

Open
wants to merge 12 commits into
base: v2
Choose a base branch
from
135 changes: 135 additions & 0 deletions v2/pink-sb/src/lib/combobox/Combobox.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
<script lang="ts">
import type { ComboboxOption, ComboboxProps } from '$lib/combobox/index.js';
import { Icon } from '$lib';
import { IconChevronDown } from '@appwrite.io/pink-icons-svelte';

export let placeholder: ComboboxProps['placeholder'] = '';
export let disabled: ComboboxProps['disabled'] = false;
export let options: ComboboxProps['options'] = [];
export let label: ComboboxProps['label'] = '';

Copy link
Member

Choose a reason for hiding this comment

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

We might need the required state like for other inputs

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added $$restProps to the input

Copy link
Member

Choose a reason for hiding this comment

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

Not sure if the $$restProps is enough... I think we might need to show a * on the label too when it's required (double check with Caio)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I thought the $$restProps came in handy because we extend the props from HTMLInputAttributes, so we get all those optional props right there. Asked Caio about the *

$: hasFocus = false;
let currentActiveIndex: number | null = null;
let selectedOption: ComboboxOption | null = null;
let filteredOptions = options;
let inputTextValue: string | null = null;
// eslint-disable-next-line no-undef
let focusTimeout: NodeJS.Timeout | null = null;

function selectOption(optionIndex: number) {
if (focusTimeout !== null) {
clearTimeout(focusTimeout);
}
selectedOption = filteredOptions[optionIndex];
inputTextValue = selectedOption.value;
hasFocus = false;
currentActiveIndex = null;
}

function keepActiveInView() {
const element = document.getElementById(`option-${options[currentActiveIndex].key}`);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}

function handleKeydown(event: KeyboardEvent) {
if (hasFocus) {
switch (event.key) {
case 'ArrowUp':
if (currentActiveIndex !== null && currentActiveIndex > 0) {
currentActiveIndex--;
keepActiveInView();
} else if (currentActiveIndex === 0) {
currentActiveIndex = null;
}

break;
case 'ArrowDown':
if (currentActiveIndex !== null && currentActiveIndex < options.length - 1) {
currentActiveIndex++;
keepActiveInView();
} else if (currentActiveIndex === null) {
currentActiveIndex = 0;
}
break;
case 'Enter':
if (currentActiveIndex !== null) {
selectOption(currentActiveIndex);
}
break;
default:
break;
}
}
}
</script>

<div
class="combobox-container"
tabindex="0"
on:keydown={handleKeydown}
role="combobox"
aria-expanded={hasFocus}
data-active-option={currentActiveIndex ? `option-${options[currentActiveIndex].key}` : ''}
aria-controls="comboboxoptions"
>
<label
><span>{label}</span>
<div class="combobox-input" class:disabled>
<input
type="text"
{placeholder}
{disabled}
{...$$restProps}
on:focus={() => {
hasFocus = true;
}}
on:focusout={() => {
focusTimeout = setTimeout(() => {
hasFocus = false;
currentActiveIndex = null;
filteredOptions = options;
}, 200);
}}
bind:value={inputTextValue}
on:input={() => {
filteredOptions = options.filter((option) =>
option.value
.toLowerCase()
.includes(inputTextValue ? inputTextValue.toLowerCase() : '')
);
}}
/>
<Icon icon={IconChevronDown} />
</div>
</label>
<div class="combobox-menu" class:hidden={!hasFocus || filteredOptions.length === 0}>
<ul id="comboboxoptions" role="listbox" aria-label="Options">
{#each filteredOptions as option, index}
<li
class="option"
class:active={currentActiveIndex === index}
id={`option-${option.key}`}
on:mouseenter={() => {
currentActiveIndex = index;
}}
>
<button
on:click={() => {
selectOption(index);
}}
>
{option.value}
</button>
</li>
{/each}
</ul>
</div>
</div>

<style lang="scss">
@use './combobox';

@include combobox.base;
</style>
104 changes: 104 additions & 0 deletions v2/pink-sb/src/lib/combobox/_combobox.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
@mixin base {
label {
display: flex;
flex-direction: column;
gap: 6px;
}

.combobox-container {
position: relative;
}

.combobox-input {
display: flex;
padding: var(--space-3, 6px) var(--space-4, 8px) var(--space-3, 6px) var(--space-6, 12px);
align-items: center;
gap: var(--space-7, 16px);
align-self: stretch;
border-radius: 8px;
border: var(--border-width-S, 1px) solid var(--color-border-neutral, #e4e4e7);
background: var(--color-bgcolor-neutral-primary, #fff);

box-sizing: border-box;

input {
flex-grow: 1;
&::placeholder {
color: var(--color-fgColor-neutral-tertiary, #97979b);
}
}

&:hover {
border: var(--border-width-S, 1px) solid var(--color-border-focus, #818186);
}

&:focus-within {
box-shadow: 0 0 0 1px var(--color-border-focus, #818186);
border-color: var(--color-border-focus, #818186);

svg {
transform: rotate(180deg);
}
}

&.disabled {
border: var(--border-width-S, 1px) solid var(--color-border-neutral-strong, #d8d8db);
background: var(--color-bgColor-neutral-tertiary, #ededf0);
}

svg {
transform: rotate(0deg);
transition: transform 0.2s;
fill: var(--color-fgColor-neutral-secondary, #56565c);
}
}

.combobox-menu {
display: flex;
position: absolute;
width: 100%;
padding: var(--gap-XXS, 4px);
flex-direction: column;
align-items: flex-start;
gap: var(--gap-XXXS, 2px);
border-radius: var(--border-radius-M, 12px);
border: var(--border-width-S, 1px) solid var(--color-border-neutral, #e4e4e7);
background: var(--color-bgcolor-neutral-primary, #fff);
margin-top: 8px;
max-height: 275px;
overflow-y: scroll;

/* box-shadow/neutral/S */
box-shadow:
0 1px 3px 0 rgba(0, 0, 0, 0.03),
0 4px 4px 0 rgba(0, 0, 0, 0.04);

ul,
button {
width: 100%;
}

button {
cursor: pointer;
height: 100%;
padding-top: var(--space-3, 6px);
padding-bottom: var(--space-3, 6px);
}

.option {
display: flex;
padding: 0 var(--space-4, 8px) 0 var(--space-5, 10px);
align-items: center;
gap: var(--gap-S, 8px);
align-self: stretch;

&.active {
border-radius: var(--border-radius-S, 8px);
background: var(--color-overlay-neutral-hover, rgba(25, 25, 28, 0.03));
}
}
}
.hidden {
display: none;
}
}
9 changes: 9 additions & 0 deletions v2/pink-sb/src/lib/combobox/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { HTMLInputAttributes } from 'svelte/elements';

export type ComboboxOption = { key: string; value: string };
export type ComboboxProps = HTMLInputAttributes & {
label: string;
options: Array<ComboboxOption>;
};

export { default as Combobox } from './Combobox.svelte';
89 changes: 89 additions & 0 deletions v2/pink-sb/src/stories/combobox/Combobox.stories.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<script context="module" lang="ts">
import type { MetaProps } from '@storybook/addon-svelte-csf';
import { Combobox } from '$lib/combobox/index.js';

export const meta: MetaProps = {
title: 'Components/Combobox',
component: Combobox,
args: {
placeholder: 'Placeholder',
disabled: false,
label: 'My combobox',
options: [
{ key: 'RU', value: 'Russia' },
{ key: 'CA', value: 'Canada' },
{ key: 'US', value: 'United States' },
{ key: 'CN', value: 'China' },
{ key: 'BR', value: 'Brazil' },
{ key: 'AU', value: 'Australia' },
{ key: 'IN', value: 'India' },
{ key: 'AR', value: 'Argentina' },
{ key: 'KZ', value: 'Kazakhstan' },
{ key: 'DZ', value: 'Algeria' },
{ key: 'CD', value: 'Democratic Republic of the Congo' },
{ key: 'SA', value: 'Saudi Arabia' },
{ key: 'MX', value: 'Mexico' },
{ key: 'ID', value: 'Indonesia' },
{ key: 'SD', value: 'Sudan' },
{ key: 'LY', value: 'Libya' },
{ key: 'IR', value: 'Iran' },
{ key: 'MN', value: 'Mongolia' },
{ key: 'PE', value: 'Peru' },
{ key: 'TD', value: 'Chad' },
{ key: 'NE', value: 'Niger' },
{ key: 'AO', value: 'Angola' },
{ key: 'ML', value: 'Mali' },
{ key: 'ZA', value: 'South Africa' },
{ key: 'CO', value: 'Colombia' },
{ key: 'ET', value: 'Ethiopia' },
{ key: 'BO', value: 'Bolivia' },
{ key: 'MA', value: 'Morocco' },
{ key: 'EG', value: 'Egypt' },
{ key: 'TZ', value: 'Tanzania' },
{ key: 'NG', value: 'Nigeria' },
{ key: 'VE', value: 'Venezuela' },
{ key: 'PK', value: 'Pakistan' },
{ key: 'NA', value: 'Namibia' },
{ key: 'MO', value: 'Mozambique' },
{ key: 'TR', value: 'Turkey' },
{ key: 'CL', value: 'Chile' },
{ key: 'ZM', value: 'Zambia' },
{ key: 'MM', value: 'Myanmar' },
{ key: 'AF', value: 'Afghanistan' },
{ key: 'SS', value: 'South Sudan' },
{ key: 'FR', value: 'France' },
{ key: 'SO', value: 'Somalia' },
{ key: 'CF', value: 'Central African Republic' },
{ key: 'UA', value: 'Ukraine' },
{ key: 'MG', value: 'Madagascar' },
{ key: 'BW', value: 'Botswana' },
{ key: 'KE', value: 'Kenya' },
{ key: 'YE', value: 'Yemen' },
{ key: 'TH', value: 'Thailand' }
]
},
argTypes: {
placeholder: {
control: { type: 'text' }
},
disabled: {
control: { type: 'boolean' }
},
options: {
control: false
}
}
};
</script>

<script>
import { Story, Template } from '@storybook/addon-svelte-csf';
</script>

<Template let:args>
<div style="width: 320px">
<Combobox {...args} />
</div>
</Template>

<Story name="Default" />
Loading