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
7 changes: 7 additions & 0 deletions .changeset/date-input-segment-lookup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@ark-ui/solid": patch
"@ark-ui/vue": patch
---

Fixed `DateInput.Segment` resolving segments by `type`, so segments sharing a type all rendered the first match's text.
Literal separators like `:` and `,` rendered as `/`.
8 changes: 8 additions & 0 deletions .changeset/vue-aschild-duplicate-class.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@ark-ui/vue": patch
---

- Fixed `asChild` duplicating the child element's `class`, so `<ark.div class="parent" as-child><span class="child">`
rendered `class="child parent child"` instead of `class="parent child"`.
- Fixed `asChild` applying props to a leading comment node, which silently dropped them when a comment or a false `v-if`
preceded the child element.
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,9 @@ export interface DateInputSegmentContextProps {
export const DateInputSegmentContext = (props: DateInputSegmentContextProps) => {
const api = useDateInputContext()
const segmentGroupProps = useDateInputSegmentGroupPropsContext()
return <Index each={api().getSegments(segmentGroupProps)}>{(segment) => props.children(segment())}</Index>
return (
<Index each={api().getSegments(segmentGroupProps)}>
{(segment, index) => props.children({ ...segment(), index } as DateSegment)}
</Index>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,18 @@ export interface DateInputSegmentProps extends HTMLProps<'span'>, DateInputSegme

const splitSegmentProps = createSplitProps<Pick<SegmentProps, 'segment'>>()

type IndexedSegment = SegmentProps['segment'] & { index?: number }

export const DateInputSegment = (props: DateInputSegmentProps) => {
const [segmentProps, localProps] = splitSegmentProps(props, ['segment'])
const segmentGroupProps = useDateInputSegmentGroupPropsContext()
const api = useDateInputContext()

// `type` alone doesn't identify a segment, since multiple segments can share it (e.g. `literal`)
const currentSegment = createMemo(() => {
const index = (segmentProps.segment as IndexedSegment).index
const segments = api().getSegments(segmentGroupProps)
return segments.find((s) => s.type === segmentProps.segment.type) ?? segmentProps.segment
return (typeof index === 'number' ? segments[index] : undefined) ?? segmentProps.segment
})

const mergedProps = mergeProps(
Expand Down
14 changes: 14 additions & 0 deletions packages/solid/src/components/date-input/tests/date-input.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,20 @@ describe('Date Input', () => {
expect(document.querySelector('[data-type="timeZoneName"]')).toBeInTheDocument()
})

it('should render each literal segment with its own text, not the first literal', () => {
render(() => (
<ComponentUnderTest
defaultValue={[parseZonedDateTime('2025-02-03T08:45:00[America/Los_Angeles]')]}
granularity="minute"
/>
))
const literalSegments = document.querySelectorAll('[data-type="literal"]')
const literalTexts = Array.from(literalSegments).map((segment) => segment.textContent)
expect(literalTexts.length).toBeGreaterThan(1)
expect(literalTexts).toContain('/')
expect(literalTexts).toContain(':')
})

it('should hide timeZoneName segment when hideTimeZone is true', () => {
render(() => (
<ComponentUnderTest
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const segmentGroupProps = useDateInputSegmentGroupPropsContext()
const keyedSegments = computed(() =>
dateInput.value.getSegments(segmentGroupProps!.value).map((segment, index) => ({
...segment,
index,
key: `${segment.type}-${index}`,
})),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,13 @@ const dateInput = useDateInputContext()

useForwardExpose()

type IndexedSegment = SegmentProps['segment'] & { index?: number }

const currentSegment = computed(() => {
const segments = dateInput.value.getSegments(segmentGroupProps!.value)
return segments.find((s) => s.type === props.segment.type) ?? props.segment
// `type` alone doesn't identify a segment, since multiple segments can share it (e.g. `literal`)
const index = (props.segment as IndexedSegment).index
return (typeof index === 'number' ? segments[index] : undefined) ?? props.segment
})

const mergedProps = computed(() =>
Expand Down
14 changes: 14 additions & 0 deletions packages/vue/src/components/date-input/tests/date-input.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,20 @@ describe('Date Input', () => {
expect(document.querySelector('[data-type="timeZoneName"]')).toBeInTheDocument()
})

it('should render each literal segment with its own text, not the first literal', () => {
render(ComponentUnderTest, {
props: {
defaultValue: [parseZonedDateTime('2025-02-03T08:45:00[America/Los_Angeles]')],
granularity: 'minute',
},
})
const literalSegments = document.querySelectorAll('[data-type="literal"]')
const literalTexts = Array.from(literalSegments).map((segment) => segment.textContent)
expect(literalTexts.length).toBeGreaterThan(1)
expect(literalTexts).toContain('/')
expect(literalTexts).toContain(':')
})

it('should hide timeZoneName segment when hideTimeZone is true', () => {
render(ComponentUnderTest, {
props: {
Expand Down
70 changes: 70 additions & 0 deletions packages/vue/src/components/factory.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import user from '@testing-library/user-event'
import { render, screen } from '@testing-library/vue'
import { createCommentVNode, defineComponent, h, nextTick, ref } from 'vue'
import { ark } from './factory.ts'

const ComponentUnderTest = (
Expand Down Expand Up @@ -33,6 +34,75 @@ describe('Factory', () => {
expect(screen.getByText('Ark UI')).toBeVisible()
})

it('should not duplicate the class of a plain child element', () => {
render(
<ark.div class="parent" asChild>
<span data-testid="child" class="child">
Ark UI
</span>
</ark.div>,
)
const child = screen.getByTestId('child')
expect(child.className.split(/\s+/).filter(Boolean).sort()).toEqual(['child', 'parent'])
})

it('should call each handler of a plain child element once', async () => {
const onClickParent = vi.fn()
const onClickChild = vi.fn()
render(
<ark.div onClick={onClickParent} asChild>
<button type="button" data-testid="child" onClick={onClickChild} />
</ark.div>,
)
await user.click(screen.getByTestId('child'))
expect(onClickParent).toHaveBeenCalledTimes(1)
expect(onClickChild).toHaveBeenCalledTimes(1)
})

it('should apply props to the first non-comment child', () => {
render(
defineComponent({
setup: () => () =>
h(ark.div, { class: 'parent', asChild: true }, () => [
createCommentVNode('placeholder'),
h('span', { 'data-testid': 'child', class: 'child' }, 'Ark UI'),
]),
}),
)
const child = screen.getByTestId('child')
expect(child.className.split(/\s+/).filter(Boolean).sort()).toEqual(['child', 'parent'])
})

it('should patch reactive props onto the same child element', async () => {
const parentClass = ref('a')
const { container } = render(
defineComponent({
setup: () => () =>
h(ark.div, { class: parentClass.value, asChild: true }, () => [
h('span', { 'data-testid': 'child', class: 'child' }, 'Ark UI'),
]),
}),
)
const before = screen.getByTestId('child')
expect(before.className.split(/\s+/).filter(Boolean).sort()).toEqual(['a', 'child'])

parentClass.value = 'b'
await nextTick()

const after = container.querySelector('[data-testid="child"]')
expect(after).toBe(before)
expect(after?.className.split(/\s+/).filter(Boolean).sort()).toEqual(['b', 'child'])
})

it('should render comment-only children untouched', () => {
const { container } = render(
defineComponent({
setup: () => () => h(ark.div, { class: 'parent', asChild: true }, () => [createCommentVNode('v-if')]),
}),
)
expect(container.innerHTML).toBe('<!--v-if-->')
})

it('should merge events', async () => {
const onClickParent = vi.fn()
const onClickChild = vi.fn()
Expand Down
2 changes: 1 addition & 1 deletion packages/vue/src/components/popover/examples/factory.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { ark } from '@ark-ui/vue/factory'
</script>

<template>
<ark.span asChild>
<ark.span as-child>
<a href="#">Ark UI</a>
</ark.span>
</template>
22 changes: 11 additions & 11 deletions packages/vue/src/utils/dynamic.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { mergeProps } from '@zag-js/vue'
import { Fragment, type VNode, cloneVNode, defineComponent } from 'vue'
import { Comment, Fragment, type VNode, cloneVNode, defineComponent } from 'vue'

export const Dynamic = defineComponent({
name: 'Dynamic',
Expand All @@ -8,20 +8,20 @@ export const Dynamic = defineComponent({
return () => {
if (!slots.default) return null
const children = renderSlotFragments(slots.default())
const [firstChildren, ...otherChildren] = children
const index = children.findIndex((child) => child.type !== Comment)
if (index === -1) return children

if (firstChildren && Object.keys(attrs).length > 0) {
const firstChildren = children[index]

if (Object.keys(attrs).length > 0) {
delete firstChildren.props?.ref
// props are cleared below so `cloneVNode` doesn't merge the child's own props a second time
const mergedProps = mergeProps(attrs, firstChildren.props ?? {})
const cloned = cloneVNode(firstChildren, mergedProps)
for (const prop in mergedProps) {
if (prop.startsWith('on')) {
cloned.props ||= {}
cloned.props[prop] = mergedProps[prop]
}
}
const cloned = cloneVNode({ ...firstChildren, props: {} }, mergedProps)

return children.length === 1 ? cloned : [cloned, ...otherChildren]
if (children.length === 1) return cloned
children[index] = cloned
return children
}

return children
Expand Down
7 changes: 5 additions & 2 deletions website/src/content/pages/guides/composition.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,15 @@ You can use the `ark` factory to create your own elements that work just like Ar

<ExampleCode id="factory" component="popover" />

This will produce the following HTML:
The factory renders the child element in place of the `span`, so this produces:

```html
<span id="child" class="parent child" style="background: red; color: blue;">Ark UI</span>
<a href="#">Ark UI</a>
```

Any props you pass to `ark.span` are merged onto the child element, which is how the factory forwards styling and behavior to
whatever you render.

## ID Composition

When composing components that need to work together, share IDs between them using the `ids` prop for proper
Expand Down