iryx-ui 0.12.0 → 0.13.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 (225) hide show
  1. package/README.md +35 -1315
  2. package/dist/component-names.d.ts +1 -1
  3. package/dist/component-names.js +1 -1
  4. package/dist/components/Accordion.js +5 -0
  5. package/dist/components/Accordion.vue.d.ts +80 -0
  6. package/dist/components/Accordion.vue_vue_type_script_setup_true_lang.js +87 -0
  7. package/dist/components/Alert.vue.d.ts +1 -1
  8. package/dist/components/Alert.vue_vue_type_script_setup_true_lang.js +7 -7
  9. package/dist/components/App.vue.d.ts +1 -1
  10. package/dist/components/AppShell.js +5 -0
  11. package/dist/components/AppShell.vue.d.ts +46 -0
  12. package/dist/components/AppShell.vue_vue_type_script_setup_true_lang.js +59 -0
  13. package/dist/components/AspectRatio.js +5 -0
  14. package/dist/components/AspectRatio.vue.d.ts +26 -0
  15. package/dist/components/AspectRatio.vue_vue_type_script_setup_true_lang.js +27 -0
  16. package/dist/components/Avatar.js +5 -0
  17. package/dist/components/Avatar.vue.d.ts +52 -0
  18. package/dist/components/Avatar.vue_vue_type_script_setup_true_lang.js +67 -0
  19. package/dist/components/AvatarGroup.js +5 -0
  20. package/dist/components/AvatarGroup.vue.d.ts +39 -0
  21. package/dist/components/AvatarGroup.vue_vue_type_script_setup_true_lang.js +45 -0
  22. package/dist/components/Badge.vue.d.ts +1 -1
  23. package/dist/components/Banner.vue.d.ts +2 -2
  24. package/dist/components/BarChart.vue.d.ts +1 -1
  25. package/dist/components/BarChart.vue_vue_type_script_setup_true_lang.js +14 -14
  26. package/dist/components/Button.vue.d.ts +2 -2
  27. package/dist/components/ButtonGroup.vue.d.ts +1 -1
  28. package/dist/components/Card.vue.d.ts +1 -1
  29. package/dist/components/Collapsible.js +5 -0
  30. package/dist/components/Collapsible.vue.d.ts +58 -0
  31. package/dist/components/Collapsible.vue_vue_type_script_setup_true_lang.js +67 -0
  32. package/dist/components/ColorPicker.js +5 -0
  33. package/dist/components/ColorPicker.vue.d.ts +49 -0
  34. package/dist/components/ColorPicker.vue_vue_type_script_setup_true_lang.js +172 -0
  35. package/dist/components/Combobox.vue.d.ts +6 -6
  36. package/dist/components/CommandPalette.js +5 -0
  37. package/dist/components/CommandPalette.vue.d.ts +63 -0
  38. package/dist/components/CommandPalette.vue_vue_type_script_setup_true_lang.js +185 -0
  39. package/dist/components/Container.js +5 -0
  40. package/dist/components/Container.vue.d.ts +33 -0
  41. package/dist/components/Container.vue_vue_type_script_setup_true_lang.js +40 -0
  42. package/dist/components/ContextMenu.js +5 -0
  43. package/dist/components/ContextMenu.vue.d.ts +42 -0
  44. package/dist/components/ContextMenu.vue_vue_type_script_setup_true_lang.js +49 -0
  45. package/dist/components/DateRangePicker.vue.d.ts +1 -1
  46. package/dist/components/Dialog.vue.d.ts +1 -1
  47. package/dist/components/Drawer.vue.d.ts +2 -2
  48. package/dist/components/EmptyState.vue.d.ts +1 -1
  49. package/dist/components/Input.vue.d.ts +1 -1
  50. package/dist/components/Input.vue_vue_type_script_setup_true_lang.js +4 -4
  51. package/dist/components/Kbd.js +5 -0
  52. package/dist/components/Kbd.vue.d.ts +46 -0
  53. package/dist/components/Kbd.vue_vue_type_script_setup_true_lang.js +41 -0
  54. package/dist/components/LineChart.vue_vue_type_script_setup_true_lang.js +19 -19
  55. package/dist/components/Menubar.js +5 -0
  56. package/dist/components/Menubar.vue.d.ts +62 -0
  57. package/dist/components/Menubar.vue_vue_type_script_setup_true_lang.js +79 -0
  58. package/dist/components/NavigationMenu.js +5 -0
  59. package/dist/components/NavigationMenu.vue.d.ts +75 -0
  60. package/dist/components/NavigationMenu.vue_vue_type_script_setup_true_lang.js +167 -0
  61. package/dist/components/NumberInput.vue_vue_type_script_setup_true_lang.js +2 -2
  62. package/dist/components/PageHeader.js +5 -0
  63. package/dist/components/PageHeader.vue.d.ts +52 -0
  64. package/dist/components/PageHeader.vue_vue_type_script_setup_true_lang.js +59 -0
  65. package/dist/components/PinInput.js +5 -0
  66. package/dist/components/PinInput.vue.d.ts +65 -0
  67. package/dist/components/PinInput.vue_vue_type_script_setup_true_lang.js +76 -0
  68. package/dist/components/Popover.js +5 -0
  69. package/dist/components/Popover.vue.d.ts +75 -0
  70. package/dist/components/Popover.vue_vue_type_script_setup_true_lang.js +99 -0
  71. package/dist/components/Progress.vue.d.ts +20 -1
  72. package/dist/components/Progress.vue_vue_type_script_setup_true_lang.js +84 -44
  73. package/dist/components/RadioGroup.vue_vue_type_script_setup_true_lang.js +4 -1
  74. package/dist/components/ScrollArea.js +5 -0
  75. package/dist/components/ScrollArea.vue.d.ts +41 -0
  76. package/dist/components/ScrollArea.vue_vue_type_script_setup_true_lang.js +68 -0
  77. package/dist/components/ScrollFade.js +5 -0
  78. package/dist/components/ScrollFade.vue.d.ts +36 -0
  79. package/dist/components/ScrollFade.vue_vue_type_script_setup_true_lang.js +59 -0
  80. package/dist/components/Select.vue.d.ts +2 -2
  81. package/dist/components/Sidebar.js +5 -0
  82. package/dist/components/Sidebar.vue.d.ts +72 -0
  83. package/dist/components/Sidebar.vue_vue_type_script_setup_true_lang.js +183 -0
  84. package/dist/components/SignaturePad.js +5 -0
  85. package/dist/components/SignaturePad.vue.d.ts +60 -0
  86. package/dist/components/SignaturePad.vue_vue_type_script_setup_true_lang.js +158 -0
  87. package/dist/components/Skeleton.vue.d.ts +1 -1
  88. package/dist/components/Skeleton.vue_vue_type_script_setup_true_lang.js +8 -8
  89. package/dist/components/Slider.js +5 -0
  90. package/dist/components/Slider.vue.d.ts +80 -0
  91. package/dist/components/Slider.vue_vue_type_script_setup_true_lang.js +115 -0
  92. package/dist/components/Splitter.js +5 -0
  93. package/dist/components/Splitter.vue.d.ts +59 -0
  94. package/dist/components/Splitter.vue_vue_type_script_setup_true_lang.js +69 -0
  95. package/dist/components/Stat.vue.d.ts +1 -1
  96. package/dist/components/Switch.vue.d.ts +1 -0
  97. package/dist/components/Switch.vue_vue_type_script_setup_true_lang.js +7 -3
  98. package/dist/components/Table.vue.d.ts +7 -1
  99. package/dist/components/Table.vue_vue_type_script_setup_true_lang.js +73 -58
  100. package/dist/components/TagsInput.js +5 -0
  101. package/dist/components/TagsInput.vue.d.ts +71 -0
  102. package/dist/components/TagsInput.vue_vue_type_script_setup_true_lang.js +119 -0
  103. package/dist/components/Textarea.vue_vue_type_script_setup_true_lang.js +3 -3
  104. package/dist/components/TimeField.js +5 -0
  105. package/dist/components/TimeField.vue.d.ts +50 -0
  106. package/dist/components/TimeField.vue_vue_type_script_setup_true_lang.js +85 -0
  107. package/dist/components/Timeline.js +5 -0
  108. package/dist/components/Timeline.vue.d.ts +61 -0
  109. package/dist/components/Timeline.vue_vue_type_script_setup_true_lang.js +60 -0
  110. package/dist/components/Toaster.vue.d.ts +1 -1
  111. package/dist/components/Toggle.js +5 -0
  112. package/dist/components/Toggle.vue.d.ts +42 -0
  113. package/dist/components/Toggle.vue_vue_type_script_setup_true_lang.js +52 -0
  114. package/dist/components/ToggleGroup.js +5 -0
  115. package/dist/components/ToggleGroup.vue.d.ts +64 -0
  116. package/dist/components/ToggleGroup.vue_vue_type_script_setup_true_lang.js +78 -0
  117. package/dist/components/Toolbar.js +5 -0
  118. package/dist/components/Toolbar.vue.d.ts +52 -0
  119. package/dist/components/Toolbar.vue_vue_type_script_setup_true_lang.js +94 -0
  120. package/dist/components/Tree.js +5 -0
  121. package/dist/components/Tree.vue.d.ts +70 -0
  122. package/dist/components/Tree.vue_vue_type_script_setup_true_lang.js +112 -0
  123. package/dist/components/index.d.ts +29 -0
  124. package/dist/components/index.js +144 -86
  125. package/dist/composables/appearance.js +5 -1
  126. package/dist/composables/cartesian.d.ts +11 -0
  127. package/dist/composables/cartesian.js +18 -19
  128. package/dist/composables/command-palette.d.ts +42 -0
  129. package/dist/composables/command-palette.js +32 -0
  130. package/dist/composables/date.d.ts +18 -2
  131. package/dist/composables/date.js +23 -12
  132. package/dist/composables/kbd.d.ts +26 -0
  133. package/dist/composables/kbd.js +161 -0
  134. package/dist/composables/navigation-menu.d.ts +29 -0
  135. package/dist/composables/navigation-menu.js +6 -0
  136. package/dist/composables/scroll-fade.d.ts +35 -0
  137. package/dist/composables/scroll-fade.js +36 -0
  138. package/dist/composables/sidebar.d.ts +35 -0
  139. package/dist/composables/sidebar.js +23 -0
  140. package/dist/composables/signature.d.ts +22 -0
  141. package/dist/composables/signature.js +13 -0
  142. package/dist/index.d.ts +35 -0
  143. package/dist/index.js +153 -93
  144. package/dist/theme/accordion.d.ts +68 -0
  145. package/dist/theme/accordion.js +24 -0
  146. package/dist/theme/alert.d.ts +0 -3
  147. package/dist/theme/app-shell.d.ts +77 -0
  148. package/dist/theme/app-shell.js +35 -0
  149. package/dist/theme/avatar.d.ts +253 -0
  150. package/dist/theme/avatar.js +84 -0
  151. package/dist/theme/banner.d.ts +0 -9
  152. package/dist/theme/bar-chart.d.ts +0 -16
  153. package/dist/theme/breadcrumb.d.ts +3 -3
  154. package/dist/theme/button-group.d.ts +0 -3
  155. package/dist/theme/button.d.ts +0 -3
  156. package/dist/theme/card.js +2 -2
  157. package/dist/theme/chart-legend.d.ts +3 -11
  158. package/dist/theme/collapsible.d.ts +44 -0
  159. package/dist/theme/collapsible.js +17 -0
  160. package/dist/theme/color-picker.d.ts +98 -0
  161. package/dist/theme/color-picker.js +19 -0
  162. package/dist/theme/command-palette.d.ts +122 -0
  163. package/dist/theme/command-palette.js +22 -0
  164. package/dist/theme/container.d.ts +101 -0
  165. package/dist/theme/container.js +26 -0
  166. package/dist/theme/date-picker.d.ts +12 -0
  167. package/dist/theme/date-picker.js +1 -1
  168. package/dist/theme/dropdown-menu.d.ts +0 -9
  169. package/dist/theme/file-upload.d.ts +0 -3
  170. package/dist/theme/form.d.ts +12 -12
  171. package/dist/theme/index.d.ts +25 -0
  172. package/dist/theme/kbd.d.ts +71 -0
  173. package/dist/theme/kbd.js +29 -0
  174. package/dist/theme/line-chart.d.ts +0 -9
  175. package/dist/theme/menubar.d.ts +29 -0
  176. package/dist/theme/menubar.js +11 -0
  177. package/dist/theme/navigation-menu.d.ts +161 -0
  178. package/dist/theme/navigation-menu.js +43 -0
  179. package/dist/theme/number-input.d.ts +0 -3
  180. package/dist/theme/page-header.d.ts +47 -0
  181. package/dist/theme/page-header.js +17 -0
  182. package/dist/theme/pagination.d.ts +0 -3
  183. package/dist/theme/pin-input.d.ts +86 -0
  184. package/dist/theme/pin-input.js +32 -0
  185. package/dist/theme/popover.d.ts +119 -0
  186. package/dist/theme/popover.js +31 -0
  187. package/dist/theme/presets.d.ts +0 -2
  188. package/dist/theme/progress.d.ts +93 -0
  189. package/dist/theme/progress.js +37 -7
  190. package/dist/theme/radio-group.d.ts +24 -0
  191. package/dist/theme/radio-group.js +23 -14
  192. package/dist/theme/scroll-area.d.ts +65 -0
  193. package/dist/theme/scroll-area.js +57 -0
  194. package/dist/theme/scroll-fade.d.ts +35 -0
  195. package/dist/theme/scroll-fade.js +12 -0
  196. package/dist/theme/sidebar.d.ts +167 -0
  197. package/dist/theme/sidebar.js +43 -0
  198. package/dist/theme/signature-pad.d.ts +50 -0
  199. package/dist/theme/signature-pad.js +17 -0
  200. package/dist/theme/slider.d.ts +128 -0
  201. package/dist/theme/slider.js +49 -0
  202. package/dist/theme/sparkline.d.ts +0 -6
  203. package/dist/theme/splitter.d.ts +59 -0
  204. package/dist/theme/splitter.js +25 -0
  205. package/dist/theme/stat.d.ts +0 -3
  206. package/dist/theme/stepper.js +2 -2
  207. package/dist/theme/switch.d.ts +53 -29
  208. package/dist/theme/switch.js +33 -8
  209. package/dist/theme/table.d.ts +51 -3
  210. package/dist/theme/table.js +8 -3
  211. package/dist/theme/tabs.d.ts +0 -3
  212. package/dist/theme/tags-input.d.ts +110 -0
  213. package/dist/theme/tags-input.js +42 -0
  214. package/dist/theme/time-field.d.ts +68 -0
  215. package/dist/theme/time-field.js +21 -0
  216. package/dist/theme/timeline.d.ts +182 -0
  217. package/dist/theme/timeline.js +83 -0
  218. package/dist/theme/toggle.d.ts +145 -0
  219. package/dist/theme/toggle.js +84 -0
  220. package/dist/theme/toolbar.d.ts +56 -0
  221. package/dist/theme/toolbar.js +21 -0
  222. package/dist/theme/tree.d.ts +68 -0
  223. package/dist/theme/tree.js +28 -0
  224. package/package.json +2 -2
  225. package/theme.css +221 -0
