Skip to content

Commit

Permalink
Add asChild prop & forward input events (#14)
Browse files Browse the repository at this point in the history
  • Loading branch information
huntabyte authored Nov 1, 2023
1 parent 1e6d064 commit b259ce9
Show file tree
Hide file tree
Showing 17 changed files with 475 additions and 128 deletions.
5 changes: 5 additions & 0 deletions .changeset/polite-needles-eat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"cmdk-sv": patch
---

Add `asChild` prop & forward input events
55 changes: 35 additions & 20 deletions .eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,51 +10,66 @@ module.exports = {
plugins: ['@typescript-eslint'],
parserOptions: {
sourceType: 'module',
ecmaVersion: 'lastest',
ecmaVersion: 'latest',
extraFileExtensions: ['.svelte']
},
env: {
browser: true,
es2024: true,
node: true
},
overrides: [
{
files: ['*.svelte'],
parser: 'svelte-eslint-parser',
parserOptions: {
parser: '@typescript-eslint/parser'
}
}
],
globals: { $$Generic: 'readable', NodeJS: true },
rules: {
// eslint
'no-console': 'warn',

// @typescript-eslint
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_'
}
],

// eslint-plugin-svelte
'svelte/no-target-blank': 'error',
'svelte/no-immutable-reactive-statements': 'error',
'svelte/prefer-style-directive': 'error',
'svelte/no-reactive-literals': 'error',
'svelte/no-useless-mustaches': 'error',
// TODO: opt in to these at a later stage
'svelte/button-has-type': 'off',
'svelte/require-each-key': 'off',
'svelte/no-at-html-tags': 'off',
'svelte/no-unused-svelte-ignore': 'off',
'svelte/require-stores-init': 'off'
},
globals: {
NodeJS: true,
$$Generic: 'readable'
}
overrides: [
{
files: ['*.svelte'],
parser: 'svelte-eslint-parser',
parserOptions: {
parser: '@typescript-eslint/parser'
},
rules: {
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^\\$\\$(Props|Events|Slots|Generic)$'
}
]
}
},
{
files: ['*.ts'],
parser: '@typescript-eslint/parser',
rules: {
'@typescript-eslint/ban-types': [
'error',
{
extendDefaults: true,
types: {
'{}': false
}
}
]
}
}
]
};
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,12 @@ Render `Command` inside of the popover content:

You can find global stylesheets to drop in as a starting point for styling. See [src/styles/cmdk](src/styles/cmdk) for examples.

### Render Delegation

Each of the components (except the dialog) accept an `asChild` prop that can be used to render a custom element in place of the default. When using this prop, you'll need to check the components slot props to see what attributes & actions you'll need to pass to your custom element.

Components that contain only a single element will just have `attrs` & `action` slot props, or just `attrs`. Components that contain multiple elements will have an `attrs` and possibly an `actions` object whose properties are the attributes and actions for each element.

## FAQ

