Compare commits
10 Commits
14f9b87680
...
32da012b26
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32da012b26 | ||
|
|
71d320535e | ||
|
|
71c068bad2 | ||
|
|
247b683c87 | ||
|
|
8c0c91deb7 | ||
|
|
261c19db69 | ||
|
|
a85b3cf217 | ||
|
|
f02b19eff5 | ||
|
|
4dbf91f600 | ||
|
|
0daf0bf3bf |
@@ -1,27 +1,31 @@
|
|||||||
|
<!--
|
||||||
|
Component: FontList
|
||||||
|
|
||||||
|
- Displays a virtualized list of fonts with loading, empty, and error states.
|
||||||
|
- Uses unifiedFontStore from context for data, but can accept explicit fonts via props.
|
||||||
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { fontshareStore } from '$entities/Font/model/store/fontshareStore.svelte';
|
import FontView from '$features/ShowFont/ui/FontView.svelte';
|
||||||
import {
|
import {
|
||||||
Content as ItemContent,
|
Content as ItemContent,
|
||||||
Root as ItemRoot,
|
Root as ItemRoot,
|
||||||
Title as ItemTitle,
|
Title as ItemTitle,
|
||||||
} from '$shared/shadcn/ui/item';
|
} from '$shared/shadcn/ui/item';
|
||||||
import { VirtualList } from '$shared/ui';
|
import { VirtualList } from '$shared/ui';
|
||||||
/**
|
import { fontshareStore } from '../../model';
|
||||||
* FontList
|
|
||||||
*
|
$inspect(fontshareStore.fonts);
|
||||||
* Displays a virtualized list of fonts with loading, empty, and error states.
|
|
||||||
* Uses unifiedFontStore from context for data, but can accept explicit fonts via props.
|
|
||||||
*/
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<VirtualList items={fontshareStore.fonts} itemHeight={30}>
|
<VirtualList items={fontshareStore.fonts}>
|
||||||
{#snippet children({ item: font })}
|
{#snippet children({ item: font })}
|
||||||
<ItemRoot>
|
<ItemRoot>
|
||||||
<ItemContent>
|
<ItemContent>
|
||||||
<ItemTitle>{font.name}</ItemTitle>
|
<!-- <ItemTitle></ItemTitle> -->
|
||||||
<span class="text-xs text-muted-foreground">
|
<span class="text-xs text-muted-foreground">
|
||||||
{font.category} • {font.provider}
|
{font.provider} • {font.category}
|
||||||
</span>
|
</span>
|
||||||
|
<FontView id={font.id} slug={font.id} name={font.name}>{font.name}</FontView>
|
||||||
</ItemContent>
|
</ItemContent>
|
||||||
</ItemRoot>
|
</ItemRoot>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
<!--
|
||||||
|
Component: FontSearch
|
||||||
|
|
||||||
|
Combines search input with font list display
|
||||||
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {
|
import {
|
||||||
FontList,
|
FontList,
|
||||||
@@ -8,12 +13,6 @@ import { onMount } from 'svelte';
|
|||||||
import { mapManagerToParams } from '../../lib';
|
import { mapManagerToParams } from '../../lib';
|
||||||
import { filterManager } from '../../model';
|
import { filterManager } from '../../model';
|
||||||
|
|
||||||
/**
|
|
||||||
* FontSearch
|
|
||||||
*
|
|
||||||
* Font search component with search input and font list display.
|
|
||||||
* Uses unifiedFontStore for all font operations and search state.
|
|
||||||
*/
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
/**
|
/**
|
||||||
* The Pairing:
|
* The Pairing:
|
||||||
@@ -24,8 +23,6 @@ onMount(() => {
|
|||||||
|
|
||||||
return unbind;
|
return unbind;
|
||||||
});
|
});
|
||||||
|
|
||||||
$inspect(filterManager.queryValue, filterManager.debouncedQueryValue);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<SearchBar
|
<SearchBar
|
||||||
|
|||||||
40
src/features/ShowFont/ui/FontView.svelte
Normal file
40
src/features/ShowFont/ui/FontView.svelte
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<!--
|
||||||
|
Component: FontView
|
||||||
|
|
||||||
|
Loads fonts from fontshare with link tag
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
id: string;
|
||||||
|
children?: import('svelte').Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { name, slug, id, children }: Props = $props();
|
||||||
|
let isLoaded = $state(false);
|
||||||
|
|
||||||
|
// Construct the Fontshare API CSS URL
|
||||||
|
// We specify the weight (400) or 'all'
|
||||||
|
const cssUrl = $derived(`https://api.fontshare.com/v2/css?f[]=${id}&display=swap`);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
// Even though we use a link tag, we can still "watch"
|
||||||
|
// for the font to be ready for a smooth fade-in
|
||||||
|
document.fonts.load(`1em "${name}"`).then(() => {
|
||||||
|
isLoaded = true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<link rel="stylesheet" href={cssUrl} />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style:--f={name}
|
||||||
|
style:font-family={name ? `'${name}', sans-serif` : 'inherit'}
|
||||||
|
class="transition-opacity duration-500 {isLoaded ? 'font-[var(--f)] opacity-100' : 'font-sans opacity-0'}"
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
37
src/shared/lib/accessibility/motion.svelte.ts
Normal file
37
src/shared/lib/accessibility/motion.svelte.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
// Check if we are in a browser environment
|
||||||
|
const isBrowser = typeof window !== 'undefined';
|
||||||
|
|
||||||
|
class MotionPreference {
|
||||||
|
// Reactive state
|
||||||
|
#reduced = $state(false);
|
||||||
|
#mediaQuery: MediaQueryList = new MediaQueryList();
|
||||||
|
|
||||||
|
private handleChange = (e: MediaQueryListEvent) => {
|
||||||
|
this.#reduced = e.matches;
|
||||||
|
};
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
if (isBrowser) {
|
||||||
|
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||||
|
|
||||||
|
// Set initial value immediately
|
||||||
|
this.#reduced = mediaQuery.matches;
|
||||||
|
|
||||||
|
mediaQuery.addEventListener('change', this.handleChange);
|
||||||
|
|
||||||
|
this.#mediaQuery = mediaQuery;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getter allows us to use 'motion.reduced' reactively in components
|
||||||
|
get reduced() {
|
||||||
|
return this.#reduced;
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.#mediaQuery.removeEventListener('change', this.handleChange);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export a single instance to be used everywhere
|
||||||
|
export const motion = new MotionPreference();
|
||||||
@@ -37,41 +37,43 @@ export function createFilter<TValue extends string>(initialState: FilterModel<TV
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Helper to find a property by ID
|
||||||
|
const findProp = (id: string) => properties.find(p => p.id === id);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
get properties() {
|
get properties() {
|
||||||
return properties;
|
return properties;
|
||||||
},
|
},
|
||||||
|
|
||||||
get selectedProperties() {
|
get selectedProperties() {
|
||||||
return properties.filter(p => p.selected);
|
return properties.filter(p => p.selected);
|
||||||
},
|
},
|
||||||
|
|
||||||
get selectedCount() {
|
get selectedCount() {
|
||||||
return this.selectedProperties.length;
|
return properties.filter(p => p.selected)?.length;
|
||||||
},
|
},
|
||||||
|
|
||||||
// 3. Methods mutate the reactive state directly
|
|
||||||
toggleProperty(id: string) {
|
toggleProperty(id: string) {
|
||||||
const prop = properties.find(p => p.id === id);
|
const property = findProp(id);
|
||||||
if (prop) prop.selected = !prop.selected;
|
if (property) {
|
||||||
|
property.selected = !property.selected;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
selectProperty(id: string) {
|
selectProperty(id: string) {
|
||||||
const prop = properties.find(p => p.id === id);
|
const property = findProp(id);
|
||||||
if (prop) prop.selected = true;
|
if (property) {
|
||||||
|
property.selected = true;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
deselectProperty(id: string) {
|
deselectProperty(id: string) {
|
||||||
const prop = properties.find(p => p.id === id);
|
const property = findProp(id);
|
||||||
if (prop) prop.selected = false;
|
if (property) {
|
||||||
|
property.selected = false;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
selectAll() {
|
selectAll() {
|
||||||
properties.forEach(p => p.selected = true);
|
properties.forEach(property => property.selected = true);
|
||||||
},
|
},
|
||||||
|
|
||||||
deselectAll() {
|
deselectAll() {
|
||||||
properties.forEach(p => p.selected = false);
|
properties.forEach(property => property.selected = false);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,27 +79,23 @@ export interface VirtualizerOptions {
|
|||||||
* </div>
|
* </div>
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export function createVirtualizer(optionsGetter: () => VirtualizerOptions) {
|
export function createVirtualizer<T>(optionsGetter: () => VirtualizerOptions & { data: T[] }) {
|
||||||
// Reactive State
|
|
||||||
let scrollOffset = $state(0);
|
let scrollOffset = $state(0);
|
||||||
let containerHeight = $state(0);
|
let containerHeight = $state(0);
|
||||||
let measuredSizes = $state<Record<number, number>>({});
|
let measuredSizes = $state<Record<number, number>>({});
|
||||||
|
|
||||||
// Non-reactive ref for DOM manipulation (avoiding unnecessary state tracking)
|
|
||||||
let elementRef: HTMLElement | null = null;
|
let elementRef: HTMLElement | null = null;
|
||||||
|
|
||||||
// Reactive Options
|
// By wrapping the getter in $derived, we track everything inside it
|
||||||
const options = $derived(optionsGetter());
|
const options = $derived(optionsGetter());
|
||||||
|
|
||||||
// Optimized Memoization (The Cache Layer)
|
// This derivation now tracks: count, measuredSizes, AND the data array itself
|
||||||
// Only recalculates when item count or measured sizes change.
|
|
||||||
const offsets = $derived.by(() => {
|
const offsets = $derived.by(() => {
|
||||||
const count = options.count;
|
const count = options.count;
|
||||||
const result = Array.from<number>({ length: count });
|
const result = new Float64Array(count);
|
||||||
let accumulated = 0;
|
let accumulated = 0;
|
||||||
|
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
result[i] = accumulated;
|
result[i] = accumulated;
|
||||||
|
// Accessing measuredSizes here creates the subscription
|
||||||
accumulated += measuredSizes[i] ?? options.estimateSize(i);
|
accumulated += measuredSizes[i] ?? options.estimateSize(i);
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
@@ -112,24 +108,30 @@ export function createVirtualizer(optionsGetter: () => VirtualizerOptions) {
|
|||||||
: 0,
|
: 0,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Visible Range Calculation
|
|
||||||
// Svelte tracks dependencies automatically here.
|
|
||||||
const items = $derived.by((): VirtualItem[] => {
|
const items = $derived.by((): VirtualItem[] => {
|
||||||
const count = options.count;
|
// We MUST read options.data here so Svelte knows to re-run
|
||||||
if (count === 0 || containerHeight === 0) return [];
|
// this derivation when the items array is replaced!
|
||||||
|
const { count, data } = options;
|
||||||
|
if (count === 0 || containerHeight === 0 || !data) return [];
|
||||||
|
|
||||||
const overscan = options.overscan ?? 5;
|
const overscan = options.overscan ?? 5;
|
||||||
const viewportStart = scrollOffset;
|
|
||||||
const viewportEnd = scrollOffset + containerHeight;
|
|
||||||
|
|
||||||
// Find Start (Linear Scan)
|
// Binary search for efficiency
|
||||||
|
let low = 0;
|
||||||
|
let high = count - 1;
|
||||||
let startIdx = 0;
|
let startIdx = 0;
|
||||||
while (startIdx < count && offsets[startIdx + 1] < viewportStart) {
|
while (low <= high) {
|
||||||
startIdx++;
|
const mid = Math.floor((low + high) / 2);
|
||||||
|
if (offsets[mid] <= scrollOffset) {
|
||||||
|
startIdx = mid;
|
||||||
|
low = mid + 1;
|
||||||
|
} else {
|
||||||
|
high = mid - 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find End
|
|
||||||
let endIdx = startIdx;
|
let endIdx = startIdx;
|
||||||
|
const viewportEnd = scrollOffset + containerHeight;
|
||||||
while (endIdx < count && offsets[endIdx] < viewportEnd) {
|
while (endIdx < count && offsets[endIdx] < viewportEnd) {
|
||||||
endIdx++;
|
endIdx++;
|
||||||
}
|
}
|
||||||
@@ -139,18 +141,16 @@ export function createVirtualizer(optionsGetter: () => VirtualizerOptions) {
|
|||||||
|
|
||||||
const result: VirtualItem[] = [];
|
const result: VirtualItem[] = [];
|
||||||
for (let i = start; i < end; i++) {
|
for (let i = start; i < end; i++) {
|
||||||
const size = measuredSizes[i] ?? options.estimateSize(i);
|
|
||||||
result.push({
|
result.push({
|
||||||
index: i,
|
index: i,
|
||||||
start: offsets[i],
|
start: offsets[i],
|
||||||
size,
|
size: measuredSizes[i] ?? options.estimateSize(i),
|
||||||
end: offsets[i] + size,
|
end: offsets[i] + (measuredSizes[i] ?? options.estimateSize(i)),
|
||||||
key: options.getItemKey?.(i) ?? i,
|
key: options.getItemKey?.(i) ?? i,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Svelte Actions (The DOM Interface)
|
// Svelte Actions (The DOM Interface)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -185,6 +185,8 @@ export function createVirtualizer(optionsGetter: () => VirtualizerOptions) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let measurementBuffer: Record<number, number> = {};
|
||||||
|
let frameId: number | null = null;
|
||||||
/**
|
/**
|
||||||
* Svelte action to measure individual item elements for dynamic height support.
|
* Svelte action to measure individual item elements for dynamic height support.
|
||||||
*
|
*
|
||||||
@@ -195,23 +197,32 @@ export function createVirtualizer(optionsGetter: () => VirtualizerOptions) {
|
|||||||
* @returns Object with destroy method for cleanup
|
* @returns Object with destroy method for cleanup
|
||||||
*/
|
*/
|
||||||
function measureElement(node: HTMLElement) {
|
function measureElement(node: HTMLElement) {
|
||||||
// Use a ResizeObserver on individual items for dynamic height support
|
|
||||||
const resizeObserver = new ResizeObserver(([entry]) => {
|
const resizeObserver = new ResizeObserver(([entry]) => {
|
||||||
if (entry) {
|
if (!entry) return;
|
||||||
const index = parseInt(node.dataset.index || '', 10);
|
const index = parseInt(node.dataset.index || '', 10);
|
||||||
const height = entry.borderBoxSize[0]?.blockSize ?? node.offsetHeight;
|
const height = entry.borderBoxSize[0]?.blockSize ?? node.offsetHeight;
|
||||||
|
|
||||||
// Only update if height actually changed to prevent loops
|
if (!isNaN(index) && measuredSizes[index] !== height) {
|
||||||
if (!isNaN(index) && measuredSizes[index] !== height) {
|
// 1. Stuff the measurement into a temporary buffer
|
||||||
measuredSizes[index] = height;
|
measurementBuffer[index] = height;
|
||||||
|
|
||||||
|
// 2. Schedule a single update for the next animation frame
|
||||||
|
if (frameId === null) {
|
||||||
|
frameId = requestAnimationFrame(() => {
|
||||||
|
// 3. Update the state once for all collected measurements
|
||||||
|
// We use spread to trigger a single fine-grained update
|
||||||
|
measuredSizes = { ...measuredSizes, ...measurementBuffer };
|
||||||
|
|
||||||
|
// 4. Reset the buffer
|
||||||
|
measurementBuffer = {};
|
||||||
|
frameId = null;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
resizeObserver.observe(node);
|
resizeObserver.observe(node);
|
||||||
return {
|
return { destroy: () => resizeObserver.disconnect() };
|
||||||
destroy: () => resizeObserver.disconnect(),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Programmatic Scroll
|
// Programmatic Scroll
|
||||||
|
|||||||
@@ -13,3 +13,5 @@ export {
|
|||||||
type Virtualizer,
|
type Virtualizer,
|
||||||
type VirtualizerOptions,
|
type VirtualizerOptions,
|
||||||
} from './helpers';
|
} from './helpers';
|
||||||
|
|
||||||
|
export { motion } from './accessibility/motion.svelte';
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Filter } from '$shared/lib';
|
import type { Filter } from '$shared/lib';
|
||||||
|
import { motion } from '$shared/lib';
|
||||||
import { Badge } from '$shared/shadcn/ui/badge';
|
import { Badge } from '$shared/shadcn/ui/badge';
|
||||||
import { buttonVariants } from '$shared/shadcn/ui/button';
|
import { buttonVariants } from '$shared/shadcn/ui/button';
|
||||||
import { Checkbox } from '$shared/shadcn/ui/checkbox';
|
import { Checkbox } from '$shared/shadcn/ui/checkbox';
|
||||||
@@ -37,29 +38,11 @@ const { displayedLabel, filter }: PropertyFilterProps = $props();
|
|||||||
|
|
||||||
// Toggle state - defaults to open for better discoverability
|
// Toggle state - defaults to open for better discoverability
|
||||||
let isOpen = $state(true);
|
let isOpen = $state(true);
|
||||||
// Accessibility preference to disable animations
|
|
||||||
let prefersReducedMotion = $state(false);
|
|
||||||
|
|
||||||
// Check reduced motion preference on mount (window access required)
|
|
||||||
// Event listener allows responding to system preference changes
|
|
||||||
onMount(() => {
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
||||||
prefersReducedMotion = mediaQuery.matches;
|
|
||||||
|
|
||||||
const handleChange = (e: MediaQueryListEvent) => {
|
|
||||||
prefersReducedMotion = e.matches;
|
|
||||||
};
|
|
||||||
|
|
||||||
mediaQuery.addEventListener('change', handleChange);
|
|
||||||
return () => mediaQuery.removeEventListener('change', handleChange);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Animation config respects user preferences - zero duration if reduced motion enabled
|
// Animation config respects user preferences - zero duration if reduced motion enabled
|
||||||
// Local modifier prevents animation on initial render, only animates user interactions
|
// Local modifier prevents animation on initial render, only animates user interactions
|
||||||
const slideConfig = $derived({
|
const slideConfig = $derived({
|
||||||
duration: prefersReducedMotion ? 0 : 250,
|
duration: motion.reduced ? 0 : 250,
|
||||||
easing: cubicOut,
|
easing: cubicOut,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
<!--
|
||||||
|
Component: SearchBar
|
||||||
|
|
||||||
|
Search input with popover dropdown for results/suggestions
|
||||||
|
- Features keyboard navigation (ArrowDown/Up/Enter) and auto-focus prevention on popover open.
|
||||||
|
- The input field serves as the popover trigger.
|
||||||
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Input } from '$shared/shadcn/ui/input';
|
import { Input } from '$shared/shadcn/ui/input';
|
||||||
import { Label } from '$shared/shadcn/ui/label';
|
import { Label } from '$shared/shadcn/ui/label';
|
||||||
@@ -7,17 +14,20 @@ import {
|
|||||||
Trigger as PopoverTrigger,
|
Trigger as PopoverTrigger,
|
||||||
} from '$shared/shadcn/ui/popover';
|
} from '$shared/shadcn/ui/popover';
|
||||||
import { useId } from 'bits-ui';
|
import { useId } from 'bits-ui';
|
||||||
import {
|
import type { Snippet } from 'svelte';
|
||||||
type Snippet,
|
|
||||||
tick,
|
|
||||||
} from 'svelte';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
/** Unique identifier for the input element */
|
||||||
id: string;
|
id: string;
|
||||||
|
/** Current search value (bindable) */
|
||||||
value: string;
|
value: string;
|
||||||
|
/** Additional CSS classes for the container */
|
||||||
class?: string;
|
class?: string;
|
||||||
|
/** Placeholder text for the input */
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
/** Optional label displayed above the input */
|
||||||
label?: string;
|
label?: string;
|
||||||
|
/** Content to render inside the popover (receives unique content ID) */
|
||||||
children: Snippet<[{ id: string }]> | undefined;
|
children: Snippet<[{ id: string }]> | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,13 +45,6 @@ let triggerRef = $state<HTMLInputElement>(null!);
|
|||||||
// svelte-ignore state_referenced_locally
|
// svelte-ignore state_referenced_locally
|
||||||
const contentId = useId(id);
|
const contentId = useId(id);
|
||||||
|
|
||||||
function closeAndFocusTrigger() {
|
|
||||||
open = false;
|
|
||||||
tick().then(() => {
|
|
||||||
triggerRef?.focus();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleKeyDown(event: KeyboardEvent) {
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Enter') {
|
if (event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === 'Enter') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -50,16 +53,14 @@ function handleKeyDown(event: KeyboardEvent) {
|
|||||||
|
|
||||||
function handleInputClick() {
|
function handleInputClick() {
|
||||||
open = true;
|
open = true;
|
||||||
tick().then(() => {
|
|
||||||
triggerRef?.focus();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<PopoverRoot>
|
<PopoverRoot bind:open>
|
||||||
<PopoverTrigger bind:ref={triggerRef}>
|
<PopoverTrigger bind:ref={triggerRef}>
|
||||||
{#snippet child({ props })}
|
{#snippet child({ props })}
|
||||||
<div {...props} class="flex flex-row flex-1 w-full">
|
{@const { onclick, ...rest } = props}
|
||||||
|
<div {...rest} class="flex flex-row flex-1 w-full">
|
||||||
{#if label}
|
{#if label}
|
||||||
<Label for={id}>{label}</Label>
|
<Label for={id}>{label}</Label>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -68,6 +69,7 @@ function handleInputClick() {
|
|||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
bind:value={value}
|
bind:value={value}
|
||||||
onkeydown={handleKeyDown}
|
onkeydown={handleKeyDown}
|
||||||
|
onclick={handleInputClick}
|
||||||
class="flex flex-row flex-1"
|
class="flex flex-row flex-1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -76,7 +78,12 @@ function handleInputClick() {
|
|||||||
|
|
||||||
<PopoverContent
|
<PopoverContent
|
||||||
onOpenAutoFocus={e => e.preventDefault()}
|
onOpenAutoFocus={e => e.preventDefault()}
|
||||||
class="w-max"
|
onInteractOutside={(e => {
|
||||||
|
if (e.target === triggerRef) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
class="w-(--bits-popover-anchor-width) min-w-(--bits-popover-anchor-width)"
|
||||||
>
|
>
|
||||||
{@render children?.({ id: contentId })}
|
{@render children?.({ id: contentId })}
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
|
|||||||
@@ -10,10 +10,7 @@
|
|||||||
<script lang="ts" generics="T">
|
<script lang="ts" generics="T">
|
||||||
import { createVirtualizer } from '$shared/lib';
|
import { createVirtualizer } from '$shared/lib';
|
||||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||||
import {
|
import type { Snippet } from 'svelte';
|
||||||
type Snippet,
|
|
||||||
tick,
|
|
||||||
} from 'svelte';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/**
|
/**
|
||||||
@@ -57,6 +54,7 @@ let { items, itemHeight = 80, overscan = 5, class: className, children }: Props
|
|||||||
|
|
||||||
const virtualizer = createVirtualizer(() => ({
|
const virtualizer = createVirtualizer(() => ({
|
||||||
count: items.length,
|
count: items.length,
|
||||||
|
data: items,
|
||||||
estimateSize: typeof itemHeight === 'function' ? itemHeight : () => itemHeight,
|
estimateSize: typeof itemHeight === 'function' ? itemHeight : () => itemHeight,
|
||||||
overscan,
|
overscan,
|
||||||
}));
|
}));
|
||||||
@@ -66,19 +64,22 @@ const virtualizer = createVirtualizer(() => ({
|
|||||||
use:virtualizer.container
|
use:virtualizer.container
|
||||||
class={cn(
|
class={cn(
|
||||||
'relative overflow-auto border rounded-md bg-background',
|
'relative overflow-auto border rounded-md bg-background',
|
||||||
'outline-none focus-visible:ring-2 ring-ring ring-offset-2',
|
'h-150 w-full',
|
||||||
'h-full w-full',
|
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
role="listbox"
|
|
||||||
tabindex="0"
|
|
||||||
>
|
>
|
||||||
|
<div
|
||||||
|
style:height="{virtualizer.totalSize}px"
|
||||||
|
class="w-full pointer-events-none"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
{#each virtualizer.items as item (item.key)}
|
{#each virtualizer.items as item (item.key)}
|
||||||
<div
|
<div
|
||||||
use:virtualizer.measureElement
|
use:virtualizer.measureElement
|
||||||
data-index={item.index}
|
data-index={item.index}
|
||||||
class="absolute top-0 left-0 w-full translate-y-[var(--offset)] will-change-transform"
|
class="absolute top-0 left-0 w-full"
|
||||||
style:--offset="{item.start}px"
|
style:transform="translateY({item.start}px)"
|
||||||
>
|
>
|
||||||
{@render children({ item: items[item.index], index: item.index })}
|
{@render children({ item: items[item.index], index: item.index })}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
|
|
||||||
resolve: {
|
resolve: {
|
||||||
|
conditions: process.env.VITEST ? ['browser'] : undefined,
|
||||||
alias: {
|
alias: {
|
||||||
$lib: path.resolve(__dirname, './src/lib'),
|
$lib: path.resolve(__dirname, './src/lib'),
|
||||||
$app: path.resolve(__dirname, './src/app'),
|
$app: path.resolve(__dirname, './src/app'),
|
||||||
|
|||||||
Reference in New Issue
Block a user