iryx-ui 0.11.0 → 0.12.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 (51) hide show
  1. package/README.md +578 -498
  2. package/dist/component-names.d.ts +1 -1
  3. package/dist/component-names.js +1 -1
  4. package/dist/components/Alert.vue_vue_type_script_setup_true_lang.js +42 -42
  5. package/dist/components/Banner.vue_vue_type_script_setup_true_lang.js +40 -40
  6. package/dist/components/Breadcrumb.vue_vue_type_script_setup_true_lang.js +28 -28
  7. package/dist/components/Button.vue_vue_type_script_setup_true_lang.js +14 -14
  8. package/dist/components/Checkbox.vue_vue_type_script_setup_true_lang.js +37 -30
  9. package/dist/components/Combobox.vue_vue_type_script_setup_true_lang.js +62 -60
  10. package/dist/components/DatePicker.vue_vue_type_script_setup_true_lang.js +65 -65
  11. package/dist/components/DateRangePicker.vue_vue_type_script_setup_true_lang.js +74 -74
  12. package/dist/components/Dialog.vue_vue_type_script_setup_true_lang.js +34 -34
  13. package/dist/components/Drawer.js +5 -0
  14. package/dist/components/Drawer.vue.d.ts +94 -0
  15. package/dist/components/Drawer.vue_vue_type_script_setup_true_lang.js +144 -0
  16. package/dist/components/DropdownMenuItems.vue_vue_type_script_setup_true_lang.js +42 -42
  17. package/dist/components/FileUpload.vue_vue_type_script_setup_true_lang.js +77 -77
  18. package/dist/components/Icon.vue_vue_type_script_setup_true_lang.js +11 -11
  19. package/dist/components/Input.vue_vue_type_script_setup_true_lang.js +33 -33
  20. package/dist/components/NumberInput.vue_vue_type_script_setup_true_lang.js +68 -66
  21. package/dist/components/Pagination.vue_vue_type_script_setup_true_lang.js +37 -37
  22. package/dist/components/PasswordInput.vue_vue_type_script_setup_true_lang.js +37 -37
  23. package/dist/components/Progress.vue_vue_type_script_setup_true_lang.js +34 -31
  24. package/dist/components/Select.vue_vue_type_script_setup_true_lang.js +47 -45
  25. package/dist/components/Stat.vue_vue_type_script_setup_true_lang.js +27 -27
  26. package/dist/components/Stepper.vue_vue_type_script_setup_true_lang.js +38 -38
  27. package/dist/components/Switch.vue_vue_type_script_setup_true_lang.js +9 -2
  28. package/dist/components/Table.vue_vue_type_script_setup_true_lang.js +87 -87
  29. package/dist/components/Toaster.vue_vue_type_script_setup_true_lang.js +41 -41
  30. package/dist/components/index.d.ts +1 -0
  31. package/dist/components/index.js +54 -52
  32. package/dist/composables/date.js +12 -15
  33. package/dist/index.d.ts +1 -0
  34. package/dist/index.js +55 -54
  35. package/dist/theme/drawer.d.ts +110 -0
  36. package/dist/theme/drawer.js +100 -0
  37. package/dist/theme/index.d.ts +1 -0
  38. package/package.json +3 -2
  39. package/theme.css +114 -0
  40. package/dist/node_modules/.pnpm/@hugeicons_core-free-icons@4.2.3/node_modules/@hugeicons/core-free-icons/dist/esm/index.js +0 -377
  41. package/dist/node_modules/.pnpm/@hugeicons_vue@1.0.7_vue@3.5.35_typescript@6.0.3_/node_modules/@hugeicons/vue/dist/esm/_virtual/_plugin-vue_export-helper.js +0 -8
  42. package/dist/node_modules/.pnpm/@hugeicons_vue@1.0.7_vue@3.5.35_typescript@6.0.3_/node_modules/@hugeicons/vue/dist/esm/components/HugeiconsIcon.vue.js +0 -25
  43. package/dist/node_modules/.pnpm/@hugeicons_vue@1.0.7_vue@3.5.35_typescript@6.0.3_/node_modules/@hugeicons/vue/dist/esm/components/HugeiconsIcon.vue2.js +0 -63
  44. package/dist/node_modules/.pnpm/@internationalized_date@3.12.3/node_modules/@internationalized/date/dist/private/CalendarDate.js +0 -122
  45. package/dist/node_modules/.pnpm/@internationalized_date@3.12.3/node_modules/@internationalized/date/dist/private/DateFormatter.js +0 -84
  46. package/dist/node_modules/.pnpm/@internationalized_date@3.12.3/node_modules/@internationalized/date/dist/private/calendars/GregorianCalendar.js +0 -91
  47. package/dist/node_modules/.pnpm/@internationalized_date@3.12.3/node_modules/@internationalized/date/dist/private/conversion.js +0 -107
  48. package/dist/node_modules/.pnpm/@internationalized_date@3.12.3/node_modules/@internationalized/date/dist/private/manipulation.js +0 -159
  49. package/dist/node_modules/.pnpm/@internationalized_date@3.12.3/node_modules/@internationalized/date/dist/private/queries.js +0 -29
  50. package/dist/node_modules/.pnpm/@internationalized_date@3.12.3/node_modules/@internationalized/date/dist/private/string.js +0 -37
  51. package/dist/node_modules/.pnpm/@internationalized_date@3.12.3/node_modules/@internationalized/date/dist/private/utils.js +0 -6
