Skip to content

Commit 3dea69f

Browse files
committed
refac
1 parent bef5ec2 commit 3dea69f

7 files changed

Lines changed: 249 additions & 185 deletions

File tree

Lines changed: 21 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
<script lang="ts">
2-
import { Select } from 'bits-ui';
32
import { getContext } from 'svelte';
43
54
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
65
import Check from '$lib/components/icons/Check.svelte';
6+
import Select from '$lib/components/common/Select.svelte';
77
88
const i18n = getContext('i18n');
99
@@ -22,44 +22,27 @@
2222
];
2323
</script>
2424

25-
<Select.Root
26-
selected={items.find((item) => item.value === value)}
25+
<Select
26+
bind:value
2727
{items}
28-
onSelectedChange={(selectedItem) => {
29-
value = selectedItem.value;
30-
onChange(value);
31-
}}
28+
{placeholder}
29+
triggerClass="relative w-full flex items-center gap-0.5 px-2.5 py-1.5 bg-gray-50 dark:bg-gray-850 rounded-xl"
30+
labelClass="inline-flex h-input px-0.5 w-full outline-hidden bg-transparent truncate placeholder-gray-400 focus:outline-hidden"
31+
onChange={() => onChange(value)}
3232
>
33-
<Select.Trigger
34-
class="relative w-full flex items-center gap-0.5 px-2.5 py-1.5 bg-gray-50 dark:bg-gray-850 rounded-xl"
35-
aria-label={placeholder}
36-
>
37-
<Select.Value
38-
class="inline-flex h-input px-0.5 w-full outline-hidden bg-transparent truncate placeholder-gray-400 focus:outline-hidden"
39-
{placeholder}
40-
/>
33+
<svelte:fragment slot="trigger" let:selectedLabel>
34+
<span class="inline-flex h-input px-0.5 w-full outline-hidden bg-transparent truncate placeholder-gray-400 focus:outline-hidden">
35+
{selectedLabel}
36+
</span>
4137
<ChevronDown className="size-3.5" strokeWidth="2.5" />
42-
</Select.Trigger>
38+
</svelte:fragment>
4339

