@a4ui/core 0.38.0 → 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/README.md +66 -9
  2. package/dist/{Collapse-Cr5cDqAl.js → Collapse-CrKiFD8K.js} +173 -124
  3. package/dist/charts/Sparkline.d.ts +2 -0
  4. package/dist/charts.js +8 -5
  5. package/dist/commerce.js +59 -59
  6. package/dist/elements.css +248 -151
  7. package/dist/elements.iife.js +1 -1
  8. package/dist/elements.js +1025 -1015
  9. package/dist/full.css +248 -151
  10. package/dist/index.d.ts +2 -1
  11. package/dist/index.js +4723 -4656
  12. package/dist/lib/anchored.d.ts +7 -0
  13. package/dist/lib/direction.d.ts +18 -0
  14. package/dist/styles.css +27 -11
  15. package/dist/theme.css +1559 -0
  16. package/dist/ui/DirectionProvider.d.ts +47 -0
  17. package/dist/ui/Drawer.d.ts +6 -2
  18. package/dist/ui/Sidebar.d.ts +8 -2
  19. package/dist/ui/Toast.d.ts +5 -1
  20. package/package.json +5 -3
  21. package/preset.d.ts +2 -1
  22. package/preset.js +16 -5
  23. package/src/charts/BarList.tsx +1 -1
  24. package/src/charts/Sparkline.tsx +5 -1
  25. package/src/commerce/CartLine.tsx +1 -1
  26. package/src/commerce/FacetSidebar.tsx +1 -1
  27. package/src/commerce/ProductCard.tsx +1 -1
  28. package/src/index.ts +7 -1
  29. package/src/layout/NavGroup.tsx +2 -2
  30. package/src/lib/anchored.ts +13 -0
  31. package/src/lib/direction.ts +35 -0
  32. package/src/ui/Accordion.tsx +2 -2
  33. package/src/ui/ActivityFeed.tsx +2 -2
  34. package/src/ui/AlertDialog.tsx +8 -4
  35. package/src/ui/Anchor.tsx +1 -1
  36. package/src/ui/AnnouncementBar.tsx +3 -3
  37. package/src/ui/ArtifactPanel.tsx +1 -1
  38. package/src/ui/AvailabilityPicker.tsx +2 -2
  39. package/src/ui/AvatarGroup.tsx +2 -2
  40. package/src/ui/BackToTop.tsx +1 -1
  41. package/src/ui/Badge.tsx +1 -1
  42. package/src/ui/BlockEditor.tsx +1 -1
  43. package/src/ui/ButtonGroup.tsx +1 -1
  44. package/src/ui/Callout.tsx +1 -1
  45. package/src/ui/Carousel.tsx +17 -9
  46. package/src/ui/Cascader.tsx +2 -2
  47. package/src/ui/CategoryStrip.tsx +4 -1
  48. package/src/ui/CodeEditor.tsx +5 -5
  49. package/src/ui/CodeTabs.tsx +1 -1
  50. package/src/ui/Combobox.tsx +2 -1
  51. package/src/ui/Command.tsx +9 -5
  52. package/src/ui/Comment.tsx +1 -1
  53. package/src/ui/Configurator.tsx +2 -2
  54. package/src/ui/ContextMenu.tsx +2 -1
  55. package/src/ui/DateField.tsx +4 -4
  56. package/src/ui/DateRangePicker.tsx +4 -4
  57. package/src/ui/DiffViewer.tsx +4 -3
  58. package/src/ui/DirectionProvider.tsx +82 -0
  59. package/src/ui/Drawer.tsx +29 -8
  60. package/src/ui/Dropdown.tsx +2 -1
  61. package/src/ui/EmojiPicker.tsx +5 -2
  62. package/src/ui/Expandable.tsx +2 -2
  63. package/src/ui/Faq.tsx +1 -1
  64. package/src/ui/Field.tsx +1 -1
  65. package/src/ui/FloatingActionButton.tsx +2 -2
  66. package/src/ui/FollowingPointer.tsx +2 -2
  67. package/src/ui/Hero.tsx +2 -2
  68. package/src/ui/HoldToConfirm.tsx +2 -2
  69. package/src/ui/HoverCard.tsx +2 -1
  70. package/src/ui/Image.tsx +9 -5
  71. package/src/ui/InlineSelect.tsx +1 -1
  72. package/src/ui/InputGroup.tsx +2 -2
  73. package/src/ui/Item.tsx +1 -1
  74. package/src/ui/JsonViewer.tsx +2 -2
  75. package/src/ui/Kanban.tsx +1 -1
  76. package/src/ui/KpiBlock.tsx +1 -1
  77. package/src/ui/Lightbox.tsx +9 -6
  78. package/src/ui/MasterDetail.tsx +1 -1
  79. package/src/ui/Mentions.tsx +1 -1
  80. package/src/ui/Menubar.tsx +9 -8
  81. package/src/ui/Modal.tsx +9 -5
  82. package/src/ui/ModelPicker.tsx +2 -1
  83. package/src/ui/MultiSelect.tsx +5 -5
  84. package/src/ui/NativeSelect.tsx +2 -2
  85. package/src/ui/NavigationMenu.tsx +5 -4
  86. package/src/ui/NotificationCenter.tsx +1 -1
  87. package/src/ui/NotificationStack.tsx +1 -1
  88. package/src/ui/NumberInput.tsx +2 -2
  89. package/src/ui/OnboardingChecklist.tsx +2 -2
  90. package/src/ui/Pagination.tsx +2 -2
  91. package/src/ui/PillSearch.tsx +3 -3
  92. package/src/ui/PivotTable.tsx +0 -0
  93. package/src/ui/Popover.tsx +2 -1
  94. package/src/ui/Portal.tsx +3 -1
  95. package/src/ui/PresenceAvatars.tsx +4 -4
  96. package/src/ui/PricingTable.tsx +1 -1
  97. package/src/ui/Prose.tsx +3 -3
  98. package/src/ui/QueryBuilder.tsx +4 -4
  99. package/src/ui/Rating.tsx +6 -2
  100. package/src/ui/ReasoningTrace.tsx +1 -1
  101. package/src/ui/ScrollArea.tsx +1 -1
  102. package/src/ui/ScrollProgress.tsx +2 -2
  103. package/src/ui/SideRail.tsx +3 -3
  104. package/src/ui/Sidebar.tsx +43 -23
  105. package/src/ui/SlashMenu.tsx +1 -1
  106. package/src/ui/Snippet.tsx +6 -3
  107. package/src/ui/Sortable.tsx +1 -1
  108. package/src/ui/SpecSheet.tsx +1 -1
  109. package/src/ui/SpeedDial.tsx +1 -1
  110. package/src/ui/Splitter.tsx +8 -3
  111. package/src/ui/SpreadsheetGrid.tsx +4 -4
  112. package/src/ui/Stepper.tsx +1 -1
  113. package/src/ui/SuggestionChips.tsx +1 -1
  114. package/src/ui/Switch.tsx +1 -1
  115. package/src/ui/Table.tsx +5 -2
  116. package/src/ui/TimeField.tsx +8 -8
  117. package/src/ui/Timeline.tsx +2 -2
  118. package/src/ui/Toast.tsx +9 -3
  119. package/src/ui/ToolCallTimeline.tsx +2 -2
  120. package/src/ui/Tooltip.tsx +2 -1
  121. package/src/ui/Tour.tsx +1 -1
  122. package/src/ui/TransactionFeed.tsx +1 -1
  123. package/src/ui/Transfer.tsx +2 -2
  124. package/src/ui/Tree.tsx +2 -2
  125. package/src/ui/TreeSelect.tsx +2 -2
  126. package/src/ui/TreeTable.tsx +4 -4
  127. package/src/ui/UsageMeter.tsx +1 -1
  128. package/src/ui/VideoPlayerShell.tsx +2 -2
  129. package/src/ui/internal/CalendarCore.tsx +6 -6