**Accessible?** Yes. Labeling, aria attributes, and DOM ordering tested with Voice Over and Chrome DevTools. [Dialog](#dialog-cmdk-dialog-cmdk-overlay) composes an accessible Dialog implementation.
Expand Down
30 changes: 20 additions & 10 deletions src/lib/cmdk/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,28 @@ const defaults = {
loop: false,
onValueChange: undefined,
value: undefined,
filter: defaultFilter
filter: defaultFilter,
ids: {
root: generateId(),
list: generateId(),
label: generateId(),
input: generateId()
}
} satisfies CommandProps;

export function createCommand(props: CommandProps) {
const withDefaults = { ...defaults, ...removeUndefined(props) } satisfies CommandProps;
const ids = {
root: generateId(),
list: generateId(),
label: generateId(),
input: generateId(),
...props.ids
};

const withDefaults = {
...defaults,
...removeUndefined(props)
} satisfies CommandProps;

const state =
props.state ??
Expand All @@ -90,16 +107,9 @@ export function createCommand(props: CommandProps) {
const allIds = writable<Map<string, string>>(new Map()); // id → value
const commandEl = writable<HTMLDivElement | null>(null);

const options = toWritableStores(omit(withDefaults, 'value'));
const options = toWritableStores(omit(withDefaults, 'value', 'ids'));
const { shouldFilter, loop, filter, label } = options;

const ids = {
root: generateId(),
list: generateId(),
label: generateId(),
input: generateId()
};

const context: Context = {
value: (id, value) => {
if (value !== get(allIds).get(id)) {
Expand Down
65 changes: 47 additions & 18 deletions src/lib/cmdk/components/Command.svelte
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
<script lang="ts">
import { srOnlyStyles, styleToString } from '$lib/internal/index.js';
import {
addEventListener,
executeCallbacks,
srOnlyStyles,
styleToString
} from '$lib/internal/index.js';
import { createCommand } from '../command.js';
import type { CommandProps } from '../types.js';
type $$Props = CommandProps & {
onKeydown?: (e: KeyboardEvent) => void;
asChild?: boolean;
};
export let label: $$Props['label'] = undefined;
Expand All @@ -15,11 +21,13 @@
export let loop: $$Props['loop'] = undefined;
export let onKeydown: $$Props['onKeydown'] = undefined;
export let state: $$Props['state'] = undefined;
export let ids: $$Props['ids'] = undefined;
export let asChild: $$Props['asChild'] = false;
const {
commandEl,
handleRootKeydown,
ids,
ids: commandIds,
state: stateStore
} = createCommand({
label,
Expand All @@ -33,7 +41,8 @@
}
},
loop,
state
state,
ids
});
function syncValueAndState(value: string | undefined) {
Expand All @@ -46,27 +55,47 @@
function rootAction(node: HTMLDivElement) {
commandEl.set(node);
const unsubEvents = executeCallbacks(addEventListener(node, 'keydown', handleKeydown));
return {
destroy: unsubEvents
};
}
const rootAttrs = {
role: 'application',
id: commandIds.root,
'data-cmdk-root': ''
};
const labelAttrs = {
'data-cmdk-label': '',
for: commandIds.input,
id: commandIds.label,
style: styleToString(srOnlyStyles)
};
function handleKeydown(e: KeyboardEvent) {
onKeydown?.(e);
if (e.defaultPrevented) return;
handleRootKeydown(e);
}
const root = {
action: rootAction,
attrs: rootAttrs
};
</script>

<!-- eslint-disable-next-line svelte/valid-compile -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<div
use:rootAction
on:keydown={handleKeydown}
role="application"
id={ids.root}
data-cmdk-root=""
{...$$restProps}
>
<label data-cmdk-label="" for={ids.input} id={ids.label} style={styleToString(srOnlyStyles)}>
{label ?? ''}
</label>
<slot />
</div>
{#if asChild}
<slot {root} label={{ attrs: labelAttrs }} />
{:else}
<div use:rootAction {...rootAttrs} {...$$restProps}>
<!-- svelte-ignore a11y-label-has-associated-control applied in attrs -->
<label {...labelAttrs}>
{label ?? ''}
</label>
<slot {root} label={{ attrs: labelAttrs }} />
</div>
{/if}
17 changes: 14 additions & 3 deletions src/lib/cmdk/components/CommandEmpty.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
// eslint-disable-next-line @typescript-eslint/no-unused-vars
type $$Props = EmptyProps;
export let asChild: $$Props['asChild'] = false;
let isFirstRender = true;
onMount(() => {
Expand All @@ -15,10 +17,19 @@
const state = getState();
const render = derived(state, ($state) => $state.filtered.count === 0);
const attrs = {
'data-cmdk-empty': '',
role: 'presentation'
};
</script>

{#if !isFirstRender && $render}
<div data-cmdk-empty="" role="presentation" {...$$restProps}>
<slot />
</div>
{#if asChild}
<slot {attrs} />
{:else}
<div {...attrs} {...$$restProps}>
<slot />
</div>
{/if}
{/if}
58 changes: 42 additions & 16 deletions src/lib/cmdk/components/CommandGroup.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
export let heading: $$Props['heading'] = undefined;
export let value = '';
export let alwaysRender: $$Props['alwaysRender'] = false;
export let asChild: $$Props['asChild'] = false;
const { id } = createGroup(alwaysRender);
Expand All @@ -29,7 +30,7 @@
return unsubGroup;
});
function groupAction(node: HTMLElement) {
function containerAction(node: HTMLElement) {
if (value) {
context.value(id, value);
node.setAttribute(VALUE_ATTR, value);
Expand All @@ -45,22 +46,47 @@
context.value(id, value);
node.setAttribute(VALUE_ATTR, value);
}
$: containerAttrs = {
'data-cmdk-group': '',
role: 'presentation',
hidden: $render ? undefined : true,
'data-value': value
};
const headingAttrs = {
'data-cmdk-group-heading': '',
'aria-hidden': true,
id: headingId
};
$: groupAttrs = {
'data-cmdk-group-items': '',
role: 'group',
'aria-labelledby': heading ? headingId : undefined
};
$: container = {
action: containerAction,
attrs: containerAttrs
};
$: group = {
attrs: groupAttrs
};
</script>

<div
use:groupAction
data-cmdk-group=""
role="presentation"
hidden={$render ? undefined : true}
data-value={value}
{...$$restProps}
>
{#if heading}
<div data-cmdk-group-heading="" aria-hidden id={headingId}>
{heading}
{#if asChild}
<slot {container} {group} heading={{ attrs: headingAttrs }} />
{:else}
<div use:containerAction {...containerAttrs} {...$$restProps}>
{#if heading}
<div {...headingAttrs}>
{heading}
</div>
{/if}
<div {...groupAttrs}>
<slot {container} {group} heading={{ attrs: headingAttrs }} />
</div>
{/if}
<div data-cmdk-group-items="" role="group" aria-labelledby={heading ? headingId : undefined}>
<slot />
</div>
</div>
{/if}
Loading

0 comments on commit b259ce9

Please sign in to comment.