44-
<Select.Content
45-
class="rounded-2xl min-w-[170px] p-1 border border-gray-100 dark:border-gray-800 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg"
46-
sameWidth={false}
47-
align="start"
48-
>
49-
{#each items as item}
50-
<Select.Item
51-
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
52-
value={item.value}
53-
label={item.label}
54-
>
55-
{item.label}
56-
57-
{#if value === item.value}
58-
<div class="ml-auto">
59-
<Check />
60-
</div>
61-
{/if}
62-
</Select.Item>
63-
{/each}
64-
</Select.Content>
65-
</Select.Root>
40+
<svelte:fragment slot="item" let:item let:selected>
41+
{item.label}
42+
{#if selected}
43+
<div class="ml-auto">
44+
<Check />
45+
</div>
46+
{/if}
47+
</svelte:fragment>
48+
</Select>

src/lib/components/admin/Users/Groups.svelte

Lines changed: 21 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
import XMark from '$lib/components/icons/XMark.svelte';
2121
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
2222
import Check from '$lib/components/icons/Check.svelte';
23-
import { Select } from 'bits-ui';
23+
import Select from '$lib/components/common/Select.svelte';
2424
import { createNewGroup, getGroups } from '$lib/apis/groups';
2525
import {
2626
getUserDefaultPermissions,
@@ -175,46 +175,29 @@
175175
{/if}
176176
</div>
177177
178-
<Select.Root
179-
selected={sortItems.find((item) => item.value === sortBy)}
178+
<Select
179+
bind:value={sortBy}
180180
items={sortItems}
181-
onSelectedChange={(selectedItem) => {
182-
sortBy = selectedItem.value;
183-
}}
181+
placeholder={$i18n.t('Sort by')}
182+
triggerClass="relative flex items-center gap-0.5 px-2.5 py-1.5 text-sm bg-gray-50 dark:bg-gray-850 rounded-xl shrink-0"
183+
align="end"
184184
>
185-
<Select.Trigger
186-
class="relative flex items-center gap-0.5 px-2.5 py-1.5 text-sm bg-gray-50 dark:bg-gray-850 rounded-xl shrink-0"
187-
aria-label={$i18n.t('Sort by')}
188-
>
189-
<Select.Value
190-
class="inline-flex h-input px-0.5 outline-hidden bg-transparent truncate placeholder-gray-400 focus:outline-hidden"
191-
placeholder={$i18n.t('Sort by')}
192-
/>
185+
<svelte:fragment slot="trigger" let:selectedLabel>
186+
<span class="inline-flex h-input px-0.5 outline-hidden bg-transparent truncate placeholder-gray-400 focus:outline-hidden">
187+
{selectedLabel}
188+
</span>
193189
<ChevronDown className="size-3.5" strokeWidth="2.5" />
194-
</Select.Trigger>
195-
196-
<Select.Content
197-
class="rounded-2xl min-w-[170px] p-1 border border-gray-100 dark:border-gray-800 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg"
198-
sameWidth={false}
199-
align="end"
200-
>
201-
{#each sortItems as item}
202-
<Select.Item
203-
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
204-
value={item.value}
205-
label={item.label}
206-
>
207-
{item.label}
208-
209-
{#if sortBy === item.value}
210-
<div class="ml-auto">
211-
<Check />
212-
</div>
213-
{/if}
214-
</Select.Item>
215-
{/each}
216-
</Select.Content>
217-
</Select.Root>
190+
</svelte:fragment>
191+
192+
<svelte:fragment slot="item" let:item let:selected>
193+
{item.label}
194+
{#if selected}
195+
<div class="ml-auto">
196+
<Check />
197+
</div>
198+
{/if}
199+
</svelte:fragment>
200+
</Select>
218201
</div>
219202
220203
{#if filteredGroups.length !== 0}

src/lib/components/common/DropdownOptions.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<script lang="ts">
22
import { getContext } from 'svelte';
3-
import { Select, DropdownMenu } from 'bits-ui';
3+
import { DropdownMenu } from 'bits-ui';
44
55
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
66
const i18n = getContext('i18n');
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
<script lang="ts">
2+
import { flyAndScale } from '$lib/utils/transitions';
3+
import { tick } from 'svelte';
4+
5+
/** Currently selected value */
6+
export let value = '';
7+
8+
/** Items array: { value: string, label: string }[] */
9+
export let items = [];
10+
11+
/** Placeholder text when no value is selected */
12+
export let placeholder = '';
13+
14+
/** Callback when value changes */
15+
export let onChange: (value: string) => void = () => {};
16+
17+
/** CSS classes for the trigger button */
18+
export let triggerClass = '';
19+
20+
/** CSS classes for the label inside the trigger */
21+
export let labelClass = '';
22+
23+
/** CSS classes for the dropdown content container */
24+
export let contentClass = 'rounded-2xl min-w-[170px] p-1 border border-gray-100 dark:border-gray-800 bg-white dark:bg-gray-850 dark:text-white shadow-lg';
25+
26+
/** CSS classes for each item button */
27+
export let itemClass = 'flex w-full gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl';
28+
29+
/** Alignment of the dropdown: 'start' | 'end' */
30+
export let align = 'start';
31+
32+
/** Callback when dropdown closes */
33+
export let onClose: () => void = () => {};
34+
35+
export let open = false;
36+
37+
let triggerEl;
38+
let contentEl;
39+
40+
$: selectedLabel = items.find((i) => i.value === value)?.label ?? placeholder;
41+
42+
/** Svelte action: moves the node to document.body (portal) */
43+
function portal(node) {
44+
document.body.appendChild(node);
45+
return {
46+
destroy() {
47+
if (node.parentNode) {
48+
node.parentNode.removeChild(node);
49+
}
50+
}
51+
};
52+
}
53+
54+
function positionContent() {
55+
if (!triggerEl || !contentEl) return;
56+
const rect = triggerEl.getBoundingClientRect();
57+
58+
contentEl.style.position = 'fixed';
59+
contentEl.style.zIndex = '9999';
60+
contentEl.style.top = `${rect.bottom + 4}px`;
61+
contentEl.style.minWidth = `${rect.width}px`;
62+
63+
if (align === 'end') {
64+
contentEl.style.right = `${window.innerWidth - rect.right}px`;
65+
contentEl.style.left = 'auto';
66+
} else {
67+
contentEl.style.left = `${rect.left}px`;
68+
contentEl.style.right = 'auto';
69+
}
70+
}
71+
72+
async function toggleOpen() {
73+
open = !open;
74+
if (open) {
75+
await tick();
76+
positionContent();
77+
}
78+
}
79+
80+
function handleWindowClick(event) {
81+
if (!open) return;
82+
if (triggerEl?.contains(event.target)) return;
83+
if (contentEl?.contains(event.target)) return;
84+
open = false;
85+
onClose();
86+
}
87+
88+
function handleKeydown(event) {
89+
if (event.key === 'Escape' && open) {
90+
open = false;
91+
onClose();
92+
}
93+
}
94+
95+
export function selectItem(item) {
96+
value = item.value;
97+
open = false;
98+
onChange(value);
99+
}
100+
</script>
101+
102+
<svelte:window on:click={handleWindowClick} on:keydown={handleKeydown} on:scroll|capture={positionContent} on:resize={positionContent} />
103+
104+
<button
105+
bind:this={triggerEl}
106+
class={triggerClass}
107+
aria-label={placeholder}
108+
type="button"
109+
on:click={toggleOpen}
110+
>
111+
<slot name="trigger" {selectedLabel} {open}>
112+
<span class={labelClass}>
113+
{selectedLabel}
114+
</span>
115+
</slot>
116+
</button>
117+
118+
{#if open}
119+
<div
120+
use:portal
121+
bind:this={contentEl}
122+
class={contentClass}
123+
transition:flyAndScale
124+
>
125+
<slot {open} {selectItem}>
126+
{#each items as item}
127+
<button
128+
class={itemClass}
129+
type="button"
130+
on:click={() => selectItem(item)}
131+
>
132+
<slot name="item" {item} selected={value === item.value}>
133+
{item.label}
134+
</slot>
135+
</button>
136+
{/each}
137+
</slot>
138+
</div>
139+
{/if}

0 commit comments

Comments
 (0)