package/README.md CHANGED
@@ -252,7 +252,7 @@ Tweak a single instance with `class` (conflicts are merged smartly) or per-slot
252
252
 
253
253
  ```vue
254
254
  <IButton class="rounded-full">
255
- Pill button
255
+ Pill button
256
256
  </IButton>
257
257
 
258
258
  <ISwitch :ui="{ thumb: 'bg-zinc-900' }" />
@@ -262,7 +262,7 @@ Or drop all built-in styles and take over completely:
262
262
 
263
263
  ```vue
264
264
  <IButton unstyled class="my-own-button">
265
- Headless
265
+ Headless
266
266
  </IButton>
267
267
  ```
268
268
 
@@ -287,22 +287,19 @@ app.use(createIryxUi({ unstyled: true }))
287
287
  | --- | --- |
288
288
  | `IForm` | Validating form wrapper — any Standard Schema validator, or your own function |
289
289
  | `IFormField` | Label, description, hint, help and error text around a control |
290
+ | `ILabel` | Field label with optional `required` asterisk |
290
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` |
291
293
  | `INumberInput` | Decimal-safe numeric field — the model is a **string**, with `min`/`max`/`step`, `precision` and locale-aware display |
292
- | `ISparkline` | Tiny inline trend chart pure SVG, no charting dependency |
293
- | `IBarChart` | Categorical bar chart with a round-number axis, hover tooltip and a table view |
294
- | `ILineChart` | Line or area chart with a crosshair, hover marker and a table view |
294
+ | `IPasswordInput` | Masked field with a show/hide toggle and an optional strength meter |
295
295
  | `IFileUpload` | Drag-and-drop file field with `accept` / `maxSize` / `maxFiles`, thumbnails and a remove action |
296
296
  | `IDatePicker` | Calendar in a popover; the model is an ISO `YYYY-MM-DD` **string** |
297
297
  | `IDateRangePicker` | Two-month range calendar; the model is `{ start, end }` ISO strings |
298
- | `IPasswordInput` | Masked field with a show/hide toggle and an optional strength meter |
299
- | `ITextarea` | Multi-line field with matching sizes, `invalid` state and optional `autosize` |
300
- | `ILabel` | Field label with optional `required` asterisk |
301
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` |
302
301
  | `ISelect` | Listbox with keyboard nav and typeahead, driven by an `items` array, with optional groups |
303
302
  | `ICombobox` | Searchable select — filters as you type, with optional groups, virtualized rows and a "create from query" row |
304
- | `IRadioGroup` | Radio list with labels wired up automatically; items take a `description` |
305
- | `ISwitch` | Accessible toggle, optional `label` + `description` |
306
303
 
307
304
  **Actions**
308
305
 
@@ -312,6 +309,15 @@ app.use(createIryxUi({ unstyled: true }))
312
309
  | `IButtonGroup` | Joins any children into a segmented control — split buttons, toolbars, pagers |
313
310
  | `IDropdownMenu` | Menu driven by an `items` array, with separators, group labels, danger items and nested submenus |
314
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
+
315
321
  **Feedback**
316
322
 
317
323
  | Component | Description |
@@ -320,14 +326,11 @@ app.use(createIryxUi({ unstyled: true }))
320
326
  | `IBanner` | Page-level announcement — full-bleed, six variants, sticky top or fixed bottom |
321
327
  | `IBadge` | Status pill — five variants × three sizes; `dot` moves the colour onto a leading dot |
322
328
  | `IToaster` | Host for `useToast()`; six viewport positions, stacking, action buttons |
323
- | `IDialog` | Modal with header/body/footer slots, `dismissible` and `showClose` |
324
- | `IConfirmDialog` | Host for `useConfirm()` — renders the promise-based confirmation |
325
329
  | `IProgress` | Determinate or `indeterminate` bar, five variants, `formatValue` |
326
330
  | `ISkeleton` | Loading placeholder — `text`/`rect`/`circle`, stackable with `lines` |
327
331
  | `IEmptyState` | Icon, title, description and an `actions` slot for empty lists |
328
- | `ITooltip` | Hover/focus tooltip with side, align, delay and optional arrow |
329
332
 
330
- **Navigation & data display**
333
+ **Navigation**
331
334
 
332
335
  | Component | Description |
333
336
  | --- | --- |
@@ -335,8 +338,22 @@ app.use(createIryxUi({ unstyled: true }))
335
338
  | `IBreadcrumb` | Trail from an `items` array; the last crumb is marked as the current page |
336
339
  | `IPagination` | Page list with ellipsis, edge pages and prev/next controls; `align` places it, `size` sets the button scale |
337
340
  | `IStepper` | Multi-step progress, horizontal or vertical, optional `linear` ordering |
338
- | `IStat` | KPI tile — label, value, signed delta with trend colour, and a hint |
341
+
342
+ **Data display**
343
+
344
+ | Component | Description |
345
+ | --- | --- |
339
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 |
340
357
 
341
358
  Every component supports `unstyled` and a `class` override; multi-part ones take a `ui` prop for per-slot classes.
342
359
 
@@ -405,9 +422,47 @@ if (await confirm({ title: 'Delete this draft?', danger: true }))
405
422
 
406
423
  `confirm()` resolves `true` on confirmation and `false` on cancel or dismissal.
407
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
+
408
463
  ### Internationalisation
409
464
 
410
- No English string is baked in without an escape hatch. `IAlert`, `IDialog` 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.
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.
411
466
 
412
467
  ### Forms
413
468
 
@@ -483,577 +538,290 @@ chrome; use `ui` to reach the parts (`root`, `input`, `leading`, `trailing`,
483
538
  forwarded to the `<input>` itself. `ref` exposes the element as `.input` for
484
539
  focus management.
485
540
 
486
- #### Multiple series
487
-
488
- Both charts take plain rows plus a `series` descriptor — the same shape
489
- `ITable` uses. Omit `series` for the single-measure case.
541
+ #### Files
490
542
 
491
543
  ```vue