@@ -6,7 +6,9 @@ import { ChevronLeft, ChevronRight } from 'lucide-solid'
6
6
  import { For, createSignal, onCleanup, onMount, type JSX } from 'solid-js'
7
7
 
8
8
  import { cn } from '../lib/cn'
9
+ import { readingKey } from '../lib/direction'
9
10
  import { motionReduced } from '../lib/motion'
11
+ import { useDirection } from './DirectionProvider'
10
12
 
11
13
  export interface CarouselProps {
12
14
  /** Slides to page through, one visible at a time. */
@@ -37,6 +39,7 @@ export interface CarouselProps {
37
39
  * ```
38
40
  */
39
41
  export function Carousel(props: CarouselProps): JSX.Element {
42
+ const dir = useDirection()
40
43
  const [index, setIndex] = createSignal(0)
41
44
  const count = (): number => props.slides.length
42
45
 
@@ -49,10 +52,11 @@ export function Carousel(props: CarouselProps): JSX.Element {
49
52
  const next = (): void => go(index() + 1)
50
53
 
51
54
  const onKeyDown = (e: KeyboardEvent): void => {
52
- if (e.key === 'ArrowLeft') {
55
+ const key = readingKey(e.key, dir())
56
+ if (key === 'ArrowLeft') {
53
57
  e.preventDefault()
54
58
  prev()
55
- } else if (e.key === 'ArrowRight') {
59
+ } else if (key === 'ArrowRight') {
56
60
  e.preventDefault()
57
61
  next()
58
62
  }
@@ -80,8 +84,10 @@ export function Carousel(props: CarouselProps): JSX.Element {
80
84
  const width = viewport?.clientWidth ?? 1
81
85
  const dx = dragPx()
82
86
  const threshold = Math.max(48, width * 0.2)
83
- if (dx <= -threshold) next()
84
- else if (dx >= threshold) prev()
87
+ // A swipe toward the start of the line goes forward: left in LTR, right in RTL.
88
+ const forward = dir() === 'rtl' ? dx : -dx
89
+ if (forward >= threshold) next()
90
+ else if (forward <= -threshold) prev()
85
91
  setDragPx(0)
86
92
  setDragging(false)
87
93
  }
@@ -119,7 +125,9 @@ export function Carousel(props: CarouselProps): JSX.Element {
119
125
  swipeOn() && count() > 1 && 'touch-pan-y cursor-grab active:cursor-grabbing',
120
126
  !dragging() && 'transition-transform duration-500 ease-out',
121
127
  )}
122
- style={{ transform: `translateX(calc(-${index() * 100}% + ${dragPx()}px))` }}
128
+ style={{
129
+ transform: `translateX(calc(${index() * -100}% * var(--dir-x, 1) + ${dragPx()}px))`,
130
+ }}
123
131
  onPointerDown={onPointerDown}
124
132
  onPointerMove={onPointerMove}
125
133
  onPointerUp={endDrag}
@@ -132,17 +140,17 @@ export function Carousel(props: CarouselProps): JSX.Element {
132
140
  type="button"
133
141
  aria-label="Previous slide"
134
142
  onClick={prev}
135
- class="absolute left-2 top-1/2 flex h-9 w-9 -translate-y-1/2 items-center justify-center rounded-full bg-primary text-primary-foreground shadow transition-opacity hover:opacity-90"
143
+ class="absolute start-2 top-1/2 flex h-9 w-9 -translate-y-1/2 items-center justify-center rounded-full bg-primary text-primary-foreground shadow transition-opacity hover:opacity-90"
136
144
  >
137
- <ChevronLeft class="h-5 w-5" />
145
+ <ChevronLeft class="h-5 w-5 rtl:-scale-x-100" />
138
146
  </button>
139
147
  <button
140
148
  type="button"
141
149
  aria-label="Next slide"
142
150
  onClick={next}
143
- class="absolute right-2 top-1/2 flex h-9 w-9 -translate-y-1/2 items-center justify-center rounded-full bg-primary text-primary-foreground shadow transition-opacity hover:opacity-90"
151
+ class="absolute end-2 top-1/2 flex h-9 w-9 -translate-y-1/2 items-center justify-center rounded-full bg-primary text-primary-foreground shadow transition-opacity hover:opacity-90"
144
152
  >
145
- <ChevronRight class="h-5 w-5" />
153
+ <ChevronRight class="h-5 w-5 rtl:-scale-x-100" />
146
154
  </button>
147
155
  </div>
148
156
 
@@ -126,7 +126,7 @@ export function Cascader(props: CascaderProps): JSX.Element {
126
126
  <div class="absolute z-50 mt-1 flex overflow-hidden rounded-md border border-border bg-card text-card-foreground shadow-md">
127
127
  <For each={columns()}>
128
128
  {(column, colIndex) => (
129
- <ul class="min-w-[10rem] max-h-64 overflow-y-auto border-r border-border p-1 last:border-r-0">
129
+ <ul class="min-w-[10rem] max-h-64 overflow-y-auto border-e border-border p-1 last:border-e-0">
130
130
  <For each={column}>
131
131
  {(opt) => {
132
132
  const hasChildren = () => !!opt.children && opt.children.length > 0
@@ -142,7 +142,7 @@ export function Cascader(props: CascaderProps): JSX.Element {
142
142
  >
143
143
  <span class="truncate">{opt.label}</span>
144
144
  <Show when={hasChildren()}>
145
- <ChevronRight class="h-4 w-4 shrink-0 opacity-60" />
145
+ <ChevronRight class="h-4 w-4 shrink-0 opacity-60 rtl:-scale-x-100" />
146
146
  </Show>
147
147
  </li>
148
148
  )
@@ -5,6 +5,8 @@ import type { JSX } from 'solid-js'
5
5
  import { For } from 'solid-js'
6
6
 
7
7
  import { cn } from '../lib/cn'
8
+ import { readingKey } from '../lib/direction'
9
+ import { useDirection } from './DirectionProvider'
8
10
 
9
11
  /** A single selectable category: value, label, and optional leading icon. */
10
12
  export interface CategoryItem {
@@ -31,6 +33,7 @@ export interface CategoryStripProps {
31
33
  * ```
32
34
  */
33
35
  export function CategoryStrip(props: CategoryStripProps): JSX.Element {
36
+ const dir = useDirection()
34
37
  const activeIndex = () => props.items.findIndex((item) => item.value === props.value)
35
38
 
36
39
  const onKeyDown = (event: KeyboardEvent) => {
@@ -39,7 +42,7 @@ export function CategoryStrip(props: CategoryStripProps): JSX.Element {
39
42
  const count = props.items.length
40
43
  if (count === 0) return
41
44
  const current = activeIndex()
42
- const delta = event.key === 'ArrowRight' ? 1 : -1
45
+ const delta = readingKey(event.key, dir()) === 'ArrowRight' ? 1 : -1
43
46
  const next = ((current === -1 ? 0 : current) + delta + count) % count
44
47
  props.onChange(props.items[next].value)
45
48
  }
@@ -233,13 +233,13 @@ export function CodeEditor(props: CodeEditorProps): JSX.Element {
233
233
  }
234
234
 
235
235
  return (
236
- <div class={cn('relative flex overflow-hidden rounded-md border border-border bg-card', local.class)}>
236
+ <div
237
+ dir="ltr"
238
+ class={cn('relative flex overflow-hidden rounded-md border border-border bg-card', local.class)}
239
+ >
237
240
  <div
238
241
  aria-hidden="true"
239
- class={cn(
240
- SHARED_TEXT,
241
- 'select-none border-r border-border bg-muted text-right text-muted-foreground',
242
- )}
242
+ class={cn(SHARED_TEXT, 'select-none border-e border-border bg-muted text-end text-muted-foreground')}
243
243
  >
244
244
  <For each={lines()}>{(_line, index) => <div>{index() + 1}</div>}</For>
245
245
  </div>
@@ -52,7 +52,7 @@ export function CodeTabs(props: CodeTabsProps): JSX.Element {
52
52
 
53
53
  return (
54
54
  <div class={cn('overflow-hidden rounded-lg border border-border bg-card', props.class)}>
55
- <div class="flex items-center justify-between border-b border-border pr-2">
55
+ <div class="flex items-center justify-between border-b border-border pe-2">
56
56
  <div class="flex" role="tablist">
57
57
  <For each={props.tabs}>
58
58
  {(tab, index) => (
@@ -4,6 +4,7 @@ import { Check, ChevronsUpDown } from 'lucide-solid'
4
4
  import type { JSX } from 'solid-js'
5
5
 
6
6
  import { cn } from '../lib/cn'
7
+ import { portalDir } from '../lib/direction'
7
8
 
8
9
  interface ComboboxProps {
9
10
  /** Full list of selectable string options (filtered as the user types). */
@@ -57,7 +58,7 @@ export function Combobox(props: ComboboxProps): JSX.Element {
57
58
  </KCombobox.Icon>
58
59
  </KCombobox.Trigger>
59
60
  </KCombobox.Control>
60
- <KCombobox.Portal>
61
+ <KCombobox.Portal ref={portalDir}>
61
62
  <KCombobox.Content class="z-50 overflow-hidden rounded-md border border-border bg-card p-1 text-card-foreground shadow-sm">
62
63
  <KCombobox.Listbox class="max-h-60 overflow-y-auto" />
63
64
  </KCombobox.Content>
@@ -3,10 +3,14 @@
3
3
  // handles fuzzy filtering (label + hint + group), keyboard navigation
4
4
  // (↑/↓/Enter), optional grouping, and query reset on close. Theme-agnostic:
5
5
  // every color comes from a semantic token, never a literal.
6
- import { Dialog } from '@kobalte/core/dialog'
6
+ // Namespace import on purpose: Kobalte's `AlertDialog` is built with Object.assign on the
7
+ // same `DialogRoot` function as `Dialog`, so `Dialog.Content` depends on module load order (#118).
8
+ // The named exports (`Root`, `Content`, ...) are not affected.
9
+ import * as Dialog from '@kobalte/core/dialog'
7
10
  import { createEffect, createMemo, createSignal, For, Show, type JSX } from 'solid-js'
8
11
 
9
12
  import { cn } from '../lib/cn'
13
+ import { portalDir } from '../lib/direction'
10
14
 
11
15
  /** A single selectable row in the palette. */
12
16
  export interface CommandItem {
@@ -93,8 +97,8 @@ export function Command(props: CommandProps): JSX.Element {
93
97
  }
94
98
 
95
99
  return (
96
- <Dialog open={props.open} onOpenChange={props.onOpenChange}>
97
- <Dialog.Portal>
100
+ <Dialog.Root open={props.open} onOpenChange={props.onOpenChange}>
101
+ <Dialog.Portal ref={portalDir}>
98
102
  <Dialog.Overlay class="modal-overlay fixed inset-0 z-40 bg-background/80 backdrop-blur-sm" />
99
103
  <div class="fixed inset-x-0 top-[14vh] z-50 mx-auto w-full max-w-lg px-4">
100
104
  <Dialog.Content
@@ -129,7 +133,7 @@ export function Command(props: CommandProps): JSX.Element {
129
133
  onMouseEnter={() => setActive(i())}
130
134
  onClick={() => pick(item)}
131
135
  class={cn(
132
- 'flex w-full items-center justify-between gap-3 rounded-md px-3 py-2 text-left transition-colors',
136
+ 'flex w-full items-center justify-between gap-3 rounded-md px-3 py-2 text-start transition-colors',
133
137
  active() === i() ? 'bg-primary/15 text-foreground' : 'text-muted-foreground',
134
138
  )}
135
139
  >
@@ -153,6 +157,6 @@ export function Command(props: CommandProps): JSX.Element {
153
157
  </Dialog.Content>
154
158
  </div>
155
159
  </Dialog.Portal>
156
- </Dialog>
160
+ </Dialog.Root>
157
161
  )
158
162
  }
@@ -80,7 +80,7 @@ export function Comment(props: CommentProps): JSX.Element {
80
80
  </div>
81
81
  </div>
82
82
  <Show when={props.comment.replies?.length}>
83
- <div class="mt-4 space-y-4 border-l border-border pl-4">
83
+ <div class="mt-4 space-y-4 border-s border-border ps-4">
84
84
  <For each={props.comment.replies}>
85
85
  {(reply) => <Comment comment={reply} actions={props.actions} />}
86
86
  </For>
@@ -125,7 +125,7 @@ export function Configurator(props: ConfiguratorProps): JSX.Element {
125
125
  >
126
126
  {o.label}
127
127
  <Show when={o.price}>
128
- <span class="ml-1 opacity-70">· {fmt(o.price as number)}</span>
128
+ <span class="ms-1 opacity-70">· {fmt(o.price as number)}</span>
129
129
  </Show>
130
130
  </button>
131
131
  )}
@@ -144,7 +144,7 @@ export function Configurator(props: ConfiguratorProps): JSX.Element {
144
144
  <span class="text-sm text-foreground">
145
145
  {it.label}
146
146
  <Show when={it.price}>
147
- <span class="ml-1 text-muted-foreground">· {fmt(it.price as number)}</span>
147
+ <span class="ms-1 text-muted-foreground">· {fmt(it.price as number)}</span>
148
148
  </Show>
149
149
  </span>
150
150
  <div class="flex items-center gap-2">
@@ -4,6 +4,7 @@ import type { JSX, ParentProps } from 'solid-js'
4
4
  import { For } from 'solid-js'
5
5
 
6
6
  import { cn } from '../lib/cn'
7
+ import { portalDir } from '../lib/direction'
7
8
 
8
9
  /** A single actionable row rendered by {@link ContextMenu}. */
9
10
  export interface ContextMenuItem {
@@ -35,7 +36,7 @@ export function ContextMenu(props: ContextMenuProps): JSX.Element {
35
36
  return (
36
37
  <KContextMenu>
37
38
  <KContextMenu.Trigger class={cn('inline-flex', props.class)}>{props.children}</KContextMenu.Trigger>
38
- <KContextMenu.Portal>
39
+ <KContextMenu.Portal ref={portalDir}>
39
40
  <KContextMenu.Content class="z-50 min-w-[10rem] overflow-hidden rounded-md border border-border bg-card p-1 text-card-foreground shadow-sm">
40
41
  <For each={props.items}>
41
42
  {(item) => (
@@ -7,9 +7,9 @@
7
7
  import { CalendarDays } from 'lucide-solid'
8
8
  import type { JSX } from 'solid-js'
9
9
  import { Show, createEffect, createMemo, createSignal, onCleanup } from 'solid-js'
10
- import { Portal } from 'solid-js/web'
10
+ import { Portal } from './Portal'
11
11
 
12
- import { createAnchorReflow } from '../lib/anchored'
12
+ import { anchoredLeft, createAnchorReflow } from '../lib/anchored'
13
13
  import { cn } from '../lib/cn'
14
14
  import { CalendarCore } from './internal/CalendarCore'
15
15
 
@@ -93,7 +93,7 @@ export function DateField(props: DateFieldProps): JSX.Element {
93
93
  const place = () => {
94
94
  if (!btnRef) return
95
95
  const r = btnRef.getBoundingClientRect()
96
- const left = Math.max(8, Math.min(r.left, window.innerWidth - POP_W - 8))
96
+ const left = anchoredLeft(btnRef, POP_W)
97
97
  setPos({ top: r.bottom + 4, left })
98
98
  }
99
99
 
@@ -155,7 +155,7 @@ export function DateField(props: DateFieldProps): JSX.Element {
155
155
  type="button"
156
156
  disabled={props.disabled}
157
157
  onClick={() => (open() ? close() : openPopover())}
158
- class="flex w-full items-center justify-between gap-2 rounded-md border border-input bg-background px-3 py-2 text-left text-sm text-foreground outline-none transition-colors a4-field disabled:cursor-not-allowed disabled:opacity-50"
158
+ class="flex w-full items-center justify-between gap-2 rounded-md border border-input bg-background px-3 py-2 text-start text-sm text-foreground outline-none transition-colors a4-field disabled:cursor-not-allowed disabled:opacity-50"
159
159
  >
160
160
  <span class={displayLabel(props.value, props.months ?? MONTHS) ? '' : 'text-muted-foreground'}>
161
161
  {displayLabel(props.value, props.months ?? MONTHS) ?? props.label ?? 'Select date'}
@@ -7,9 +7,9 @@
7
7
  import { CalendarDays } from 'lucide-solid'
8
8
  import type { JSX } from 'solid-js'
9
9
  import { createEffect, createMemo, createSignal, onCleanup, Show } from 'solid-js'
10
- import { Portal } from 'solid-js/web'
10
+ import { Portal } from './Portal'
11
11
 
12
- import { createAnchorReflow } from '../lib/anchored'
12
+ import { anchoredLeft, createAnchorReflow } from '../lib/anchored'
13
13
  import { cn } from '../lib/cn'
14
14
  import { CalendarCore } from './internal/CalendarCore'
15
15
 
@@ -94,7 +94,7 @@ export function DateRangePicker(props: DateRangePickerProps): JSX.Element {
94
94
  const place = () => {
95
95
  if (!btnRef) return
96
96
  const r = btnRef.getBoundingClientRect()
97
- const left = Math.max(8, Math.min(r.left, window.innerWidth - POP_W - 8))
97
+ const left = anchoredLeft(btnRef, POP_W)
98
98
  setPos({ top: r.bottom + 4, left })
99
99
  }
100
100
 
@@ -181,7 +181,7 @@ export function DateRangePicker(props: DateRangePickerProps): JSX.Element {
181
181
  type="button"
182
182
  disabled={props.disabled}
183
183
  onClick={() => (open() ? close() : openPopover())}
184
- class="flex w-full items-center justify-between gap-2 rounded-md border border-input bg-background px-3 py-2 text-left text-sm text-foreground outline-none transition-colors a4-field disabled:cursor-not-allowed disabled:opacity-50"
184
+ class="flex w-full items-center justify-between gap-2 rounded-md border border-input bg-background px-3 py-2 text-start text-sm text-foreground outline-none transition-colors a4-field disabled:cursor-not-allowed disabled:opacity-50"
185
185
  >
186
186
  <span class={triggerLabel() ? '' : 'text-muted-foreground'}>
187
187
  {triggerLabel() ?? props.label ?? 'Select range'}
@@ -131,6 +131,7 @@ export function DiffViewer(props: DiffViewerProps): JSX.Element {
131
131
  return (
132
132
  <div
133
133
  role="figure"
134
+ dir="ltr"
134
135
  aria-label={ariaLabel()}
135
136
  class={cn('overflow-hidden rounded-lg border border-border bg-card', props.class)}
136
137
  >
@@ -144,16 +145,16 @@ export function DiffViewer(props: DiffViewerProps): JSX.Element {
144
145
  <For each={rows()}>
145
146
  {(line) => (
146
147
  <div class={cn('flex', ROW_BG[line.type])}>
147
- <span class="w-12 shrink-0 select-none py-0.5 pr-2 text-right tabular-nums text-xs text-muted-foreground">
148
+ <span class="w-12 shrink-0 select-none py-0.5 pe-2 text-end tabular-nums text-xs text-muted-foreground">
148
149
  {line.oldNum ?? ''}
149
150
  </span>
150
- <span class="w-12 shrink-0 select-none py-0.5 pr-2 text-right tabular-nums text-xs text-muted-foreground">
151
+ <span class="w-12 shrink-0 select-none py-0.5 pe-2 text-end tabular-nums text-xs text-muted-foreground">
151
152
  {line.newNum ?? ''}
152
153
  </span>
153
154
  <span class={cn('w-5 shrink-0 select-none py-0.5 text-center', MARKER_TONE[line.type])}>
154
155
  {MARKER[line.type]}
155
156
  </span>
156
- <span class={cn('flex-1 whitespace-pre py-0.5 pr-4', CONTENT_TONE[line.type])}>
157
+ <span class={cn('flex-1 whitespace-pre py-0.5 pe-4', CONTENT_TONE[line.type])}>
157
158
  {line.content}
158
159
  </span>
159
160
  </div>
@@ -0,0 +1,82 @@
1
+ // Right-to-left support: one provider that sets `dir` on the DOM and tells
2
+ // Kobalte which direction to use for keyboard navigation.
3
+ import { I18nProvider, getReadingDirection } from '@kobalte/core/i18n'
4
+ import { type Accessor, type JSX, createMemo, splitProps, useContext } from 'solid-js'
5
+
6
+ import { type Direction, DirectionContext } from '../lib/direction'
7
+ import { cn } from '../lib/cn'
8
+
9
+ export type { Direction }
10
+
11
+ /** Props for {@link DirectionProvider}. */
12
+ export interface DirectionProviderProps {
13
+ /**
14
+ * Text direction. When omitted it is derived from `locale`; when both are
15
+ * omitted it is `'ltr'`.
16
+ */
17
+ dir?: Direction
18
+ /**
19
+ * BCP 47 locale (for example `'ar'`, `'he-IL'`). Forwarded to Kobalte for
20
+ * number/date formatting and collation. Its reading direction must agree
21
+ * with `dir`; a mismatch throws.
22
+ */
23
+ locale?: string
24
+ /** Extra classes for the wrapper element. */
25
+ class?: string
26
+ children?: JSX.Element
27
+ }
28
+
29
+ /**
30
+ * Sets the reading direction for everything inside it.
31
+ *
32
+ * - Renders a `display: contents` wrapper carrying the `dir` attribute, so
33
+ * Tailwind logical utilities (`ms-*`, `start-*`, `text-start`) and the
34
+ * browser's bidi handling follow it.
35
+ * - Wraps Kobalte's `I18nProvider`, so arrow keys in Tabs, Menubar, Slider,
36
+ * menus and the like reverse under `rtl`.
37
+ * - Overlays that portal out to `<body>` (Modal, Drawer, Popover, Dropdown,
38
+ * Toast, ...) copy the direction onto their own container.
39
+ *
40
+ * Without a provider nothing changes: components inherit `dir` from the page
41
+ * (set `<html dir="rtl">` yourself in that case).
42
+ *
43
+ * @example
44
+ * ```tsx
45
+ * <DirectionProvider dir="rtl" locale="ar">
46
+ * <App />
47
+ * </DirectionProvider>
48
+ * ```
49
+ */
50
+ export function DirectionProvider(props: DirectionProviderProps): JSX.Element {
51
+ const [local] = splitProps(props, ['dir', 'locale', 'class', 'children'])
52
+ const resolved = createMemo<Direction>(() => {
53
+ const fromLocale = local.locale ? getReadingDirection(local.locale) : undefined
54
+ if (local.dir && fromLocale && local.dir !== fromLocale) {
55
+ throw new Error(
56
+ `DirectionProvider: dir="${local.dir}" conflicts with locale "${local.locale}" (reading direction "${fromLocale}").`,
57
+ )
58
+ }
59
+ return local.dir ?? fromLocale ?? 'ltr'
60
+ })
61
+ const locale = createMemo(() => local.locale ?? (resolved() === 'rtl' ? 'ar' : 'en-US'))
62
+ return (
63
+ <DirectionContext.Provider value={resolved}>
64
+ <I18nProvider locale={locale()}>
65
+ <div dir={resolved()} lang={local.locale} class={cn('contents', local.class)}>
66
+ {local.children}
67
+ </div>
68
+ </I18nProvider>
69
+ </DirectionContext.Provider>
70
+ )
71
+ }
72
+
73
+ /**
74
+ * Accessor of the current direction: the nearest {@link DirectionProvider}'s,
75
+ * or outside one the `dir` attribute of `<html>` (`'ltr'` when unset).
76
+ */
77
+ export function useDirection(): Accessor<Direction> {
78
+ const ctx = useContext(DirectionContext)
79
+ if (ctx) return ctx
80
+ return () =>
81
+ typeof document !== 'undefined' && document.documentElement.getAttribute('dir') === 'rtl' ? 'rtl' : 'ltr'
82
+ }
package/src/ui/Drawer.tsx CHANGED
@@ -3,12 +3,16 @@
3
3
  // slide-in/out keyframes in styles.css off Kobalte's data-expanded/data-closed.
4
4
  // Pass a `title` for the sticky glass header, or omit it and render your own
5
5
  // inside `children`.
6
- import { Dialog } from '@kobalte/core/dialog'
6
+ // Namespace import on purpose: Kobalte's `AlertDialog` is built with Object.assign on the
7
+ // same `DialogRoot` function as `Dialog`, so `Dialog.Content` depends on module load order (#118).
8
+ // The named exports (`Root`, `Content`, ...) are not affected.
9
+ import * as Dialog from '@kobalte/core/dialog'
7
10
  import { X } from 'lucide-solid'
8
11
  import type { JSX, ParentProps } from 'solid-js'
9
12
  import { Show } from 'solid-js'
10
13
 
11
14
  import { cn } from '../lib/cn'
15
+ import { portalDir } from '../lib/direction'
12
16
 
13
17
  interface DrawerProps extends ParentProps {
14
18
  open: boolean
@@ -24,19 +28,36 @@ interface DrawerProps extends ParentProps {
24
28
  side?: DrawerSide
25
29
  }
26
30
 
27
- export type DrawerSide = 'right' | 'left' | 'top' | 'bottom'
31
+ /**
32
+ * `left` / `right` are physical edges. `start` / `end` follow the reading
33
+ * direction (`start` is the right edge under `dir="rtl"`).
34
+ */
35
+ export type DrawerSide = 'right' | 'left' | 'start' | 'end' | 'top' | 'bottom'
28
36
 
29
37
  const SIDE: Record<DrawerSide, { wrap: string; panel: string; offscreen: string }> = {
38
+ // `right` / `left` are physical and never flip; `end` / `start` follow the
39
+ // reading direction (flex `justify-*` and `border-s/e` are logical, while the
40
+ // slide offset uses `--dir-x`, which `[dir=rtl]` sets to -1 in tokens.css).
30
41
  right: {
31
- wrap: 'justify-end',
42
+ wrap: 'justify-end rtl:justify-start',
32
43
  panel: 'h-full w-full max-w-[520px] border-l',
33
44
  offscreen: 'translateX(100%)',
34
45
  },
35
46
  left: {
36
- wrap: 'justify-start',
47
+ wrap: 'justify-start rtl:justify-end',
37
48
  panel: 'h-full w-full max-w-[520px] border-r',
38
49
  offscreen: 'translateX(-100%)',
39
50
  },
51
+ end: {
52
+ wrap: 'justify-end',
53
+ panel: 'h-full w-full max-w-[520px] border-s',
54
+ offscreen: 'translateX(calc(100% * var(--dir-x, 1)))',
55
+ },
56
+ start: {
57
+ wrap: 'justify-start',
58
+ panel: 'h-full w-full max-w-[520px] border-e',
59
+ offscreen: 'translateX(calc(-100% * var(--dir-x, 1)))',
60
+ },
40
61
  top: {
41
62
  wrap: 'flex-col justify-start',
42
63
  panel: 'max-h-[85vh] w-full rounded-b-2xl border-b',
@@ -52,7 +73,7 @@ const SIDE: Record<DrawerSide, { wrap: string; panel: string; offscreen: string
52
73
  export type { DrawerProps }
53
74
 
54
75
  /**
55
- * Edge-anchored slide-over panel (right by default, or `side="left" | "top" | "bottom"`) built on Kobalte's `Dialog` (focus trap, portal,
76
+ * Edge-anchored slide-over panel (right by default, or `side="left" | "start" | "end" | "top" | "bottom"`) built on Kobalte's `Dialog` (focus trap, portal,
56
77
  * and open/close presence handled for you). Use for create/edit forms or detail
57
78
  * panels that need more room than a centered `Modal`.
58
79
  *
@@ -67,8 +88,8 @@ export type { DrawerProps }
67
88
  export function Drawer(props: DrawerProps): JSX.Element {
68
89
  const side = (): (typeof SIDE)[DrawerSide] => SIDE[props.side ?? 'right']
69
90
  return (
70
- <Dialog open={props.open} onOpenChange={props.onOpenChange}>
71
- <Dialog.Portal>
91
+ <Dialog.Root open={props.open} onOpenChange={props.onOpenChange}>
92
+ <Dialog.Portal ref={portalDir}>
72
93
  <Dialog.Overlay class="modal-overlay fixed inset-0 z-40 bg-black/50 backdrop-blur-[2px]" />
73
94
  <div class={cn('fixed inset-0 z-50 flex', side().wrap)}>
74
95
  <Dialog.Content
@@ -100,6 +121,6 @@ export function Drawer(props: DrawerProps): JSX.Element {
100
121
  </Dialog.Content>
101
122
  </div>
102
123
  </Dialog.Portal>
103
- </Dialog>
124
+ </Dialog.Root>
104
125
  )
105
126
  }
@@ -4,6 +4,7 @@ import type { JSX, ParentProps } from 'solid-js'
4
4
  import { For } from 'solid-js'
5
5
 
6
6
  import { cn } from '../lib/cn'
7
+ import { portalDir } from '../lib/direction'
7
8
 
8
9
  /** A single selectable row in a {@link Dropdown} menu. */
9
10
  export interface DropdownItem {
@@ -48,7 +49,7 @@ export function Dropdown(props: DropdownProps): JSX.Element {
48
49
  <DropdownMenu.Trigger class={cn('inline-flex', props.class)} aria-label={props.label}>
49
50
  {props.children}
50
51
  </DropdownMenu.Trigger>
51
- <DropdownMenu.Portal>
52
+ <DropdownMenu.Portal ref={portalDir}>
52
53
  <DropdownMenu.Content class="z-50 min-w-[10rem] overflow-hidden rounded-md border border-border bg-card p-1 text-card-foreground shadow-sm">
53
54
  <For each={props.items}>
54
55
  {(item) => (
@@ -8,6 +8,8 @@ import { Dumbbell, Hash, Lightbulb, Leaf, Plane, Search, Smile, Users, Utensils
8
8
  import { createMemo, createSignal, For, Show, type Component, type JSX } from 'solid-js'
9
9
 
10
10
  import { cn } from '../lib/cn'
11
+ import { readingKey } from '../lib/direction'
12
+ import { useDirection } from './DirectionProvider'
11
13
 
12
14
  type Category = 'Smileys' | 'People' | 'Nature' | 'Food' | 'Activity' | 'Travel' | 'Objects' | 'Symbols'
13
15
 
@@ -276,6 +278,7 @@ export interface EmojiPickerProps {
276
278
  * ```
277
279
  */
278
280
  export function EmojiPicker(props: EmojiPickerProps): JSX.Element {
281
+ const dir = useDirection()
279
282
  const [query, setQuery] = createSignal('')
280
283
  const [recents, setRecents] = createSignal<string[]>([])
281
284
 
@@ -320,9 +323,9 @@ export function EmojiPicker(props: EmojiPickerProps): JSX.Element {
320
323
  if (index === -1) return
321
324
  const columns = 8
322
325
  const delta =
323
- event.key === 'ArrowRight'
326
+ readingKey(event.key, dir()) === 'ArrowRight'
324
327
  ? 1
325
- : event.key === 'ArrowLeft'
328
+ : readingKey(event.key, dir()) === 'ArrowLeft'
326
329
  ? -1
327
330
  : event.key === 'ArrowDown'
328
331
  ? columns
@@ -4,7 +4,7 @@
4
4
  // scale, so its content never stretches. Covers the motion.dev "family dialog"
5
5
  // (size="dialog") and "app store layout" (size="full") patterns with one API.
6
6
  import { createSignal, Show, onCleanup, onMount, type JSX } from 'solid-js'
7
- import { Portal } from 'solid-js/web'
7
+ import { Portal } from './Portal'
8
8
 
9
9
  import { cn } from '../lib/cn'
10
10
  import { animate, motionReduced } from '../lib/motion'
@@ -206,7 +206,7 @@ export function Expandable(props: ExpandableProps): JSX.Element {
206
206
  type="button"
207
207
  aria-label="Close"
208
208
  onClick={doClose}
209
- class="absolute right-3 top-3 z-10 grid h-8 w-8 place-items-center rounded-full bg-muted/80 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
209
+ class="absolute end-3 top-3 z-10 grid h-8 w-8 place-items-center rounded-full bg-muted/80 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
210
210
  >
211
211
  <svg
212
212
  viewBox="0 0 24 24"
package/src/ui/Faq.tsx CHANGED
@@ -41,7 +41,7 @@ export function Faq(props: FaqProps): JSX.Element {
41
41
  class={cn('mx-auto flex w-full max-w-3xl flex-col gap-10 px-5 py-20', props.class)}
42
42
  >
43
43
  <SectionHeading title={props.title} subtitle={props.subtitle} />
44
- <div class="card rounded-xl px-5 sm:px-6 [&_button]:py-4 [&_button]:text-left [&_button]:text-base">
44
+ <div class="card rounded-xl px-5 sm:px-6 [&_button]:py-4 [&_button]:text-start [&_button]:text-base">
45
45
  <Accordion
46
46
  multiple={props.multiple}
47
47
  class="[&>*:last-child]:border-b-0"
package/src/ui/Field.tsx CHANGED
@@ -364,7 +364,7 @@ export function FieldError(props: FieldErrorProps): JSX.Element {
364
364
  <Show when={visible()}>
365
365
  <div id={id} role="alert" data-slot="field-error" class={cn('text-xs text-destructive', props.class)}>
366
366
  <Show when={messages().length > 1} fallback={<>{messages()[0] ?? props.children}</>}>
367
- <ul class="ml-4 list-disc space-y-0.5">
367
+ <ul class="ms-4 list-disc space-y-0.5">
368
368
  <For each={messages()}>{(m) => <li>{m}</li>}</For>
369
369
  </ul>
370
370
  </Show>
@@ -7,8 +7,8 @@ import { spawnRipple } from './Ripple'
7
7
  export type FloatingActionButtonPosition = 'bottom-right' | 'bottom-left'
8
8
 
9
9
  const POSITION_CLASSES: Record<FloatingActionButtonPosition, string> = {
10
- 'bottom-right': 'bottom-6 right-6',
11
- 'bottom-left': 'bottom-6 left-6',
10
+ 'bottom-right': 'bottom-6 end-6',
11
+ 'bottom-left': 'bottom-6 start-6',
12
12
  }
13
13
 
14
14
  // brightness-110 on hover keeps the tint theme-agnostic — it lifts whatever
@@ -75,7 +75,7 @@ export function FollowingPointer(props: FollowingPointerProps): JSX.Element {
75
75
  <div
76
76
  ref={pointerEl}
77
77
  aria-hidden="true"
78
- class="pointer-events-none absolute left-0 top-0 z-50 flex items-center opacity-0 transition-opacity duration-150 will-change-transform"
78
+ class="pointer-events-none absolute start-0 top-0 z-50 flex items-center opacity-0 transition-opacity duration-150 will-change-transform"
79
79
  >
80
80
  <svg
81
81
  width="20"
@@ -96,7 +96,7 @@ export function FollowingPointer(props: FollowingPointerProps): JSX.Element {
96
96
  <Show when={props.label}>
97
97
  <div
98
98
  class={cn(
99
- 'ml-2 whitespace-nowrap rounded-full px-2.5 py-1 text-xs font-medium shadow-lg',
99
+ 'ms-2 whitespace-nowrap rounded-full px-2.5 py-1 text-xs font-medium shadow-lg',
100
100
  tone() === 'accent'
101
101
  ? 'bg-accent text-accent-foreground'
102
102
  : 'bg-primary text-primary-foreground',