package/README.md CHANGED
@@ -1,21 +1,27 @@
1
1
  # iryx-ui
2
2
 
3
- A Vue 3 component library built on [Reka UI](https://reka-ui.com) and [Tailwind CSS v4](https://tailwindcss.com). Styled by default, headless when you need it, with a Vue plugin and a Nuxt module in one package.
3
+ A Vue 3 component library built on [Reka UI](https://reka-ui.com) and [Tailwind CSS v4](https://tailwindcss.com). Accessible by default, yours to restyle.
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/iryx-ui.svg)](https://www.npmjs.com/package/iryx-ui)
6
6
  [![license](https://img.shields.io/npm/l/iryx-ui.svg)](https://github.com/therok1/iryx-ui/blob/main/LICENSE)
7
7
 
8
+ **[Documentation →](https://iryx-ui.com)**
9
+
10
+ Every component arrives with keyboard navigation, focus management and ARIA already working, then hands you a `class`, a per-slot `ui`, or `unstyled` when the defaults are not what you want.
11
+
8
12
  ## Installation
9
13
 
10
14
  ```bash
11
15
  pnpm add iryx-ui
12
16
  ```
13
17
 
14
- ### Vue 3 (Vite)
18
+ `npm install`, `yarn add` and `bun add` work the same — it is one package on npm.
19
+
20
+ ### Vue 3
15
21
 
16
22
  ```ts
17
- import { IryxUi } from 'iryx-ui'
18
23
  // main.ts
24
+ import { IryxUi } from 'iryx-ui'
19
25
  import { createApp } from 'vue'
20
26
  import App from './App.vue'
21
27
 
@@ -43,1339 +49,53 @@ export default defineNuxtConfig({
43
49
  @import "iryx-ui/theme.css";
44
50
  ```
45
51
 
46
- Components are auto-imported with the `I` prefix (configurable via the `iryxUi.prefix` option).
52
+ Components are auto-imported with the `I` prefix.
47
53
 
48
54
  ## Usage
49
55
 
50
56
  ```vue
51
57
  <template>
52
- <IButton variant="outline" size="lg">
53
- Click me
54
- </IButton>
55
- <ISwitch v-model="enabled" />
56
- </template>
57
- ```
58
-
59
- ### Icons
60
-
61
- Put an icon in the default slot, leading or trailing. Icons are sized to match the button, and any SVG icon set works. Iryx's own icons come from [Hugeicons](https://hugeicons.com), which ships icons as data rather than components:
62
-
63
- ```vue
64
- <script setup lang="ts">
65
- import { ArrowRight01Icon, Search01Icon } from '@hugeicons/core-free-icons'
66
- import { HugeiconsIcon } from '@hugeicons/vue'
67
- </script>
68
-
69
- <template>
70
- <IButton>
71
- <HugeiconsIcon :icon="Search01Icon" data-icon="inline-start" /> Search
72
- </IButton>
73
- <IButton variant="outline">
74
- Next <HugeiconsIcon :icon="ArrowRight01Icon" data-icon="inline-end" />
75
- </IButton>
76
- <IButton square aria-label="Search">
77
- <HugeiconsIcon :icon="Search01Icon" />
78
- </IButton>
79
- </template>
80
- ```
81
-
82
- Mark the icon with `data-icon="inline-start"` or `"inline-end"` and the padding tightens on the side it sits on. For an icon with no label, add `square`.
83
-
84
- Props that take an icon — `IAlert`'s `icon`, and the `icon` on `IDropdownMenu`, `IBreadcrumb`, `ITabs` and `IEmptyState` items — accept **either** a Hugeicons icon or any component that renders an SVG, so an icon set like Lucide still works:
85
-
86
- ```ts
87
- import { Alert02Icon } from '@hugeicons/core-free-icons'
88
- import { Bell } from 'lucide-vue-next'
89
-
90
- // Both are valid.
91
- const a = { label: 'Alerts', icon: Alert02Icon }
92
- const b = { label: 'Alerts', icon: Bell }
93
- ```
94
-
95
- The marker is needed because a label is a bare text node: CSS's `:first-child` and `:last-child` count element children, so an icon beside text matches both. An unmarked icon still renders, it just keeps the full padding.
96
-
97
- `IBadge` follows the same convention. When `loading` is set on a button, a spinner takes the leading position automatically.
98
-
99
- ## The `IApp` wrapper
100
-
101
- Wrap your app once to configure everything below it. Unlike the plugin options
102
- (which are read at install time), `IApp`'s props are **reactive** — change them
103
- and every component updates.
104
-
105
- ```vue
106
- <template>
107
- <IApp theme="rose" appearance="system" dir="ltr">
108
- <RouterView />
109
- </IApp>
110
- </template>
111
- ```
112
-
113
- It renders **no wrapper element** by default. Pass `as` (plus `class`) if you'd
114
- rather it own your page shell:
115
-
116
- ```vue
117
- <IApp as="div" class="min-h-screen bg-background text-foreground">
118
- <RouterView />
119
- </IApp>
120
- ```
121
-
122
- | Prop | What it does |
123
- | --- | --- |
124
- | `theme` | Applies a preset or custom theme, reactively. Removing it restores the defaults. |
125
- | `appearance` | Startup `light` / `dark` / `system`. A stored user preference wins. Omit it and `IApp` won't touch dark mode at all. |
126
- | `unstyled` | Strips built-in classes from every descendant. |
127
- | `dir` / `locale` / `scrollBody` / `nonce` | Forwarded to Reka UI's `ConfigProvider`, so RTL and locale-aware primitives work. |
128
-
129
- Per-component props still win over the app config, so `<IButton :unstyled="false">`
130
- stays styled inside an `<IApp unstyled>`.
131
-
132
- `IApp` is optional — the plugin options and `applyTheme()` / `useAppearance()`
133
- still work on their own.
134
-
135
- ## Appearance (light / dark)
136
-
137
- Dark mode is class-based: the `.dark` class on `<html>` flips every token.
138
- The `useAppearance()` composable manages it for you — it persists the choice
139
- and follows the OS preference in `system` mode:
140
-
141
- ```vue
142
- <script setup>
143
- import { useAppearance } from 'iryx-ui'
144
-
145
- const { appearance, isDark, setAppearance, toggleAppearance } = useAppearance()
146
- </script>
147
-
148
- <template>
149
- <IButton variant="ghost" @click="toggleAppearance()">
150
- {{ isDark ? '🌙' : '☀️' }}
151
- </IButton>
152
- </template>
153
- ```
154
-
155
- You can set the startup default via the plugin or Nuxt module (a stored user
156
- preference always wins):
157
-
158
- ```ts
159
- app.use(createIryxUi({ appearance: 'system' }))
160
- // nuxt.config.ts → iryxUi: { appearance: 'system' }
161
- ```
162
-
163
- `theme.css` also registers the class-based `dark:` variant for your own
164
- utilities (shadcn-style `@custom-variant dark`).
165
-
166
- ## Theming
167
-
168
- Pick a built-in color preset — `violet` (default) or `rose` — at startup or at
169
- runtime:
170
-
171
- ```ts
172
- import { applyTheme } from 'iryx-ui'
173
-
174
- app.use(createIryxUi({ theme: 'rose' }))
175
- // nuxt.config.ts → iryxUi: { theme: 'rose' }
176
-
177
- applyTheme('rose') // runtime, e.g. from a theme picker
178
- ```
179
-
180
- Or bring your own theme — every token can differ between light and dark:
181
-
182
- ```ts
183
- applyTheme({
184
- light: { primary: 'oklch(0.55 0.2 250)', primaryForeground: 'white' },
185
- dark: { primary: 'oklch(0.68 0.17 250)', primaryForeground: 'oklch(0.15 0.04 250)' },
186
- })
187
- ```
188
-
189
- For a static re-brand, plain CSS works too — tokens are just variables:
190
-
191
- ```css
192
- :root {
193
- --iryx-primary: oklch(0.65 0.2 145); /* make it green */
194
- }
195
- .dark {
196
- --iryx-primary: oklch(0.75 0.18 145);
197
- }
198
- ```
199
-
200
- ### Typeface
201
-
202
- Set `--iryx-font-sans` and everything follows, including Tailwind's
203
- `font-sans`. Load the family however you like — a package, a `<link>`, or
204
- self-hosted:
205
-
206
- ```css
207
- @import "@fontsource-variable/instrument-sans";
208
-
209
- :root {
210
- --iryx-font-sans: "Instrument Sans", ui-sans-serif, system-ui, sans-serif;
211
- }
212
- ```
213
-
214
- Leave it unset to use the system stack. There is no `.dark` counterpart for
215
- this one.
216
-
217
- Available tokens, each usable as a Tailwind color (`bg-primary`,
218
- `text-muted-foreground`, …):
219
-
220
- | Group | Tokens |
221
- | --- | --- |
222
- | Surfaces | `background`, `foreground`, `accent`, `accent-foreground`, `muted`, `muted-foreground`, `border`, `input` |
223
- | Brand | `primary`, `primary-foreground`, `primary-from`, `primary-to` |
224
- | Status | `success`, `warning`, `danger`, `info` — each with `-foreground`, `-muted`, `-muted-foreground` and `-border` |
225
-
226
- `input` is the fill behind the fields — `IInput`, `ITextarea`, `INumberInput`,
227
- the `ISelect` trigger, the `ICombobox` anchor — and behind `IButton`'s
228
- `outline` variant, so the two read as the same kind of surface. It's a token
229
- of its own rather than a reused `muted`, so how raised a control looks can be
230
- tuned without moving every muted surface with it. It matches the page
231
- background in light mode and lifts off it in dark, where the difference is
232
- legible; that split lives in the token values, so no component needs a `dark:`
233
- class for it.
234
-
235
- `primary-from` / `primary-to` are the stops of the solid button's vertical
236
- gradient. The status tokens carry their own dark-mode values, so components
237
- never need a `dark:` class for them: `success` is the solid fill,
238
- `success-foreground` the text on it, `success-muted` a tinted surface,
239
- `success-muted-foreground` the text on that, and `success-border` the edge.
240
-
241
- The built-in presets swap only the brand colors — red should mean danger in
242
- every theme — but `applyTheme()` accepts the status tokens too:
243
-
244
- ```ts
245
- applyTheme({
246
- light: { success: 'oklch(0.6 0.15 150)', successMuted: 'oklch(0.97 0.02 150)' },
247
- dark: { success: 'oklch(0.7 0.15 150)', successMuted: 'oklch(0.26 0.05 150)' },
248
- })
249
- ```
250
-
251
- Tweak a single instance with `class` (conflicts are merged smartly) or per-slot with `ui`:
252
-
253
- ```vue
254
- <IButton class="rounded-full">
255
- Pill button
256
- </IButton>
257
-
258
- <ISwitch :ui="{ thumb: 'bg-zinc-900' }" />
259
- ```
260
-
261
- Or drop all built-in styles and take over completely:
262
-
263
- ```vue
264
- <IButton unstyled class="my-own-button">
265
- Headless
266
- </IButton>
267
- ```
268
-
269
- ```ts
270
- // …or globally:
271
- app.use(createIryxUi({ unstyled: true }))
272
- ```
273
-
274
- ## Components
275
-
276
- **Layout & structure**
277
-
278
- | Component | Description |
279
- | --- | --- |
280
- | `IApp` | Root wrapper — reactive global config, theme, appearance, RTL/locale |
281
- | `ICard` | Panel with `outline`/`soft` variants, four paddings, header and footer slots |
282
- | `ISeparator` | Horizontal or vertical rule, optionally with a centred label |
283
-
284
- **Forms**
285
-
286
- | Component | Description |
287
- | --- | --- |
288
- | `IForm` | Validating form wrapper — any Standard Schema validator, or your own function |
289
- | `IFormField` | Label, description, hint, help and error text around a control |
290
- | `ILabel` | Field label with optional `required` asterisk |
291
- | `IInput` | Text field with `sm`/`md`/`lg` sizes, `invalid` state, `v-model`, `leading`/`trailing` slots, `clearable`, `loading`, `debounce` |
292
- | `ITextarea` | Multi-line field with matching sizes, `invalid` state and optional `autosize` |
293
- | `INumberInput` | Decimal-safe numeric field — the model is a **string**, with `min`/`max`/`step`, `precision` and locale-aware display |
294
- | `IPasswordInput` | Masked field with a show/hide toggle and an optional strength meter |
295
- | `IFileUpload` | Drag-and-drop file field with `accept` / `maxSize` / `maxFiles`, thumbnails and a remove action |
296
- | `IDatePicker` | Calendar in a popover; the model is an ISO `YYYY-MM-DD` **string** |
297
- | `IDateRangePicker` | Two-month range calendar; the model is `{ start, end }` ISO strings |
298
- | `ICheckbox` | Tri-state checkbox (`true` / `false` / `'indeterminate'`), optional `label` + `description` |
299
- | `ISwitch` | Accessible toggle, optional `label` + `description` |
300
- | `IRadioGroup` | Radio list with labels wired up automatically; items take a `description` |
301
- | `ISelect` | Listbox with keyboard nav and typeahead, driven by an `items` array, with optional groups |
302
- | `ICombobox` | Searchable select — filters as you type, with optional groups, virtualized rows and a "create from query" row |
303
-
304
- **Actions**
305
-
306
- | Component | Description |
307
- | --- | --- |
308
- | `IButton` | Variants (`solid`, `outline`, `ghost`, `link`), five sizes, `loading`, `block` and `square` states, polymorphic via `as` / `asChild` |
309
- | `IButtonGroup` | Joins any children into a segmented control — split buttons, toolbars, pagers |
310
- | `IDropdownMenu` | Menu driven by an `items` array, with separators, group labels, danger items and nested submenus |
311
-
312
- **Overlays**
313
-
314
- | Component | Description |
315
- | --- | --- |
316
- | `IDialog` | Modal with header/body/footer slots, `dismissible` and `showClose` |
317
- | `IDrawer` | Panel or sheet attached to any edge — swipe to dismiss, optional snap points |
318
- | `IConfirmDialog` | Host for `useConfirm()` — renders the promise-based confirmation |
319
- | `ITooltip` | Hover/focus tooltip with side, align, delay and optional arrow |
320
-
321
- **Feedback**
322
-
323
- | Component | Description |
324
- | --- | --- |
325
- | `IAlert` | Inline, contextual message in four variants — variant icon, `actions` slot, optional dismiss |
326
- | `IBanner` | Page-level announcement — full-bleed, six variants, sticky top or fixed bottom |
327
- | `IBadge` | Status pill — five variants × three sizes; `dot` moves the colour onto a leading dot |
328
- | `IToaster` | Host for `useToast()`; six viewport positions, stacking, action buttons |
329
- | `IProgress` | Determinate or `indeterminate` bar, five variants, `formatValue` |
330
- | `ISkeleton` | Loading placeholder — `text`/`rect`/`circle`, stackable with `lines` |
331
- | `IEmptyState` | Icon, title, description and an `actions` slot for empty lists |
332
-
333
- **Navigation**
334
-
335
- | Component | Description |
336
- | --- | --- |
337
- | `ITabs` | `solid` or `line` variants with an animated indicator, horizontal or vertical |
338
- | `IBreadcrumb` | Trail from an `items` array; the last crumb is marked as the current page |
339
- | `IPagination` | Page list with ellipsis, edge pages and prev/next controls; `align` places it, `size` sets the button scale |
340
- | `IStepper` | Multi-step progress, horizontal or vertical, optional `linear` ordering |
341
-
342
- **Data display**
343
-
344
- | Component | Description |
345
- | --- | --- |
346
- | `ITable` | Data table — sorting, selection, expansion and per-cell slots, client- or server-driven |
347
- | `IStat` | KPI tile — label, value, signed delta with trend colour, and a hint |
348
-
349
- **Charts** — pure SVG, no charting dependency; see [Charts](#charts)
350
-
351
- | Component | Description |
352
- | --- | --- |
353
- | `ISparkline` | Tiny inline trend line, sized to whatever box you put it in |
354
- | `IBarChart` | Bar chart — vertical or horizontal, grouped or stacked, with a round-number axis and a hover tooltip |
355
- | `ILineChart` | Line or area chart, single or multi-series, with a crosshair and hover marker |
356
- | `IChartLegend` | Standalone legend; shown automatically from two series up |
357
-
358
- Every component supports `unstyled` and a `class` override; multi-part ones take a `ui` prop for per-slot classes.
359
-
360
- ### Button groups
361
-
362
- `IButtonGroup` joins whatever you put inside it — buttons, a menu trigger, a link — squaring the inner edges and collapsing the shared borders. Set `size` once on the group and the buttons inherit it:
363
-
364
- ```vue
365
- <template>
366
- <IButtonGroup>
367
- <IButton @click="save">
368
- Save
369
- </IButton>
370
- <IDropdownMenu :items="saveActions" align="end">
371
- <template #trigger>
372
- <IButton square aria-label="More options">
373
- <ChevronDown />
374
- </IButton>
58
+ <IApp appearance="system">
59
+ <ICard>
60
+ <IFormField label="Email" description="We'll never share it.">
61
+ <IInput v-model="email" type="email" placeholder="you@example.com" />
62
+ </IFormField>
63
+
64
+ <template #footer>
65
+ <IButton @click="save">Save</IButton>
375
66
  </template>
376
- </IDropdownMenu>
377
- </IButtonGroup>
378
- </template>
379
- ```
380
-
381
- ### Menus
382
-
383
- `IDropdownMenu` takes entries as data. A `'-'` is a separator, an entry without `onSelect` is a group label, and one with its own `items` opens a submenu — to any depth:
384
-
385
- ```ts
386
- const items = [
387
- { label: 'Invoice' },
388
- { label: 'Open', icon: Search, onSelect: () => open() },
389
- { label: 'Export as', icon: Download, items: [
390
- { label: 'PDF', onSelect: () => exportPdf() },
391
- { label: 'CSV', onSelect: () => exportCsv() },
392
- ] },
393
- '-',
394
- { label: 'Delete', icon: Trash2, danger: true, onSelect: () => remove() },
395
- ]
396
- ```
397
-
398
- ### Toasts and confirmations
399
-
400
- Both are imperative, so they can be called from anywhere — including plain functions outside a component. Mount each host once, typically just inside `<IApp>`:
401
-
402
- ```vue
403
- <template>
404
- <IApp>
405
- <!-- your app -->
406
- <IConfirmDialog />
407
- <IToaster />
67
+ </ICard>
408
68
  </IApp>
409
69
  </template>
410
70
  ```
411
71
 
412
- ```ts
413
- const toast = useToast()
414
- toast.success('Saved')
415
- toast.danger({ title: 'Failed to send', description: 'Check the address.' })
416
- toast.toast({ title: 'Note deleted', action: { label: 'Undo', onClick: restore } })
417
-
418
- const { confirm } = useConfirm()
419
- if (await confirm({ title: 'Delete this draft?', danger: true }))
420
- await remove()
421
- ```
422
-
423
- `confirm()` resolves `true` on confirmation and `false` on cancel or dismissal.
424
-
425
- ### Drawers and sheets
426
-
427
- `IDrawer` is a panel attached to an edge of the viewport. It shares `IDialog`'s slots — `trigger`, `header` / `title` / `description`, the default body slot and `footer` (which receives `close`) — plus `dismissible`, `showClose` and `closeLabel`:
428
-
429
- ```vue
430
- <IDrawer v-model:open="filtersOpen" title="Filters" description="Narrow the list down.">
431
- <IInput v-model="search" placeholder="Reference or name" clearable />
432
- <template #footer="{ close }">
433
- <IButton variant="outline" @click="close()">
434
- Reset
435
- </IButton>
436
- <IButton @click="close()">
437
- Apply
438
- </IButton>
439
- </template>
440
- </IDrawer>
441
- ```
442
-
443
- `side` picks the edge — `right` (default), `left`, `top` or `bottom` — and doubles as the direction you drag to dismiss. `size` means width on a `left`/`right` drawer and maximum height on a `top`/`bottom` sheet, so `sm`–`xl` and `full` read naturally either way. A sheet gets a drag handle by default and a side drawer does not; `handle` overrides that in both directions.
444
-
445
- Add `snapPoints` for a sheet that rests part-way. Points are fractions of the viewport (`0.45`), pixel numbers, or CSS lengths (`'20rem'`), and `v-model:snapPoint` reads or sets the current one:
446
-
447
- ```vue
448
- <IDrawer
449
- v-model:open="open"
450
- v-model:snap-point="snap"
451
- side="bottom"
452
- :snap-points="[0.45, 1]"
453
- title="Payment method"
454
- />
455
- ```
456
-
457
- Snap points position the panel by translating it rather than resizing it, so `size` stops capping the height when they are set — otherwise the fully expanded state would be clipped instead of parked below the fold.
458
-
459
- `modal` controls how much of the page the drawer takes over: `true` (default) traps focus and blocks everything behind it, `'trap-focus'` keeps the page interactive while still holding the Tab ring — what a persistent side panel wants — and `false` does neither.
460
-
461
- Dragging is real pointer work, so it only happens in a browser. The panel follows the finger, snaps back when the drag is too short, and dismisses when it is not; `dismissible: false` refuses the swipe along with Escape and the overlay, while the corner button still closes.
462
-
463
- ### Internationalisation
464
-
465
- No English string is baked in without an escape hatch. `IAlert`, `IDialog`, `IDrawer` and `IToaster` take a `closeLabel`, `IPagination` takes `prevLabel` / `nextLabel` / `label`, `IBreadcrumb` and `ISkeleton` take a `label`, and `IProgress` and `IStat` take `formatValue` / `formatDelta` for locale-aware numbers.
466
-
467
- ### Forms
72
+ ## Three ways to take the styling back
468
73
 
469
74
  ```vue
470
- <script setup lang="ts">
471
- import { ref } from 'vue'
472
-
473
- const email = ref('')
474
- const plan = ref('pro')
475
- const framework = ref('vue')
476
- </script>
75
+ <!-- merge with the built-in classes -->
76
+ <IButton class="rounded-full" />
477
77
 
478
- <template>
479
- <ILabel for="email" required>
480
- Email
481
- </ILabel>
482
- <IInput id="email" v-model="email" type="email" placeholder="you@example.com" />
78
+ <!-- reach a single slot -->
79
+ <ISelect :ui="{ content: 'w-72' }" />
483
80
 
484
- <ILabel class="gap-2">
485
- <ICheckbox v-model="accepted" /> Accept terms
486
- </ILabel>
487
-
488
- <ISelect
489
- v-model="framework"
490
- placeholder="Pick one"
491
- :items="['Vue', 'React', { label: 'Angular', value: 'ng', disabled: true }]"
492
- />
493
-
494
- <IRadioGroup v-model="plan" :items="['free', 'pro']" />
495
- </template>
81
+ <!-- keep the primitive, drop the paint -->
82
+ <IDialog unstyled />
496
83
  ```
497
84
 
498
- #### Input affixes, clearing, loading and debounce
499
-
500
- `IInput` renders its chrome on a wrapper element, so `leading` and `trailing`
501
- slot content sits *inside* the field and takes real space — a long value is
502
- truncated by the affix rather than sliding underneath it.
503
-
504
- ```vue
505
- <IInput v-model="search" clearable placeholder="Search invoices…">
506
- <template #leading>
507
- <HugeiconsIcon :icon="Search01Icon" />
508
- </template>
509
- </IInput>
510
-
511
- <IInput v-model="team" placeholder="your-team">
512
- <template #trailing>
513
- <span class="text-sm">.example.com</span>
514
- </template>
515
- </IInput>
516
-
517
- <!-- Spinner in the trailing area; the field stays editable. -->
518
- <IInput v-model="slug" :loading="checking" />
519
-
520
- <!-- The model updates 500ms after the last keystroke. -->
521
- <IInput v-model="query" :debounce="500" clearable />
522
- ```
523
-
524
- | Prop | Effect |
525
- | --- | --- |
526
- | `clearable` | Clear button in the trailing area whenever the field is non-empty |
527
- | `loading` | Spinner in the trailing area. Does **not** disable the field |
528
- | `debounce` | Milliseconds to wait after the last keystroke before the model updates. `0` (default) updates on every keystroke |
529
- | `clearLabel` | Accessible name for the clear button, for non-English apps |
530
-
531
- The displayed text always updates on the keystroke — only the model lags. Blur
532
- and Enter flush a pending update immediately, so a submit never reads a stale
533
- value, and an external write (a reset or prefill) cancels whatever is queued.
534
-
535
- `class` lands on the wrapper, since that is the element carrying the field
536
- chrome; use `ui` to reach the parts (`root`, `input`, `leading`, `trailing`,
537
- `clear`). Stray attributes like `name`, `autocomplete` and `maxlength` are
538
- forwarded to the `<input>` itself. `ref` exposes the element as `.input` for
539
- focus management.
540
-
541
- #### Files
542
-
543
- ```vue
544
- <script setup lang="ts">
545
- import { ref } from 'vue'
546
-
547
- const logo = ref<File[]>([])
548
- </script>
549
-
550
- <template>
551
- <IFileUpload
552
- v-model="logo"
553
- accept="image/*"
554
- :max-size="2 * 1024 * 1024"
555
- label="Drag your logo here"
556
- browse-label="Browse images"
557
- hint="PNG, JPG or SVG up to 2 MB"
558
- @reject="onReject"
559
- />
560
- </template>
561
- ```
562
-
563
- The model is **always a `File[]`**, even without `multiple` — a `File | File[]`
564
- union would make every caller narrow the type before touching it, and the
565
- single case is just an array holding at most one. Without `multiple`, picking
566
- again replaces rather than appends.
567
-
568
- | Prop | Effect |
569
- | --- | --- |
570
- | `multiple` | Accept more than one file |
571
- | `accept` | Native syntax: `image/*`, `.pdf`, `image/png` |
572
- | `maxSize` | Largest accepted size, in bytes |
573
- | `maxFiles` | Cap on how many files may be held at once |
574
- | `label` | Prompt in the zone, above the hint |
575
- | `browseLabel` | Text on the browse button |
576
- | `hint` | Fine print under the prompt — the accepted types belong here |
577
-
578
- Every string is a prop, including `removeLabel` and the three rejection
579
- messages, so nothing bakes in English.
580
-
581
- `accept` is enforced in the component as well as on the input, because a
582
- dragged-in file bypasses the native filter entirely. Refused files raise
583
- `@reject` with `{ file, reason }` — `'type'`, `'size'` or `'count'` — so you
584
- can word your own message; the built-in text is available through the
585
- `tooLargeText`, `wrongTypeText` and `tooManyText` props.
586
-
587
- Image files get a thumbnail, anything else a placeholder of the same size so
588
- rows stay aligned. The object URLs behind those thumbnails are revoked as soon
589
- as a file leaves the list or the component unmounts.
590
-
591
- #### Dates
592
-
593
- The model is an ISO `YYYY-MM-DD` **string**, never a `Date`.
594
-
595
- ```vue
596
- <script setup lang="ts">
597
- import { ref } from 'vue'
598
-
599
- const issuedOn = ref<string | null>('2026-08-15')
600
- const period = ref({ start: '2026-08-01', end: '2026-08-31' })
601
- </script>
602
-
603
- <template>
604
- <IDatePicker v-model="issuedOn" clearable />
605
- <IDateRangePicker v-model="period" clearable />
606
- </template>
607
- ```
608
-
609
- A `Date` is a timestamp, so it always carries a time zone. `new Date('2026-08-15')`
610
- parses as UTC midnight, and a user west of Greenwich formatting it locally sees
611
- the 14th — which silently moves a record into the wrong reporting period. A
612
- calendar date has no zone, so it stays the day you picked. Internally the
613
- components use `@internationalized/date`; that never reaches your model, so
614
- formatting the string with `dayjs` or anything else on the way out is fine.
615
-
616
- | Prop | Effect |
617
- | --- | --- |
618
- | `min` / `max` | Selectable bounds, as ISO strings |
619
- | `locale` | Month names, weekday initials, and the trigger's text |
620
- | `format` | `Intl.DateTimeFormatOptions` for the trigger, e.g. `{ dateStyle: 'full' }` |
621
- | `weekStartsOn` | `0` is Sunday. Defaults to the locale's convention |
622
- | `clearable` | Adds a clear action to the footer |
623
- | `months` | Range picker only — months side by side, default `2` |
624
- | `separator` | Range picker only — text between the two dates |
625
-
626
- Navigation and footer labels (`todayLabel`, `clearLabel`, `previousLabel`,
627
- `nextLabel`) are all props, so nothing bakes in English.
628
-
629
- Both render their calendar at a fixed six weeks, so a short month cannot stretch
630
- its rows to match a taller neighbour and the popover does not resize as you page
631
- through it. The range picker draws only the committed range — the days between
632
- the endpoints take a flat tint while the two ends take the solid fill, so a long
633
- span still shows where it begins and ends.
634
-
635
- The helpers behind them are exported, for formatting the same values elsewhere:
636
-
637
- ```ts
638
- import { formatIsoDate, isoToday, toCalendarDate, toIsoDate } from 'iryx-ui'
639
-
640
- formatIsoDate('2026-08-15', 'en-GB', { dateStyle: 'long' }) // '15 August 2026'
641
- formatIsoDate('nonsense') // '' — malformed input is "no selection", not a crash
642
- ```
643
-
644
- #### Passwords
645
-
646
- `IPasswordInput` is `IInput` with a reveal toggle in the trailing area, plus an
647
- optional four-segment strength meter.
648
-
649
- ```vue
650
- <IPasswordInput v-model="password" strength />
651
-
652
- <!-- Toggle only, no meter -->
653
- <IPasswordInput v-model="password" />
654
-
655
- <!-- No toggle either -->
656
- <IPasswordInput v-model="password" :toggle="false" />
657
- ```
658
-
659
- The score counts length (8 and 12 characters), mixed case, a digit and a
660
- symbol, capped at four. It is a deliberately transparent nudge toward better
661
- passwords, **not** a security control — enforce real policy in the `IForm`
662
- validator, where it can actually reject a value.
663
-
664
- Every string is overridable, since components must not bake in English:
665
-
666
- ```vue
667
- <IPasswordInput
668
- v-model="password"
669
- strength
670
- show-label="Afficher le mot de passe"
671
- hide-label="Masquer le mot de passe"
672
- :strength-labels="['Faible', 'Moyen', 'Bon', 'Fort']"
673
- />
674
- ```
675
-
676
- `class` lands on the wrapper that stacks the field above the meter; `ui` reaches
677
- `root`, `input`, `toggle`, `meter`, `track`, `segment` and `label`.
678
-
679
- #### Autosizing textareas
680
-
681
- ```vue
682
- <!-- Grows without limit -->
683
- <ITextarea v-model="note" autosize />
684
-
685
- <!-- Between 2 and 8 rows, then scrolls -->
686
- <ITextarea v-model="note" :autosize="{ min: 2, max: 8 }" />
687
- ```
688
-
689
- `autosize` overrides `rows` and drops the drag handle, since the measured
690
- height is the point. The field shrinks as well as grows, and re-measures when
691
- the model changes from outside — a reset or a prefill resizes correctly.
692
-
693
- `ISelect` and `IRadioGroup` accept plain strings or `{ label, value, disabled }` objects. Both also take a default slot if you'd rather compose the Reka primitives yourself.
694
-
695
- `ISelect` also takes groups — an entry with its own `items` becomes a labelled
696
- heading, the same shape `ICombobox` uses:
697
-
698
- ```vue
699
- <ISelect
700
- v-model="framework"
701
- :items="[
702
- { label: 'Virtual DOM', items: ['Vue', 'React'] },
703
- { label: 'Compiled', items: [{ label: 'Svelte', value: 'svelte' }] },
704
- ]"
705
- />
706
- ```
707
-
708
- ### Searchable selects
709
-
710
- `ICombobox` takes the same `items` as `ISelect` and filters them against what
711
- the user types, which is what you want once a list runs to hundreds of entries.
712
- The field shows the selected option's **label** while the model holds its value.
713
-
714
- ```vue
715
- <ICombobox
716
- v-model="clientId"
717
- :items="clients"
718
- placeholder="Search clients"
719
- empty-text="No clients found."
720
- />
721
- ```
722
-
723
- Set `create` to offer a row for whatever the user typed when nothing matches.
724
- Choosing it emits `create` with the query and does **not** change the model —
725
- the option doesn't exist yet, so you add it and select it yourself:
726
-
727
- ```vue
728
- <ICombobox
729
- v-model="clientId"
730
- :items="clients"
731
- create
732
- :create-label="query => `Add ${query}`"
733
- @create="query => clients.push({ label: query, value: addClient(query) })"
734
- />
735
- ```
736
-
737
- Both `empty-text` and `create-label` are props precisely so a non-English app
738
- never inherits an English string; `empty` and `create` slots take over the
739
- markup entirely if you need more than text.
740
-
741
- An entry with its own `items` becomes a labelled group. A group disappears on
742
- its own once nothing inside it matches:
743
-
744
- ```vue
745
- <ICombobox
746
- v-model="clientId"
747
- :items="[
748
- { label: 'Recent', items: ['Acme Industries', 'Bolt Logistics'] },
749
- { label: 'Archived', items: [{ label: 'Cirrus Systems', value: 'cirrus' }] },
750
- ]"
751
- />
752
- ```
753
-
754
- For lists in the thousands, `virtual` renders only the rows on screen:
755
-
756
- ```vue
757
- <ICombobox v-model="sku" virtual :items="fiveThousandItems" :estimate-size="32" />
758
- ```
759
-
760
- `virtual` and grouped items are **mutually exclusive** — the underlying
761
- virtualizer is a flat window with no notion of group headings, so groups are
762
- flattened and their labels dropped (with a warning in dev). `estimate-size` is
763
- the assumed row height in px, used to size the scrollbar before rows are
764
- measured; set it if you have restyled rows to a different height.
765
-
766
- ### Numbers and money
767
-
768
- `INumberInput` never turns your value into a `number`. The model is a decimal
769
- **string**, because binary floating point cannot represent decimal money —
770
- `0.1 + 0.2` is `0.30000000000000004`, and `10.00` becomes `10`. Values are
771
- added, compared and rounded with `BigInt` internally, so precision survives
772
- regardless of magnitude.
773
-
774
- ```vue
775
- <script setup lang="ts">
776
- import { ref } from 'vue'
777
-
778
- // A string, and it stays one.
779
- const amount = ref('1234.56')
780
- </script>
781
-
782
- <template>
783
- <INumberInput v-model="amount" locale="sl" :precision="2" step="0.01" min="0" />
784
- </template>
785
- ```
786
-
787
- `locale` affects the **display only** — `sl` shows `1.234,56` while the model
788
- stays `"1234.56"`. Typing in the locale's own format works too. While the field
789
- is focused it shows the canonical value so separators can't fight your typing.
790
-
791
- `precision` fixes the number of decimal places, rounding half-up, and preserves
792
- trailing zeros (`"10.00"` stays `"10.00"`). `min`, `max` and `step` are decimal
793
- strings as well, and stepping is exact: `0.1 + 0.2` gives `"0.3"`.
794
-
795
- The underlying helpers are exported if you need them elsewhere:
796
-
797
- ```ts
798
- import { addDecimals, compareDecimals, roundDecimal } from 'iryx-ui'
799
-
800
- addDecimals('0.1', '0.2') // '0.3'
801
- roundDecimal('1.005', 2) // '1.01'
802
- compareDecimals('1.10', '1.1') // 0
803
- ```
804
-
805
- ### Validated forms
806
-
807
- `IForm` handles client-side validation. It accepts any [Standard Schema](https://standardschema.dev) validator — Zod 3.24+, Valibot, ArkType — so Iryx doesn't depend on a validation library. Wrap each control in an `IFormField` with a `name` matching the schema path and errors wire themselves up.
808
-
809
- ```vue
810
- <script setup lang="ts">
811
- import { reactive } from 'vue'
812
- import * as z from 'zod'
813
-
814
- const schema = z.object({
815
- email: z.string().email('That doesn\'t look like an email'),
816
- password: z.string().min(8, 'Use at least 8 characters'),
817
- })
818
-
819
- const state = reactive({ email: '', password: '' })
820
-
821
- function onSubmit(event) {
822
- console.log(event.data) // only fires when valid
823
- }
824
- </script>
825
-
826
- <template>
827
- <IForm :state="state" :schema="schema" @submit="onSubmit">
828
- <IFormField name="email" label="Email" required description="We'll never share it.">
829
- <IInput v-model="state.email" type="email" />
830
- </IFormField>
831
- <IFormField name="password" label="Password" help="At least 8 characters.">
832
- <IInput v-model="state.password" type="password" />
833
- </IFormField>
834
- <IButton type="submit">
835
- Create account
836
- </IButton>
837
- </IForm>
838
- </template>
839
- ```
840
-
841
- The control inside a field automatically inherits its `id`, invalid styling and `aria-describedby` — no wiring needed. On a failed submit, focus moves to the first invalid control.
842
-
843
- **Validation timing** — `validate-on` defaults to `['blur', 'change']`; submit always validates everything.
844
-
845
- **Custom rules** — pass `validate` for anything a schema can't express (it runs alongside the schema, and works without one):
846
-
847
- ```vue
848
- <IForm
849
- :state="state"
850
- :validate="s => s.email.endsWith('@corp.com') ? [] : [{ name: 'email', message: 'Must be a work email' }]"
851
- />
852
- ```
853
-
854
- **Server errors and manual control** — grab a template ref to the form and call `validate()`, `clear(name?)` or `setErrors()`. `IFormField` also takes a plain `error` prop that bypasses validation entirely.
855
-
856
- ### Labels and descriptions
857
-
858
- `ICheckbox` and `ISwitch` render bare by default. Give them a `label` and/or `description` and they render a wired-up layout instead — the text is clickable, and the description is linked with `aria-describedby`. `IRadioGroup` items take a `description` too.
859
-
860
- ```vue
861
- <template>
862
- <ICheckbox
863
- v-model="accepted"
864
- label="Accept terms"
865
- description="You agree to the terms of service and privacy policy."
866
- />
867
-
868
- <ISwitch
869
- v-model="notify"
870
- label="Push notifications"
871
- description="Send alerts to this device."
872
- />
873
-
874
- <IRadioGroup
875
- v-model="plan"
876
- :items="[
877
- { label: 'Free', value: 'free', description: 'Up to 3 projects.' },
878
- { label: 'Pro', value: 'pro', description: 'Unlimited projects.' },
879
- ]"
880
- />
881
- </template>
882
- ```
883
-
884
- Use the `#label` / `#description` slots instead of the props when you need markup (a link, a badge) inside the text.
885
-
886
- Where a component has no visible label — a search field, a bare select — put `aria-label` on it directly. Attributes always land on the control the label belongs to, not on whatever wrapper the component happens to render around it:
887
-
888
- ```vue
889
- <template>
890
- <IInput v-model="q" aria-label="Search invoices" />
891
- <ISelect v-model="status" :items="statuses" aria-label="Filter by status" />
892
- <INumberInput v-model="amount" aria-label="Amount" />
893
- </template>
894
- ```
895
-
896
- The same holds for `name`, `autocomplete`, `maxlength` and `data-*`. `IProgress` is the one that needs saying twice: its `label` prop renders visible text *and* names the bar for assistive tech, so pass `aria-label` only when there is no visible label to use.
897
-
898
- An automated axe sweep runs over every component on each commit. It is a floor rather than a guarantee — it catches roughly a third of real barriers, and contrast rules need a real browser — but nothing ships with an unlabelled control or a nested interactive element.
899
-
900
- ### Alerts and banners
901
-
902
- They look similar and do different jobs, so they're separate components.
903
-
904
- **`IAlert` is contextual.** It sits in the flow next to the thing it's about —
905
- a failed upload, a form that won't submit — boxed, with an icon and a title.
906
- Danger and warning variants take `role="alert"` so a screen reader interrupts,
907
- because the user caused it and needs to know now.
908
-
909
- **`IBanner` is page-level.** It spans the full width and announces something
910
- that isn't about any one element: a trial ending, scheduled maintenance. It's a
911
- labelled `role="region"`, never an alert — it's ambient, so interrupting
912
- someone mid-task would be wrong.
913
-
914
- ```vue
915
- <IBanner
916
- v-model:open="showTrial"
917
- variant="primary"
918
- position="top"
919
- title="Trial ends in 3 days."
920
- description="Upgrade to keep your data."
921
- closable
922
- label="Trial notice"
923
- >
924
- <template #actions>
925
- <IButton size="sm" variant="outline">Upgrade</IButton>
926
- </template>
927
- </IBanner>
928
- ```
929
-
930
- `position` is `static` (in flow), `top` (sticky) or `bottom` (fixed to the
931
- viewport, with the rule moved to its top edge). `contained` keeps the text at a
932
- readable measure while the fill still spans the window.
933
-
934
- Both dismiss through `v-model:open`, so the usual case is one binding instead
935
- of a `close` handler plus a `v-if`. `close` still fires if you need to persist
936
- or confirm first:
937
-
938
- ```vue
939
- <IAlert v-model:open="visible" variant="danger" title="Upload failed" closable>
940
- The file was larger than 10 MB.
941
- <template #actions>
942
- <IButton size="sm" @click="retry()">Retry</IButton>
943
- </template>
944
- </IAlert>
945
- ```
946
-
947
- ### Tables
948
-
949
- `ITable` takes `rows` and `columns` and renders a real `<table>`. Columns are
950
- plain objects — no render functions — and anything beyond the raw value is a
951
- slot:
952
-
953
- ```vue
954
- <script setup>
955
- const columns = [
956
- { key: 'number', label: 'Invoice', sortable: true },
957
- { key: 'customer.name', label: 'Customer', sortable: true, sortKey: 'customer_name' },
958
- { key: 'total', label: 'Total', sortable: true, numeric: true },
959
- { key: 'status', label: 'Status' },
960
- ]
961
- </script>
962
-
963
- <template>
964
- <ITable :rows="invoices" :columns="columns" label="Invoices">
965
- <template #cell-total="{ value }">
966
- {{ formatMoney(value) }}
967
- </template>
968
- <template #cell-status="{ row }">
969
- <IBadge :variant="row.paid ? 'success' : 'warning'">
970
- {{ row.paid ? 'Paid' : 'Due' }}
971
- </IBadge>
972
- </template>
973
- </ITable>
974
- </template>
975
- ```
976
-
977
- `key` doubles as the accessor (dot-notation reaches nested values) and the slot
978
- suffix, so `#cell-customer.name` targets that column. `#header-<key>` replaces a
979
- header the same way.
980
-
981
- `numeric` gives a column tabular figures and end alignment, so amounts line up
982
- digit-for-digit down the column instead of wandering with each glyph's width.
983
- Set `align` alongside it to keep the figures but place the column differently.
984
-
985
- #### Client or server
986
-
987
- **The table never fetches.** It renders what you give it and emits what the
988
- user did, so caching, cancellation and auth stay in your data layer.
989
-
990
- Which mode it runs in is decided by one prop. Omit `total` and it sorts and
991
- paginates `rows` itself. Provide `total` and it does neither — the rows you
992
- passed are already the page the server returned, so it only reflects state and
993
- emits changes:
994
-
995
- ```vue
996
- <ITable
997
- v-model:sort="sort"
998
- v-model:page="page"
999
- :rows="data.rows"
1000
- :columns="columns"
1001
- :total="data.total"
1002
- :loading="pending"
1003
- />
1004
- ```
1005
-
1006
- Watch those models and refetch. Sorting a column resets `page` to 1, since the
1007
- old page number means nothing against a reordered list.
1008
-
1009
- #### State ownership
1010
-
1011
- Every model is optional. Bind one and you own that state — put it in the URL, a
1012
- store, or `useState`. Leave it unbound and the table keeps it internally, so the
1013
- simple case needs no wiring:
1014
-
1015
- | Model | Type |
1016
- | --- | --- |
1017
- | `v-model:sort` | `{ key, order } \| null` — `null` is "unsorted", distinct from never-sorted |
1018
- | `v-model:page` | `number`, 1-indexed |
1019
- | `v-model:perPage` | `number` |
1020
- | `v-model:selection` | `(string \| number)[]` of row keys |
1021
- | `v-model:expanded` | `(string \| number)[]` of row keys |
1022
-
1023
- #### Selection and expansion
1024
-
1025
- `selectable` adds a checkbox column whose header is tri-state over the rows on
1026
- screen. Selection is held as **row keys** (`rowKey`, default `'id'`), and
1027
- select-all only touches the current page, so selections made on other pages
1028
- survive. `isRowSelectable` vetoes a row — its checkbox is hidden and the header
1029
- skips it.
1030
-
1031
- `expandable` adds a disclosure column and renders the `#expanded` slot beneath
1032
- an open row; `canExpandRow` vetoes it per row.
1033
-
1034
- ```vue
1035
- <ITable
1036
- v-model:selection="selected"
1037
- :rows="rows"
1038
- :columns="columns"
1039
- selectable
1040
- expandable
1041
- :is-row-selectable="row => !row.locked"
1042
- >
1043
- <template #expanded="{ row }">
1044
- <div class="p-4">{{ row.notes }}</div>
1045
- </template>
1046
- </ITable>
1047
- ```
1048
-
1049
- #### States and props
1050
-
1051
- `loading` sets `aria-busy` and, when there is nothing to show yet, renders
1052
- `loadingRows` skeleton rows instead of the empty message. With rows already on
1053
- screen it leaves them in place, so a refetch doesn't blank the table. Otherwise
1054
- `emptyText` — or the `#empty` slot — takes over.
1055
-
1056
- | Prop | What it does |
1057
- | --- | --- |
1058
- | `rowKey` | Field identifying a row. Default `'id'` |
1059
- | `clickableRows` | Emits `rowClick` and shows a pointer cursor |
1060
- | `striped` / `hoverable` / `stickyHeader` | Row and header treatment |
1061
- | `size` | `sm` / `md` / `lg` row density |
1062
- | `label` / `caption` | Accessible name, and an optional visible caption |
1063
-
1064
- Headers are real `<button>`s, sorted columns carry `aria-sort`, and the table is
1065
- a plain `<table>`, so screen readers and keyboard users get the semantics for
1066
- free. Give it a `label` (or a `caption`) — a table with no accessible name is
1067
- hard to place when tabbing through a page.
1068
-
1069
- #### Without the markup
1070
-
1071
- `useDataTable()` holds the whole state machine — sorting, paging, selection,
1072
- expansion — and renders nothing, if you want the logic under your own markup:
1073
-
1074
- ```ts
1075
- const table = useDataTable({
1076
- rows: () => rows.value,
1077
- columns: () => columns,
1078
- })
1079
- // table.pageRows, table.toggleSort, table.headerSelection, …
1080
- ```
1081
-
1082
- ### Charts
1083
-
1084
- A small set of chart types done properly, in plain SVG — no charting
1085
- dependency, nothing to register, and no canvas. They inherit the theme the
1086
- same way every other component does, so they follow light/dark and any token
1087
- override for free. For the exotic five percent, reach for Chart.js; that is
1088
- the intended escape hatch, not a gap.
1089
-
1090
- Every chart is `aria-hidden` with a screen-reader table carrying the numbers,
1091
- and that table renders before measurement — the data is never gated behind
1092
- layout.
1093
-
1094
- #### Sparklines
1095
-
1096
- A trend at a glance, for a stat tile or a table cell.
1097
-
1098
- ```vue
1099
- <ISparkline
1100
- :data="[4200, 4600, 4100, 5200, 5800, 6300]"
1101
- variant="area"
1102
- end-dot
1103
- label="Revenue over six months, trending up"
1104
- />
1105
- ```
1106
-
1107
- Because it is SVG, colour comes from `currentColor`: recolour it with a text
1108
- utility, and it follows your theme preset and light/dark automatically with no
1109
- JavaScript. A canvas chart cannot read CSS variables, so it would need a
1110
- re-render on every theme change.
1111
-
1112
- | Prop | Effect |
1113
- | --- | --- |
1114
- | `data` | Values, oldest first. `null` is a **gap**, not a zero — the line breaks |
1115
- | `variant` | `line` (default) or `area`, which adds a wash beneath the line |
1116
- | `endDot` | Marks the most recent point |
1117
- | `baseline` | Lower edge of the `area` wash: `min` (default) or `zero` |
1118
- | `min` / `max` | Pin the domain — set both to put several sparklines on one scale |
1119
- | `muted` | Draw in muted ink, for a de-emphasised trend |
1120
- | `height` | Rendered height in px (default 32). Width always fills the container |
1121
-
1122
- Width is fluid and the stroke never distorts: the drawing stretches via
1123
- `preserveAspectRatio="none"`, while every stroke carries
1124
- `vector-effect="non-scaling-stroke"`, so a 2px line stays 2px and the end dot
1125
- stays circular at any aspect ratio.
1126
-
1127
- `label` sets an accessible description. **Without one the sparkline is hidden
1128
- from assistive tech as decorative** — which is correct when it sits beside a
1129
- value that already states the number, and wrong if it is the only thing
1130
- carrying the information.
1131
-
1132
- Edge cases behave: an empty series draws nothing, a flat series draws through
1133
- the middle rather than collapsing to an edge, and a single reading is a dot.
1134
-
1135
- The scale helpers are exported for building your own marks:
1136
-
1137
- ```ts
1138
- import { extent, linearScale } from 'iryx-ui'
1139
-
1140
- extent([3, null, 9, 1]) // [1, 9] — gaps ignored
1141
- linearScale([0, 10], [100, 0])(10) // 0 — ranges may be inverted for SVG's y-axis
1142
- ```
1143
-
1144
- #### Line charts
1145
-
1146
- ```vue
1147
- <ILineChart
1148
- :data="[
1149
- { label: 'Jan', value: 4200 },
1150
- { label: 'Feb', value: null },
1151
- { label: 'Mar', value: 5600 },
1152
- ]"
1153
- variant="area"
1154
- label="Revenue by month"
1155
- />
1156
- ```
1157
-
1158
- Same `data`, `height`, `ticks`, `axis`, `locale`, `format` and `label` props as
1159
- `IBarChart`, plus:
1160
-
1161
- | Prop | Effect |
1162
- | --- | --- |
1163
- | `variant` | `line` (default) or `area`, which adds a wash beneath the line |
1164
- | `zero` | Force zero onto the axis. **Off by default** |
1165
-
1166
- **`zero` is off here and always on for bars, deliberately.** A bar is read by
1167
- length, so a truncated baseline lies about the comparison. A line is read by
1168
- its *shape*, and a series hovering around 8,000 flattens into a straight edge
1169
- once the axis starts at nothing. Turn it on when the distance from zero is the
1170
- point.
1171
-
1172
- `null` breaks the line rather than bridging it, so a missing reading never
1173
- draws a slope that didn't happen.
1174
-
1175
- Hovering shows a crosshair and a single ringed marker on the reading under the
1176
- cursor — not a dot on every point, which is noise the axis and tooltip already
1177
- cover.
1178
-
1179
- #### Bar charts
1180
-
1181
- ```vue
1182
- <IBarChart
1183
- :data="[
1184
- { label: 'Jan', value: 4200 },
1185
- { label: 'Feb', value: 5600 },
1186
- { label: 'Mar', value: null },
1187
- ]"
1188
- label="Revenue by month"
1189
- locale="de-DE"
1190
- :format="{ style: 'currency', currency: 'EUR', maximumFractionDigits: 0 }"
1191
- />
1192
- ```
1193
-
1194
- | Prop | Effect |
1195
- | --- | --- |
1196
- | `data` | `{ label, value }[]`. `value: null` is a **missing reading** — no bar, which is not a zero |
1197
- | `height` | Rendered height in px (default 240). Width fills the container |
1198
- | `ticks` | Target tick count. A hint — the axis lands on round numbers first |
1199
- | `axis` | Set `false` to drop the value axis and gridlines |
1200
- | `locale` / `format` | `Intl.NumberFormat` settings, applied to ticks and tooltip alike |
1201
- | `label` | Accessible name for the figure |
1202
-
1203
- **The axis picks the domain, not the data.** Values are snapped outwards to a
1204
- 1/2/5 step, so an axis reads `0 / 2,000 / 4,000` rather than `0 / 1,726.8`.
1205
- Zero is always included, because bars are compared by length and a truncated
1206
- baseline makes that comparison a lie.
1207
-
1208
- Bars are capped at 24px and never fill their slot — the gap between them is
1209
- what separates them. They're rounded at the data end and square at the
1210
- baseline, so the rounding reads as the tip of the value.
1211
-
1212
- Hovering a bar dims the rest and shows a tooltip; hit targets span the full
1213
- band and plot height, so a short bar is no harder to hit than a tall one.
1214
-
1215
- **Accessibility:** the SVG is `aria-hidden` and the data is exposed as a
1216
- visually-hidden table instead, so a screen reader gets the actual numbers
1217
- rather than a blank graphic. That table renders even before the container has
1218
- been measured — the data is never gated behind layout.
1219
-
1220
- **`stacked`** turns grouped series into one bar per category:
1221
-
1222
- ```vue
1223
- <IBarChart :data="cashflow" :series="series" stacked label="Cashflow" />
1224
- ```
1225
-
1226
- Stacking answers *"what makes up the total"*; grouping answers *"how do these
1227
- compare"*. Only the bottom segment shares a baseline, so segments above it are
1228
- hard to compare across categories — stay with grouped bars when the comparison
1229
- matters more than the total.
1230
-
1231
- The axis is sized against the running totals, only the outermost segment is
1232
- rounded, and the tooltip adds a **Total** row (`totalLabel` to rename it).
1233
- Negative values stack downward from zero rather than cancelling positives out,
1234
- so a mixed stack shows both sides at full length. Stacking is ignored for a
1235
- single series, and works horizontally too.
1236
-
1237
- **`orientation="horizontal"`** runs the categories down the side:
1238
-
1239
- ```vue
1240
- <IBarChart :data="spend" orientation="horizontal" label="Spend by category" />
1241
- ```
1242
-
1243
- Vertical charts thin colliding labels to every *n*th, which is fine for `Jan`
1244
- / `Feb` and lossy for `Travel and accommodation`. Turn the chart and the names
1245
- get real width, so nothing is dropped — that's the case horizontal is for.
1246
- Everything else behaves the same: grouped series, the tooltip, the round-number
1247
- axis anchored at zero.
1248
-
1249
- #### Multiple series
1250
-
1251
- Both charts take plain rows plus a `series` descriptor — the same shape
1252
- `ITable` uses. Omit `series` for the single-measure case.
1253
-
1254
- ```vue
1255
- <IBarChart
1256
- :data="[
1257
- { label: 'Jan', revenue: 4200, expenses: 3100 },
1258
- { label: 'Feb', revenue: 5600, expenses: 3400 },
1259
- ]"
1260
- :series="[
1261
- { key: 'revenue', name: 'Revenue', slot: 0 },
1262
- { key: 'expenses', name: 'Expenses', slot: 1 },
1263
- ]"
1264
- label="Cashflow by month"
1265
- />
1266
- ```
1267
-
1268
- Bars group inside their category; lines draw one path each. One hover reports
1269
- **every** series for that category in a single tooltip, so the reader compares
1270
- in one place instead of chasing marks.
1271
-
1272
- **`slot` pins a series to a palette colour.** Without it, colour follows array
1273
- position — so filtering a series out repaints the survivors and the reader has
1274
- to relearn the chart. Pin the slots whenever series can be toggled.
1275
-
1276
- **The legend is mandatory from two series up.** `legend: false` only silences
1277
- the single-series case, where the title already names what is plotted. Colour
1278
- alone is never a dependable identity channel, so this is not configurable.
1279
-
1280
- Past eight series the colours stop identifying anything; the chart warns in
1281
- development and you should fold the tail into "Other" or switch to small
1282
- multiples.
1283
-
1284
- `variant="area"` is ignored for multiple series — overlapping washes muddy into
1285
- a colour that belongs to neither.
1286
-
1287
- #### Chart colours
1288
-
1289
- Eight categorical slots, `--iryx-chart-1` … `--iryx-chart-8`, usable as Tailwind
1290
- colours (`text-chart-3`, `fill-chart-5`). They encode **identity** — which
1291
- series a mark belongs to — never magnitude.
1292
-
1293
- ```vue
1294
- <!-- One series per slot, assigned in order. -->
1295
- <ISparkline :data="revenue" class="text-chart-1" />
1296
-
1297
- <ISparkline :data="expenses" class="text-chart-2" />
1298
- ```
1299
-
1300
- Three rules, and they are not stylistic:
1301
-
1302
- - **Assign in order, never cycle.** A ninth series is not a generated ninth
1303
- hue — fold it into "Other", or switch to small multiples. A generated colour
1304
- hasn't been checked for separation against its neighbours.
1305
- - **Status colours are never series colours.** A series that happens to land in
1306
- slot 4 must not read as a warning. `success`/`warning`/`danger`/`info` stay
1307
- reserved.
1308
- - **Colour follows the entity, not its rank.** If a filter removes a series,
1309
- the survivors keep their slots rather than shifting up.
1310
-
1311
- Tailwind scans source text, so a class name assembled at runtime is never
1312
- generated — write the slots out, or reach for the variable:
1313
-
1314
- ```vue
1315
- <!-- Silently unstyled: Tailwind never sees this string -->
1316
- <ISparkline :class="`text-chart-${index + 1}`" />
1317
-
1318
- <!-- Either of these works -->
1319
- <ISparkline :class="['text-chart-1', 'text-chart-2'][index]" />
1320
-
1321
- <ISparkline :style="{ color: `var(--iryx-chart-${index + 1})` }" />
1322
- ```
1323
-
1324
- The steps are not eyeballed. Each clears a lightness band, a chroma floor, and
1325
- protanopia/deuteranopia separation against its own surface, checked with a
1326
- validator rather than by eye. **Dark has its own steps**, validated against the
1327
- dark background — not an automatic flip of the light ones.
1328
-
1329
- Two caps worth knowing before you design around them:
1330
-
1331
- | Chart form | Max series |
1332
- | --- | --- |
1333
- | Bars, lines, stacks — only neighbours touch | **8** |
1334
- | Scatter, bubble, small multiples — any two marks can sit side by side | **3** |
1335
-
1336
- Past those, the answer is fewer series or facets, not more colours. If you
1337
- re-step any slot, re-run the validator for **both** modes.
1338
-
1339
- #### Annotations, and why there is no plugin API
1340
-
1341
- Chart.js has plugins because canvas is opaque — once painted you cannot select
1342
- or style anything, so the only way in is an imperative draw hook. SVG has no
1343
- such problem, so these charts hand you the layout and let you write ordinary
1344
- markup into it:
1345
-
1346
- ```vue
1347
- <ILineChart :data="revenue" label="Revenue against target">
1348
- <template #overlay="{ plot, value }">
1349
- <line
1350
- :x1="plot.left" :y1="value(7000)"
1351
- :x2="plot.left + plot.width" :y2="value(7000)"
1352
- stroke="var(--iryx-warning)" stroke-width="2" stroke-dasharray="4 4"
1353
- />
1354
- </template>
1355
- </ILineChart>
1356
- ```
85
+ ## Requirements
1357
86
 
1358
- `#underlay` renders behind the marks — target bands, shaded regions. `#overlay`
1359
- renders in front — reference lines, callouts. Both sit below the hit targets,
1360
- so hovering keeps working through whatever you draw.
87
+ - Vue 3.5 or newer
88
+ - Tailwind CSS v4
1361
89
 
1362
- Both receive the `CartesianLayout`:
90
+ Ships as ESM with full type definitions. Every component renders on the server.
1363
91
 
1364
- | Prop | What it gives you |
1365
- | --- | --- |
1366
- | `plot` | `{ left, top, width, height }` of the plot rectangle, in px |
1367
- | `value(n)` | A data value to its pixel on the value axis |
1368
- | `bandCentre(i)` | The centre of category `i`, in px |
1369
- | `bandWidth` | Size of one category slot |
1370
- | `ticks` | The axis values actually drawn |
1371
- | `orientation` | `'vertical'` or `'horizontal'` |
92
+ ## Documentation
1372
93
 
1373
- That's strictly more capable than a draw hook: it's declarative, reactive, and
1374
- type-checked, with no lifecycle or registration order to learn.
94
+ Every component and composable has its own page, with the props in full and demos you can operate:
1375
95
 
1376
- For a chart type that doesn't exist here, the same primitives are exported —
1377
- `cartesianLayout`, `linearScale`, `niceTicks`, `seriesColor` — so you can build
1378
- one on the same spine rather than starting over.
96
+ - [Components](https://iryx-ui.com/components/)
97
+ - [Composables](https://iryx-ui.com/composables/)
98
+ - [Theming](https://iryx-ui.com/guide/theming)
1379
99
 
1380
100
  ## License
1381
101