492
- <IBarChart
493
- :data="[
494
- { label: 'Jan', revenue: 4200, expenses: 3100 },
495
- { label: 'Feb', revenue: 5600, expenses: 3400 },
496
- ]"
497
- :series="[
498
- { key: 'revenue', name: 'Revenue', slot: 0 },
499
- { key: 'expenses', name: 'Expenses', slot: 1 },
500
- ]"
501
- label="Cashflow by month"
502
- />
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>
503
561
  ```
504
562
 
505
- Bars group inside their category; lines draw one path each. One hover reports
506
- **every** series for that category in a single tooltip, so the reader compares
507
- in one place instead of chasing marks.
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.
508
567
 
509
- **`slot` pins a series to a palette colour.** Without it, colour follows array
510
- position so filtering a series out repaints the survivors and the reader has
511
- to relearn the chart. Pin the slots whenever series can be toggled.
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 |
512
577
 
513
- **The legend is mandatory from two series up.** `legend: false` only silences
514
- the single-series case, where the title already names what is plotted. Colour
515
- alone is never a dependable identity channel, so this is not configurable.
578
+ Every string is a prop, including `removeLabel` and the three rejection
579
+ messages, so nothing bakes in English.
516
580
 
517
- Past eight series the colours stop identifying anything; the chart warns in
518
- development and you should fold the tail into "Other" or switch to small
519
- multiples.
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.
520
586
 
521
- `variant="area"` is ignored for multiple series overlapping washes muddy into
522
- a colour that belongs to neither.
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.
523
590
 
524
- #### Annotations, and why there is no plugin API
591
+ #### Dates
525
592
 
526
- Chart.js has plugins because canvas is opaque once painted you cannot select
527
- or style anything, so the only way in is an imperative draw hook. SVG has no
528
- such problem, so these charts hand you the layout and let you write ordinary
529
- markup into it:
593
+ The model is an ISO `YYYY-MM-DD` **string**, never a `Date`.
530
594
 
531
595
  ```vue
532
- <ILineChart :data="revenue" label="Revenue against target">
533
- <template #overlay="{ plot, value }">
534
- <line
535
- :x1="plot.left" :y1="value(7000)"
536
- :x2="plot.left + plot.width" :y2="value(7000)"
537
- stroke="var(--iryx-warning)" stroke-width="2" stroke-dasharray="4 4"
538
- />
539
- </template>
540
- </ILineChart>
541
- ```
596
+ <script setup lang="ts">
597
+ import { ref } from 'vue'
542
598
 
543
- `#underlay` renders behind the marks — target bands, shaded regions. `#overlay`
544
- renders in front reference lines, callouts. Both sit below the hit targets,
545
- so hovering keeps working through whatever you draw.
599
+ const issuedOn = ref<string | null>('2026-08-15')
600
+ const period = ref({ start: '2026-08-01', end: '2026-08-31' })
601
+ </script>
546
602
 
