Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- **Input** — `leadingSlot` and `trailingSlot` now reserve room in the field, so their content no longer sits on top of the text, and pointer events reach them, so a button placed in a slot is clickable. A decorative `leadingIcon` or `trailingIcon` stays click through, and slot content is inert while the field is disabled or loading. The loading spinner also takes precedence over a slot instead of being swallowed by it. ([#229](https://github.com/ndlabdev/sv5ui/issues/229))
- **Table** — the column resize handle is a focusable `separator` with `aria-valuenow`, arrow key resizing and a larger step while shift is held, plus `Home` and `End` for the bounds. It was mouse only before: no role, no tabindex and no key handler, so keyboard users could not resize a column at all. The drag also moves to the shared pointer hook, so touch and pen work rather than mouse alone. ([#226](https://github.com/ndlabdev/sv5ui/issues/226))
- **ThemeModeButton** — renders both mode icons and lets CSS pick the visible one, so server and client markup are identical. The wrong glyph no longer sticks after a reload in dark mode. The accessible name is now a mode-independent `Toggle theme`. ([#224](https://github.com/ndlabdev/sv5ui/issues/224))
- **Modal**, **Slideover**, **Drawer**, **Popover** — with `portal={false}`, nested floating layers (Select, DatePicker, DropdownMenu, Tooltip, ...) no longer hide behind the container when an ancestor has a `z-index` above 50. ([#217](https://github.com/ndlabdev/sv5ui/issues/217))
Expand Down
43 changes: 29 additions & 14 deletions src/lib/components/Input/Input.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,15 @@
const loadingLeading = $derived(loading && !trailing)
const loadingTrailing = $derived(loading && trailing)

const isLeading = $derived((!!icon && !trailing) || !!leadingIcon || !!avatar || loadingLeading)
const isTrailing = $derived((!!icon && trailing) || !!trailingIcon || loadingTrailing)
const isInert = $derived(disabled || loading)
const leadingInteractive = $derived(!!leadingSlot && !isInert)
const trailingInteractive = $derived(!!trailingSlot && !isInert)
const isLeading = $derived(
!!leadingSlot || (!!icon && !trailing) || !!leadingIcon || !!avatar || loadingLeading
)
const isTrailing = $derived(
!!trailingSlot || (!!icon && trailing) || !!trailingIcon || loadingTrailing
)

const leadingIconName = $derived(leadingIcon || (!!icon && !trailing ? icon : undefined))
const trailingIconName = $derived(trailingIcon || (!!icon && trailing ? icon : undefined))
Expand Down Expand Up @@ -128,32 +135,40 @@
base: variantSlots.base({
class: [config.slots.base, fieldGroupClass?.base, ui?.base]
}),
leading: variantSlots.leading({ class: [config.slots.leading, ui?.leading] }),
leading: variantSlots.leading({
class: [config.slots.leading, leadingInteractive && 'pointer-events-auto', ui?.leading]
}),
leadingIcon: variantSlots.leadingIcon({
class: [config.slots.leadingIcon, ui?.leadingIcon]
}),
leadingAvatar: variantSlots.leadingAvatar({
class: [config.slots.leadingAvatar, ui?.leadingAvatar]
}),
leadingAvatarSize: variantSlots.leadingAvatarSize() as AvatarSize,
trailing: variantSlots.trailing({ class: [config.slots.trailing, ui?.trailing] }),
trailing: variantSlots.trailing({
class: [
config.slots.trailing,
trailingInteractive && 'pointer-events-auto',
ui?.trailing
]
}),
trailingIcon: variantSlots.trailingIcon({
class: [config.slots.trailingIcon, ui?.trailingIcon]
})
})
</script>

<div class={classes.root}>
{#if leadingSlot}
<span class={classes.leading}>
{@render leadingSlot()}
</span>
{:else if loadingLeading}
{#if loadingLeading}
<span class={classes.leading}>
<span class="inline-flex animate-spin">
<Icon name={loadingIcon} class={classes.leadingIcon} />
</span>
</span>
{:else if leadingSlot}
<span class={classes.leading}>
{@render leadingSlot()}
</span>
{:else if avatar}
<span class={classes.leading}>
<Avatar {...avatar} size={classes.leadingAvatarSize} class={classes.leadingAvatar} />
Expand Down Expand Up @@ -181,16 +196,16 @@
onfocus={handleFocus}
/>

{#if trailingSlot}
<span class={classes.trailing}>
{@render trailingSlot()}
</span>
{:else if loadingTrailing}
{#if loadingTrailing}
<span class={classes.trailing}>
<span class="inline-flex animate-spin">
<Icon name={loadingIcon} class={classes.trailingIcon} />
</span>
</span>
{:else if trailingSlot}
<span class={classes.trailing}>
{@render trailingSlot()}
</span>
{:else if trailingIconName}
<span class={classes.trailing}>
<Icon name={trailingIconName} class={classes.trailingIcon} />
Expand Down
66 changes: 66 additions & 0 deletions src/lib/components/Input/Input.svelte.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import '../../../routes/layout.css'
import { page } from 'vitest/browser'
import { describe, expect, it, vi } from 'vitest'
import { render } from 'vitest-browser-svelte'
import { createRawSnippet } from 'svelte'
import Input from './Input.svelte'

const snippet = (html: string) => createRawSnippet(() => ({ render: () => html, setup: () => {} }))

const AVATAR_SRC =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='

Expand Down Expand Up @@ -402,6 +406,68 @@ describe('Input', () => {

// ==================== ACCESSIBILITY ====================

// ==================== LEADING & TRAILING SLOTS ====================

describe('leading and trailing slots', () => {
const button = () => snippet('<button type="button" id="slot-btn">x</button>')

const wrapperOf = (container: Element, side: 'first' | 'last') => {
const spans = Array.from(container.querySelectorAll<HTMLElement>('div > span'))
return side === 'first' ? spans[0] : spans[spans.length - 1]
}

it('should reserve room for a leading slot, like the leadingIcon prop does', () => {
const { container } = render(Input, { leadingSlot: button() })
const input = container.querySelector('input')!

expect(input.className).toMatch(/\bps-\d/)
})

it('should reserve room for a trailing slot', () => {
const { container } = render(Input, { trailingSlot: button() })
const input = container.querySelector('input')!

expect(input.className).toMatch(/\bpe-\d/)
})

it('should let pointer events reach interactive slot content', () => {
const { container } = render(Input, { trailingSlot: button() })

expect(getComputedStyle(wrapperOf(container, 'last')).pointerEvents).toBe('auto')
})

it('should keep a decorative icon click through so it focuses the input', () => {
const { container } = render(Input, { trailingIcon: 'lucide:check' })

expect(getComputedStyle(wrapperOf(container, 'last')).pointerEvents).toBe('none')
})

it.each([
['disabled', { disabled: true }],
['loading', { loading: true }]
])('should block slot interaction while the field is %s', (_label, props) => {
const { container } = render(Input, { ...props, trailingSlot: button() })

expect(getComputedStyle(wrapperOf(container, 'last')).pointerEvents).toBe('none')
})

it('should still show the loading spinner when a slot is present', async () => {
const { container } = render(Input, { loading: true, leadingSlot: button() })

await vi.waitFor(() => {
expect(container.querySelector('.animate-spin')).not.toBeNull()
})
expect(container.querySelector('#slot-btn')).toBeNull()
})

it('should render slot content when not loading', () => {
const { container } = render(Input, { leadingSlot: button() })

expect(container.querySelector('#slot-btn')).not.toBeNull()
expect(container.querySelector('.animate-spin')).toBeNull()
})
})

describe('accessibility', () => {
it('should support aria-label', () => {
render(Input, { 'aria-label': 'Search input' })
Expand Down
2 changes: 1 addition & 1 deletion src/lib/components/Input/input.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export type InputProps<T extends InputValue = InputValue> = Omit<
highlight?: boolean

/**
* Renders a loading spinner and optionally disables interaction.
* Renders a loading spinner. The input is disabled while loading.
* @default false
*/
loading?: boolean
Expand Down
Loading