547
- Both receive the `CartesianLayout`:
603
+ <template>
604
+ <IDatePicker v-model="issuedOn" clearable />
605
+ <IDateRangePicker v-model="period" clearable />
606
+ </template>
607
+ ```
548
608
 
549
- | Prop | What it gives you |
550
- | --- | --- |
551
- | `plot` | `{ left, top, width, height }` of the plot rectangle, in px |
552
- | `value(n)` | A data value to its pixel on the value axis |
553
- | `bandCentre(i)` | The centre of category `i`, in px |
554
- | `bandWidth` | Size of one category slot |
555
- | `ticks` | The axis values actually drawn |
556
- | `orientation` | `'vertical'` or `'horizontal'` |
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.
557
615
 
558
- That's strictly more capable than a draw hook: it's declarative, reactive, and
559
- type-checked, with no lifecycle or registration order to learn.
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 |
560
625
 
561
- For a chart type that doesn't exist here, the same primitives are exported —
562
- `cartesianLayout`, `linearScale`, `niceTicks`, `seriesColor` so you can build
563
- one on the same spine rather than starting over.
626
+ Navigation and footer labels (`todayLabel`, `clearLabel`, `previousLabel`,
627
+ `nextLabel`) are all props, so nothing bakes in English.
564
628
 
565
- #### Chart colours
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.
566
634
 
567
- Eight categorical slots, `--iryx-chart-1` `--iryx-chart-8`, usable as Tailwind
568
- colours (`text-chart-3`, `fill-chart-5`). They encode **identity** — which
569
- series a mark belongs to — never magnitude.
635
+ The helpers behind them are exported, for formatting the same values elsewhere:
570
636
 
571
- ```vue
572
- <!-- One series per slot, assigned in order. -->
573
- <ISparkline :data="revenue" class="text-chart-1" />
637
+ ```ts
638
+ import { formatIsoDate, isoToday, toCalendarDate, toIsoDate } from 'iryx-ui'
574
639
 
575
- <ISparkline :data="expenses" class="text-chart-2" />
640
+ formatIsoDate('2026-08-15', 'en-GB', { dateStyle: 'long' }) // '15 August 2026'
641
+ formatIsoDate('nonsense') // '' — malformed input is "no selection", not a crash
576
642
  ```
577
643
 
578
- Three rules, and they are not stylistic:
579
-
580
- - **Assign in order, never cycle.** A ninth series is not a generated ninth
581
- hue — fold it into "Other", or switch to small multiples. A generated colour
582
- hasn't been checked for separation against its neighbours.
583
- - **Status colours are never series colours.** A series that happens to land in
584
- slot 4 must not read as a warning. `success`/`warning`/`danger`/`info` stay
585
- reserved.
586
- - **Colour follows the entity, not its rank.** If a filter removes a series,
587
- the survivors keep their slots rather than shifting up.
644
+ #### Passwords
588
645
 
589
- Tailwind scans source text, so a class name assembled at runtime is never
590
- generated write the slots out, or reach for the variable:
646
+ `IPasswordInput` is `IInput` with a reveal toggle in the trailing area, plus an
647
+ optional four-segment strength meter.
591
648
 
592
649
  ```vue
593
- <!-- Silently unstyled: Tailwind never sees this string -->
594
- <ISparkline :class="`text-chart-${index + 1}`" />
650
+ <IPasswordInput v-model="password" strength />
595
651
 
596
- <!-- Either of these works -->
597
- <ISparkline :class="['text-chart-1', 'text-chart-2'][index]" />
652
+ <!-- Toggle only, no meter -->
653
+ <IPasswordInput v-model="password" />
598
654
 
599
- <ISparkline :style="{ color: `var(--iryx-chart-${index + 1})` }" />
655
+ <!-- No toggle either -->
656
+ <IPasswordInput v-model="password" :toggle="false" />
600
657
  ```
601
658
 
602
- The steps are not eyeballed. Each clears a lightness band, a chroma floor, and
603
- protanopia/deuteranopia separation against its own surface, checked with a
604
- validator rather than by eye. **Dark has its own steps**, validated against the
605
- dark background not an automatic flip of the light ones.
606
-
607
- Two caps worth knowing before you design around them:
608
-
609
- | Chart form | Max series |
610
- | --- | --- |
611
- | Bars, lines, stacks — only neighbours touch | **8** |
612
- | Scatter, bubble, small multiples — any two marks can sit side by side | **3** |
613
-
614
- Past those, the answer is fewer series or facets, not more colours. If you
615
- re-step any slot, re-run the validator for **both** modes.
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.
616
663
 
617
- #### Line charts
664
+ Every string is overridable, since components must not bake in English:
618
665
 
619
666
  ```vue
620
- <ILineChart
621
- :data="[
622
- { label: 'Jan', value: 4200 },
623
- { label: 'Feb', value: null },
624
- { label: 'Mar', value: 5600 },
625
- ]"
626
- variant="area"
627
- label="Revenue by month"
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']"
628
673
  />
629
674
  ```
630
675
 
631
- Same `data`, `height`, `ticks`, `axis`, `locale`, `format` and `label` props as
632
- `IBarChart`, plus:
676
+ `class` lands on the wrapper that stacks the field above the meter; `ui` reaches
677
+ `root`, `input`, `toggle`, `meter`, `track`, `segment` and `label`.
633
678
 
634
- | Prop | Effect |
635
- | --- | --- |
636
- | `variant` | `line` (default) or `area`, which adds a wash beneath the line |
637
- | `zero` | Force zero onto the axis. **Off by default** |
679
+ #### Autosizing textareas
638
680
 
639
- **`zero` is off here and always on for bars, deliberately.** A bar is read by
640
- length, so a truncated baseline lies about the comparison. A line is read by
641
- its *shape*, and a series hovering around 8,000 flattens into a straight edge
642
- once the axis starts at nothing. Turn it on when the distance from zero is the
643
- point.
681
+ ```vue
682
+ <!-- Grows without limit -->
683
+ <ITextarea v-model="note" autosize />
644
684
 
645
- `null` breaks the line rather than bridging it, so a missing reading never
646
- draws a slope that didn't happen.
685
+ <!-- Between 2 and 8 rows, then scrolls -->
686
+ <ITextarea v-model="note" :autosize="{ min: 2, max: 8 }" />
687
+ ```
647
688
 
648
- Hovering shows a crosshair and a single ringed marker on the reading under the
649
- cursor not a dot on every point, which is noise the axis and tooltip already
650
- cover.
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.
651
692
 
652
- #### Bar charts
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:
653
697
 
654
698
  ```vue
655
- <IBarChart
656
- :data="[
657
- { label: 'Jan', value: 4200 },
658
- { label: 'Feb', value: 5600 },
659
- { label: 'Mar', value: null },
699
+ <ISelect
700
+ v-model="framework"
701
+ :items="[
702
+ { label: 'Virtual DOM', items: ['Vue', 'React'] },
703
+ { label: 'Compiled', items: [{ label: 'Svelte', value: 'svelte' }] },
660
704
  ]"
661
- label="Revenue by month"
662
- locale="de-DE"
663
- :format="{ style: 'currency', currency: 'EUR', maximumFractionDigits: 0 }"
664
705
  />
665
706
  ```
666
707
 
667
- | Prop | Effect |
668
- | --- | --- |
669
- | `data` | `{ label, value }[]`. `value: null` is a **missing reading** — no bar, which is not a zero |
670
- | `height` | Rendered height in px (default 240). Width fills the container |
671
- | `ticks` | Target tick count. A hint — the axis lands on round numbers first |
672
- | `axis` | Set `false` to drop the value axis and gridlines |
673
- | `locale` / `format` | `Intl.NumberFormat` settings, applied to ticks and tooltip alike |
674
- | `label` | Accessible name for the figure |
675
-
676
- **The axis picks the domain, not the data.** Values are snapped outwards to a
677
- 1/2/5 step, so an axis reads `0 / 2,000 / 4,000` rather than `0 / 1,726.8`.
678
- Zero is always included, because bars are compared by length and a truncated
679
- baseline makes that comparison a lie.
680
-
681
- Bars are capped at 24px and never fill their slot — the gap between them is
682
- what separates them. They're rounded at the data end and square at the
683
- baseline, so the rounding reads as the tip of the value.
684
-
685
- Hovering a bar dims the rest and shows a tooltip; hit targets span the full
686
- band and plot height, so a short bar is no harder to hit than a tall one.
687
-
688
- **Accessibility:** the SVG is `aria-hidden` and the data is exposed as a
689
- visually-hidden table instead, so a screen reader gets the actual numbers
690
- rather than a blank graphic. That table renders even before the container has
691
- been measured — the data is never gated behind layout.
708
+ ### Searchable selects
692
709
 
693
- **`stacked`** turns grouped series into one bar per category:
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.
694
713
 
695
714
  ```vue
696
- <IBarChart :data="cashflow" :series="series" stacked label="Cashflow" />
715
+ <ICombobox
716
+ v-model="clientId"
717
+ :items="clients"
718
+ placeholder="Search clients"
719
+ empty-text="No clients found."
720
+ />
697
721
  ```
698
722
 
699
- Stacking answers *"what makes up the total"*; grouping answers *"how do these
700
- compare"*. Only the bottom segment shares a baseline, so segments above it are
701
- hard to compare across categories stay with grouped bars when the comparison
702
- matters more than the total.
703
-
704
- The axis is sized against the running totals, only the outermost segment is
705
- rounded, and the tooltip adds a **Total** row (`totalLabel` to rename it).
706
- Negative values stack downward from zero rather than cancelling positives out,
707
- so a mixed stack shows both sides at full length. Stacking is ignored for a
708
- single series, and works horizontally too.
709
-
710
- **`orientation="horizontal"`** runs the categories down the side:
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:
711
726
 
712
727
  ```vue
713
- <IBarChart :data="spend" orientation="horizontal" label="Spend by category" />
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
+ />
714
735
  ```
715
736
 
716
- Vertical charts thin colliding labels to every *n*th, which is fine for `Jan`
717
- / `Feb` and lossy for `Travel and accommodation`. Turn the chart and the names
718
- get real width, so nothing is dropped — that's the case horizontal is for.
719
- Everything else behaves the same: grouped series, the tooltip, the round-number
720
- axis anchored at zero.
721
-
722
- #### Sparklines
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.
723
740
 
724
- A trend at a glance, for a stat tile or a table cell. Plain SVG no charting
725
- library, no canvas, nothing to install.
741
+ An entry with its own `items` becomes a labelled group. A group disappears on
742
+ its own once nothing inside it matches:
726
743
 
727
744
  ```vue
728
- <ISparkline
729
- :data="[4200, 4600, 4100, 5200, 5800, 6300]"
730
- variant="area"
731
- end-dot
732
- label="Revenue over six months, trending up"
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
+ ]"
733
751
  />
734
752
  ```
735
753
 
736
- Because it is SVG, colour comes from `currentColor`: recolour it with a text
737
- utility, and it follows your theme preset and light/dark automatically with no
738
- JavaScript. A canvas chart cannot read CSS variables, so it would need a
739
- re-render on every theme change.
740
-
741
- | Prop | Effect |
742
- | --- | --- |
743
- | `data` | Values, oldest first. `null` is a **gap**, not a zero — the line breaks |
744
- | `variant` | `line` (default) or `area`, which adds a wash beneath the line |
745
- | `endDot` | Marks the most recent point |
746
- | `baseline` | Lower edge of the `area` wash: `min` (default) or `zero` |
747
- | `min` / `max` | Pin the domain — set both to put several sparklines on one scale |
748
- | `muted` | Draw in muted ink, for a de-emphasised trend |
749
- | `height` | Rendered height in px (default 32). Width always fills the container |
750
-
751
- Width is fluid and the stroke never distorts: the drawing stretches via
752
- `preserveAspectRatio="none"`, while every stroke carries
753
- `vector-effect="non-scaling-stroke"`, so a 2px line stays 2px and the end dot
754
- stays circular at any aspect ratio.
755
-
756
- `label` sets an accessible description. **Without one the sparkline is hidden
757
- from assistive tech as decorative** — which is correct when it sits beside a
758
- value that already states the number, and wrong if it is the only thing
759
- carrying the information.
760
-
761
- Edge cases behave: an empty series draws nothing, a flat series draws through
762
- the middle rather than collapsing to an edge, and a single reading is a dot.
754
+ For lists in the thousands, `virtual` renders only the rows on screen:
763
755
 
764
- The scale helpers are exported for building your own marks:
756
+ ```vue
757
+ <ICombobox v-model="sku" virtual :items="fiveThousandItems" :estimate-size="32" />
758
+ ```
765
759
 
766
- ```ts
767
- import { extent, linearScale } from 'iryx-ui'
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.
768
765
 
769
- extent([3, null, 9, 1]) // [1, 9] — gaps ignored
770
- linearScale([0, 10], [100, 0])(10) // 0 — ranges may be inverted for SVG's y-axis
771
- ```
766
+ ### Numbers and money
772
767
 
773
- #### Files
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.
774
773
 
775
774
  ```vue
776
775
  <script setup lang="ts">
777
776
  import { ref } from 'vue'
778
777
 
779
- const logo = ref<File[]>([])
778
+ // A string, and it stays one.
779
+ const amount = ref('1234.56')
780
780
  </script>
781
781
 
782
782
  <template>
783
- <IFileUpload
784
- v-model="logo"
785
- accept="image/*"
786
- :max-size="2 * 1024 * 1024"
787
- label="Drag your logo here"
788
- browse-label="Browse images"
789
- hint="PNG, JPG or SVG up to 2 MB"
790
- @reject="onReject"
791
- />
783
+ <INumberInput v-model="amount" locale="sl" :precision="2" step="0.01" min="0" />
792
784
  </template>
793
785
  ```
794
786
 
795
- The model is **always a `File[]`**, even without `multiple` a `File | File[]`
796
- union would make every caller narrow the type before touching it, and the
797
- single case is just an array holding at most one. Without `multiple`, picking
798
- again replaces rather than appends.
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.
799
790
 
800
- | Prop | Effect |
801
- | --- | --- |
802
- | `multiple` | Accept more than one file |
803
- | `accept` | Native syntax: `image/*`, `.pdf`, `image/png` |
804
- | `maxSize` | Largest accepted size, in bytes |
805
- | `maxFiles` | Cap on how many files may be held at once |
806
- | `label` | Prompt in the zone, above the hint |
807
- | `browseLabel` | Text on the browse button |
808
- | `hint` | Fine print under the prompt — the accepted types belong here |
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"`.
809
794
 
810
- Every string is a prop, including `removeLabel` and the three rejection
811
- messages, so nothing bakes in English.
795
+ The underlying helpers are exported if you need them elsewhere:
812
796
 
813
- `accept` is enforced in the component as well as on the input, because a
814
- dragged-in file bypasses the native filter entirely. Refused files raise
815
- `@reject` with `{ file, reason }` — `'type'`, `'size'` or `'count'` — so you
816
- can word your own message; the built-in text is available through the
817
- `tooLargeText`, `wrongTypeText` and `tooManyText` props.
797
+ ```ts
798
+ import { addDecimals, compareDecimals, roundDecimal } from 'iryx-ui'
818
799
 
819
- Image files get a thumbnail, anything else a placeholder of the same size so
820
- rows stay aligned. The object URLs behind those thumbnails are revoked as soon
821
- as a file leaves the list or the component unmounts.
800
+ addDecimals('0.1', '0.2') // '0.3'
801
+ roundDecimal('1.005', 2) // '1.01'
802
+ compareDecimals('1.10', '1.1') // 0
803
+ ```
822
804
 
823
- #### Dates
805
+ ### Validated forms
824
806
 
825
- The model is an ISO `YYYY-MM-DD` **string**, never a `Date`.
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.
826
808
 
827
809
  ```vue
828
810
  <script setup lang="ts">
829
- import { ref } from 'vue'
811
+ import { reactive } from 'vue'
812
+ import * as z from 'zod'
830
813
 
831
- const issuedOn = ref<string | null>('2026-08-15')
832
- const period = ref({ start: '2026-08-01', end: '2026-08-31' })
833
- </script>
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
+ })
834
818
 
835
- <template>
836
- <IDatePicker v-model="issuedOn" clearable />
837
- <IDateRangePicker v-model="period" clearable />
838
- </template>
839
- ```
819
+ const state = reactive({ email: '', password: '' })
840
820
 
841
- A `Date` is a timestamp, so it always carries a time zone. `new Date('2026-08-15')`
842
- parses as UTC midnight, and a user west of Greenwich formatting it locally sees
843
- the 14th — which silently moves a record into the wrong reporting period. A
844
- calendar date has no zone, so it stays the day you picked. Internally the
845
- components use `@internationalized/date`; that never reaches your model, so
846
- formatting the string with `dayjs` or anything else on the way out is fine.
847
-
848
- | Prop | Effect |
849
- | --- | --- |
850
- | `min` / `max` | Selectable bounds, as ISO strings |
851
- | `locale` | Month names, weekday initials, and the trigger's text |
852
- | `format` | `Intl.DateTimeFormatOptions` for the trigger, e.g. `{ dateStyle: 'full' }` |
853
- | `weekStartsOn` | `0` is Sunday. Defaults to the locale's convention |
854
- | `clearable` | Adds a clear action to the footer |
855
- | `months` | Range picker only — months side by side, default `2` |
856
- | `separator` | Range picker only — text between the two dates |
857
-
858
- Navigation and footer labels (`todayLabel`, `clearLabel`, `previousLabel`,
859
- `nextLabel`) are all props, so nothing bakes in English.
860
-
861
- Both render their calendar at a fixed six weeks, so a short month cannot stretch
862
- its rows to match a taller neighbour and the popover does not resize as you page
863
- through it. The range picker draws only the committed range — the days between
864
- the endpoints take a flat tint while the two ends take the solid fill, so a long
865
- span still shows where it begins and ends.
866
-
867
- The helpers behind them are exported, for formatting the same values elsewhere:
868
-
869
- ```ts
870
- import { formatIsoDate, isoToday, toCalendarDate, toIsoDate } from 'iryx-ui'
871
-
872
- formatIsoDate('2026-08-15', 'en-GB', { dateStyle: 'long' }) // '15 August 2026'
873
- formatIsoDate('nonsense') // '' — malformed input is "no selection", not a crash
874
- ```
875
-
876
- #### Passwords
877
-
878
- `IPasswordInput` is `IInput` with a reveal toggle in the trailing area, plus an
879
- optional four-segment strength meter.
880
-
881
- ```vue
882
- <IPasswordInput v-model="password" strength />
883
-
884
- <!-- Toggle only, no meter -->
885
- <IPasswordInput v-model="password" />
886
-
887
- <!-- No toggle either -->
888
- <IPasswordInput v-model="password" :toggle="false" />
889
- ```
890
-
891
- The score counts length (8 and 12 characters), mixed case, a digit and a
892
- symbol, capped at four. It is a deliberately transparent nudge toward better
893
- passwords, **not** a security control — enforce real policy in the `IForm`
894
- validator, where it can actually reject a value.
895
-
896
- Every string is overridable, since components must not bake in English:
897
-
898
- ```vue
899
- <IPasswordInput
900
- v-model="password"
901
- strength
902
- show-label="Afficher le mot de passe"
903
- hide-label="Masquer le mot de passe"
904
- :strength-labels="['Faible', 'Moyen', 'Bon', 'Fort']"
905
- />
906
- ```
907
-
908
- `class` lands on the wrapper that stacks the field above the meter; `ui` reaches
909
- `root`, `input`, `toggle`, `meter`, `track`, `segment` and `label`.
910
-
911
- #### Autosizing textareas
912
-
913
- ```vue
914
- <!-- Grows without limit -->
915
- <ITextarea v-model="note" autosize />
916
-
917
- <!-- Between 2 and 8 rows, then scrolls -->
918
- <ITextarea v-model="note" :autosize="{ min: 2, max: 8 }" />
919
- ```
920
-
921
- `autosize` overrides `rows` and drops the drag handle, since the measured
922
- height is the point. The field shrinks as well as grows, and re-measures when
923
- the model changes from outside — a reset or a prefill resizes correctly.
924
-
925
- `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.
926
-
927
- `ISelect` also takes groups — an entry with its own `items` becomes a labelled
928
- heading, the same shape `ICombobox` uses:
929
-
930
- ```vue
931
- <ISelect
932
- v-model="framework"
933
- :items="[
934
- { label: 'Virtual DOM', items: ['Vue', 'React'] },
935
- { label: 'Compiled', items: [{ label: 'Svelte', value: 'svelte' }] },
936
- ]"
937
- />
938
- ```
939
-
940
- ### Searchable selects
941
-
942
- `ICombobox` takes the same `items` as `ISelect` and filters them against what
943
- the user types, which is what you want once a list runs to hundreds of entries.
944
- The field shows the selected option's **label** while the model holds its value.
945
-
946
- ```vue
947
- <ICombobox
948
- v-model="clientId"
949
- :items="clients"
950
- placeholder="Search clients"
951
- empty-text="No clients found."
952
- />
953
- ```
954
-
955
- Set `create` to offer a row for whatever the user typed when nothing matches.
956
- Choosing it emits `create` with the query and does **not** change the model —
957
- the option doesn't exist yet, so you add it and select it yourself:
958
-
959
- ```vue
960
- <ICombobox
961
- v-model="clientId"
962
- :items="clients"
963
- create
964
- :create-label="query => `Add ${query}`"
965
- @create="query => clients.push({ label: query, value: addClient(query) })"
966
- />
967
- ```
968
-
969
- Both `empty-text` and `create-label` are props precisely so a non-English app
970
- never inherits an English string; `empty` and `create` slots take over the
971
- markup entirely if you need more than text.
972
-
973
- An entry with its own `items` becomes a labelled group. A group disappears on
974
- its own once nothing inside it matches:
975
-
976
- ```vue
977
- <ICombobox
978
- v-model="clientId"
979
- :items="[
980
- { label: 'Recent', items: ['Acme Industries', 'Bolt Logistics'] },
981
- { label: 'Archived', items: [{ label: 'Cirrus Systems', value: 'cirrus' }] },
982
- ]"
983
- />
984
- ```
985
-
986
- For lists in the thousands, `virtual` renders only the rows on screen:
987
-
988
- ```vue
989
- <ICombobox v-model="sku" virtual :items="fiveThousandItems" :estimate-size="32" />
990
- ```
991
-
992
- `virtual` and grouped items are **mutually exclusive** — the underlying
993
- virtualizer is a flat window with no notion of group headings, so groups are
994
- flattened and their labels dropped (with a warning in dev). `estimate-size` is
995
- the assumed row height in px, used to size the scrollbar before rows are
996
- measured; set it if you have restyled rows to a different height.
997
-
998
- ### Numbers and money
999
-
1000
- `INumberInput` never turns your value into a `number`. The model is a decimal
1001
- **string**, because binary floating point cannot represent decimal money —
1002
- `0.1 + 0.2` is `0.30000000000000004`, and `10.00` becomes `10`. Values are
1003
- added, compared and rounded with `BigInt` internally, so precision survives
1004
- regardless of magnitude.
1005
-
1006
- ```vue
1007
- <script setup lang="ts">
1008
- import { ref } from 'vue'
1009
-
1010
- // A string, and it stays one.
1011
- const amount = ref('1234.56')
1012
- </script>
1013
-
1014
- <template>
1015
- <INumberInput v-model="amount" locale="sl" :precision="2" step="0.01" min="0" />
1016
- </template>
1017
- ```
1018
-
1019
- `locale` affects the **display only** — `sl` shows `1.234,56` while the model
1020
- stays `"1234.56"`. Typing in the locale's own format works too. While the field
1021
- is focused it shows the canonical value so separators can't fight your typing.
1022
-
1023
- `precision` fixes the number of decimal places, rounding half-up, and preserves
1024
- trailing zeros (`"10.00"` stays `"10.00"`). `min`, `max` and `step` are decimal
1025
- strings as well, and stepping is exact: `0.1 + 0.2` gives `"0.3"`.
1026
-
1027
- The underlying helpers are exported if you need them elsewhere:
1028
-
1029
- ```ts
1030
- import { addDecimals, compareDecimals, roundDecimal } from 'iryx-ui'
1031
-
1032
- addDecimals('0.1', '0.2') // '0.3'
1033
- roundDecimal('1.005', 2) // '1.01'
1034
- compareDecimals('1.10', '1.1') // 0
1035
- ```
1036
-
1037
- ### Validated forms
1038
-
1039
- `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.
1040
-
1041
- ```vue
1042
- <script setup lang="ts">
1043
- import { reactive } from 'vue'
1044
- import * as z from 'zod'
1045
-
1046
- const schema = z.object({
1047
- email: z.string().email('That doesn\'t look like an email'),
1048
- password: z.string().min(8, 'Use at least 8 characters'),
1049
- })
1050
-
1051
- const state = reactive({ email: '', password: '' })
1052
-
1053
- function onSubmit(event) {
1054
- console.log(event.data) // only fires when valid
1055
- }
1056
- </script>
821
+ function onSubmit(event) {
822
+ console.log(event.data) // only fires when valid
823
+ }
824
+ </script>
1057
825
 
1058
826
  <template>
1059
827
  <IForm :state="state" :schema="schema" @submit="onSubmit">
@@ -1115,6 +883,20 @@ The control inside a field automatically inherits its `id`, invalid styling and
1115
883
 
1116
884
  Use the `#label` / `#description` slots instead of the props when you need markup (a link, a badge) inside the text.
1117
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
+
1118
900
  ### Alerts and banners
1119
901
 
1120
902
  They look similar and do different jobs, so they're separate components.
@@ -1297,6 +1079,304 @@ const table = useDataTable({
1297
1079
  // table.pageRows, table.toggleSort, table.headerSelection, …
1298
1080
  ```
1299
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
+ ```
1357
+
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.
1361
+
1362
+ Both receive the `CartesianLayout`:
1363
+
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'` |
1372
+
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.
1375
+
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.
1379
+
1300
1380
  ## License
1301
1381
 
1302
1382
  [MIT](https://github.com/therok1/iryx-ui/blob/main/LICENSE)