@blakesteve/roster 4.8.1 → 4.9.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.
package/README.md CHANGED
@@ -186,7 +186,8 @@ they wrap your content and that text is not Roster's to restyle. `Eyebrow`,
186
186
 
187
187
  `solid` is the one variant where a component picks both the background and the
188
188
  text on it, so it can fail contrast on its own with no help from your app. Every
189
- solid fill in `Badge`, `Pill` and `Button` is measured against WCAG AA (4.5:1)
189
+ solid fill in `Badge`, `Pill`, `Chip`, `Toast` and `Button` is measured against WCAG AA
190
+ (4.5:1)
190
191
  by `src/contrast.test.ts`, at rest **and** on hover, in both themes.
191
192
 
192
193
  `Checkbox` is measured too, at 3:1 rather than 4.5:1 — its tick is a graphical
@@ -260,6 +261,9 @@ Note also that a solid fill's contrast against the surface _behind_ it is a
260
261
  separate requirement (WCAG 1.4.11, 3:1) and is not covered here. Solid amber on
261
262
  a white page is 1.67:1, so it has effectively no visible edge.
262
263
 
264
+ Form controls **are** covered for that requirement, separately: see
265
+ `--roster-control-border` under [Control surface colors](#control-surface-colors).
266
+
263
267
  ### Focus
264
268
 
265
269
  Every focusable control draws the same ring: `--roster-ring` for the ring
@@ -268,6 +272,24 @@ control's own fill. Both flip with the theme — `primary-500` on white in light
268
272
  `primary-400` on `gray-950` in dark — which puts the indicator at 6.37:1 and
269
273
  7.31:1 against the surface either side of it, where WCAG 1.4.11 asks for 3:1.
270
274
 
275
+ Both tokens follow the **page**, which is right until a component deliberately
276
+ inverts against it. `Dialog`'s `slate` and `primary` are dark whatever the page
277
+ is doing, so on a light page they were handing their contents the light-mode
278
+ ring: `primary-500` on `gray-700` is **1.61:1**, and the white offset behind it
279
+ was 10.27:1, making the gap the most visible part of the indicator. Those two
280
+ variants now set both tokens on their own subtree, so a field inside them gets
281
+ 3.80:1 and 4.04:1 instead. `white` and `glass` are left alone because they
282
+ follow the page like everything else.
283
+
284
+ If you build your own inverted surface, do the same on it:
285
+
286
+ ```css
287
+ .my-dark-panel {
288
+ --roster-ring: var(--roster-primary-400);
289
+ --roster-ring-offset: /* that panel's own background */;
290
+ }
291
+ ```
292
+
271
293
  Retint it for your own accent:
272
294
 
273
295
  ```css
@@ -365,40 +387,55 @@ pseudo-elements for any element that sets either of them, so on anything modern
365
387
 
366
388
  ### Control surface colors
367
389
 
368
- `Input`, `PasswordInput` and `Select` read these variables in their `outline`
369
- variant, so a consumer can repaint a form control without restating the class
370
- list.
390
+ `Input`, `PasswordInput`, `Textarea` and `Select` read these variables, so a
391
+ consumer can repaint a form control without restating the class list. Both
392
+ `outline` and `soft` read the border: `soft` used to be borderless and lean on
393
+ its fill alone, which fails the moment the fill matches the surface — a `soft`
394
+ field inside a `white` Dialog in dark mode was exactly 1.00:1 and invisible
395
+ until focus.
371
396
 
372
397
  ```css
373
398
  :root {
374
- --roster-control-border: #d6d3d1;
399
+ --roster-control-border: #65635f; /* gray-500 */
375
400
  --roster-control-bg: transparent;
376
401
  --roster-control-text: #1c1917;
377
402
  --roster-control-border-focus: #0f6498;
378
403
  }
379
404
  .dark {
380
- --roster-control-border: #44403c;
405
+ --roster-control-border: #a8a29e; /* gray-400 */
381
406
  --roster-control-bg: transparent;
382
407
  --roster-control-text: #f5f5f4;
383
- --roster-control-border-focus: #0f6498;
408
+ --roster-control-border-focus: #5ea3de; /* primary-400 */
384
409
  }
385
410
  ```
386
411
 
412
+ `--roster-control-border` is the one token here whose two scopes hold different
413
+ values, and that is deliberate. WCAG 1.4.11 asks 3:1 to identify a control, and
414
+ a border has to move opposite its surface to get there: gray-500 reaches 5.99
415
+ on white but only 2.53 on a gray-800 Dialog, while gray-400 reaches 6.01 there
416
+ and 2.52 on white. No single step of the ramp clears 3:1 both ways. The old
417
+ gray-300 / gray-700 pair was 1.49 and 1.48 — a hairline rather than a boundary.
418
+
387
419
  Set them in both scopes, for the same reason the scrollbar thumb needs both:
388
420
  Roster's own `.dark` rule has equal specificity and comes later.
389
421
 
390
- The defaults are the values the component used to hardcode, so installing this
391
- version changes nothing on its own. Placeholder color, the error state, and the
422
+ Three of the four still default to what the components used to hardcode.
423
+ `--roster-control-border` does not — the 1.4.11 pass moved it two ramp steps —
424
+ so installing this version **does** change how your fields look, deliberately. Placeholder color, the error state, and the
392
425
  focus ring are deliberately not on tokens — the ring is already
393
426
  `--roster-ring`, and error styling should stay recognizably an error.
394
427
 
395
428
  Not every control uses every token: `Select` takes the first three, because
396
- focus on its trigger is the shared `--roster-ring` rather than a border color.
429
+ focus on its trigger is the shared `--roster-ring` rather than a border color,
430
+ and it draws the border as a ring rather than a border.
397
431
  One family rather than one per component is deliberate — these controls sit in
398
432
  the same row of the same form and are drawn to look identical, so being able to
399
433
  repaint one and not the other would be a bug, not a choice.
400
434
 
401
- The `outline` variant is the only one that reads them, in every component, and
435
+ `outline` reads all four. `soft` reads the border only, because a boundary is
436
+ the one thing a filled field cannot supply for itself on an arbitrary surface;
437
+ `white`, `slate` and `ghost` stay opinionated. `Checkbox` reads the border too,
438
+ for its unchecked box. Historically `outline` was the only reader, and
402
439
  it carries no hover fill. The other variants each name a concrete surface
403
440
  (`white`, `soft`, `slate`, `ghost`) and stay opinionated: a token that meant
404
441
  something different in each would not be a token.
@@ -514,6 +551,340 @@ shared size, so they cannot drift apart. `Button` also has `xs` and `icon`,
514
551
  which neither field has an equivalent for. `Textarea` is not on this scale — it
515
552
  is multi-line, so the answer there is a min-height rather than a height.
516
553
 
554
+ ### Toasts
555
+
556
+ Mount `Toaster` once near the root, then call `toast` from anywhere:
557
+
558
+ ```tsx
559
+ import { Toaster, toast } from "@blakesteve/roster";
560
+
561
+ <Toaster position="bottom-right" />;
562
+
563
+ toast.success("Saved");
564
+ toast.error("That name is taken");
565
+ toast.info("Week 4 matchups are up");
566
+ toast.warning("Picks close in ten minutes");
567
+ ```
568
+
569
+ It wraps [`react-hot-toast`](https://react-hot-toast.com), which is a **peer
570
+ dependency** — install it alongside Roster. It has to be external rather than
571
+ bundled: its queue lives in module scope, so a bundled copy would give you two
572
+ stores, and toasts fired from your own `import { toast } from "react-hot-toast"`
573
+ would silently never appear.
574
+
575
+ An app already using it keeps its call sites and only changes the import. What changes is
576
+ the styling: the toast body is a real `Toast` component reading `--roster-*`
577
+ rather than the inline hex each app was pinning.
578
+
579
+ Two of those four helpers are Roster's own. `react-hot-toast` ships `success`,
580
+ `error`, `loading` and `blank` — there is no `info` and no `warning`, so an app
581
+ that wants one improvises, and at least one improvised by calling `toast.error`
582
+ for `info`.
583
+
584
+ **Errors are announced assertively, everything else politely.** That is also
585
+ Roster's decision rather than the library's: `react-hot-toast` marks every
586
+ toast `status` / `polite`, and a polite live region is read when the reader
587
+ next pauses — which for a message that disappears in four seconds can mean
588
+ never.
589
+
590
+ `variant` on `Toaster` sets the fill for the whole queue:
591
+
592
+ - **`soft`** (default) — the tinted fill `Alert` uses. Quiet enough that a stack
593
+ does not shout, which is the common case.
594
+ - **`solid`** — the `-500` fill and ink token, step for step with `Pill` and
595
+ `Chip`. The one to reach for when toasts land over photography, video, or any
596
+ surface the app does not own: `bg-success-50` is a whisper on a white page
597
+ and illegible over an image.
598
+ - **`glass`** — a neutral translucent surface at 60% with a backdrop blur,
599
+ matching `Dialog`'s variant of the same name. The tone lives on the **border
600
+ only**; the surface and the text are neutral for every scheme.
601
+
602
+ Glass is neutral for a reason, and the border-only tone is the cost. Anything
603
+ whose contrast depends on the backdrop is a guess: a colored fill measured
604
+ 2.60:1, and colored text on a neutral fill measured 2.81:1 — worse. Only the
605
+ neutral ink is safe in both directions, at 17.49 over white, 6.14 over black
606
+ and 9.22 over a brand color — 60% is as far as that allows, with 4.69 the worst
607
+ case in dark. If the tone needs to be unmistakable, use `solid`.
608
+
609
+ `src/contrast.test.ts` measures the `solid` fills and deliberately does not
610
+ measure `glass` — a number there would be a number for one background.
611
+
612
+ ### Anchored popups share their internals
613
+
614
+ `Select`, `Combobox` and `MultiSelect` open the same panel, and deliberately
615
+ so: a combobox that opened a different-looking menu than a select on the same
616
+ form is the bug worth preventing. The panel classes, the option rows and the dark-mode carry
617
+ live in `src/internal/`, which is **not exported** — its contract is
618
+ with those components, not with you. The escape hatch you reach for is
619
+ `optionsClassName` on the component itself.
620
+
621
+ Two things in there are worth knowing about even from outside, because both
622
+ look like oversights until you know why:
623
+
624
+ - **The panel sets no `max-height` and no `overflow`.** Headless UI's `size`
625
+ middleware writes both inline whenever `anchor` is set, so a utility would
626
+ lose to it anyway. To cap a menu shorter than the viewport allows, set the
627
+ variable that inline rule reads:
628
+ `optionsClassName="rst:[--anchor-max-height:20rem]"`.
629
+ - **The panel is portaled to `<body>`**, so a `.dark` scoped to part of your
630
+ page does not reach it. All three components copy the nearest `.dark` onto
631
+ the panel to fix that. Custom properties are not carried the same way — set
632
+ `--roster-popover-*` at `:root`.
633
+ - **`variant="slate"` opens on the dark palette**, in light mode too. That
634
+ variant paints a dark trigger, and its panel was rendering white: a dark
635
+ control opening a white sheet, which is the mismatch `--roster-popover-*`
636
+ exists to prevent. The token family only ever covered the variant that reads
637
+ tokens, and the ones that name a surface were left behind. It reuses the dark
638
+ palette rather than introducing a slate one, because every option state there
639
+ already exists and has already been measured.
640
+
641
+ ### Two ways to select more than one thing
642
+
643
+ `MultiSelect` and `CheckboxGroup` both hold a set. They are not a fallback for
644
+ each other:
645
+
646
+ - **`MultiSelect`** when the field is one row of a form and the options do not
647
+ need to be read at a glance. It costs a click to see the list, which is what
648
+ makes it the right control for a dialog. Note that the default trigger still
649
+ grows as chips wrap — `maxChips` or `display="count"` is what actually pins
650
+ it to one row.
651
+ - **`CheckboxGroup`** when scanning every option matters more than saving the
652
+ space, or when the options arrive in labeled categories. Nothing is hidden
653
+ and nothing needs a click to be read.
654
+
655
+ Density is the axis, not correctness. The same eight sports are a good
656
+ `MultiSelect` in a cramped creation dialog and a good `CheckboxGroup` on a
657
+ settings page.
658
+
659
+ Swapping one for the other is not free, though, and it is worth saying which
660
+ parts move. `label`, `helperText` and `errorMessage` are the same three names
661
+ in both. The value types are not: `CheckboxGroup` is `string[]`, `MultiSelect`
662
+ is `(string | number)[]`, so state typed for one does not assign to the other.
663
+ Neither do the options — `CheckboxGroupOption` carries a `description` that
664
+ `SelectOption` has no field for, and `CheckboxGroup` additionally accepts
665
+ `{ category, options }` groups that `MultiSelect` cannot take at all. Headless
666
+ UI's `Listbox` has no grouping primitive, so option groups in the dropdown
667
+ would be Roster's to build.
668
+
669
+ #### MultiSelect
670
+
671
+ The same Headless UI `Listbox` as `Select`, with `multiple` set, opening the
672
+ same panel from `src/internal/popup.ts`. The panel stays open across picks;
673
+ clicking a selected option again removes it.
674
+
675
+ ```tsx
676
+ <MultiSelect
677
+ label="Sports"
678
+ options={SPORTS}
679
+ value={sports}
680
+ onChange={setSports}
681
+ maxChips={3}
682
+ clearable
683
+ />
684
+ ```
685
+
686
+ Selections render as chips in the trigger, and each chip is individually
687
+ dismissible without opening the menu.
688
+
689
+ Getting that required a specific structure, and it is the interesting part of
690
+ the component. A dismiss control is a `<button>` and `ListboxButton` is a
691
+ button, so the chips cannot be its children — that pair is invalid HTML, and
692
+ browsers do not rescue it either: React builds the DOM through the DOM API
693
+ rather than the parser, which leaves it nested, so it would ship as a focusable
694
+ control whose every click also opens the listbox. So the trigger is a **shell**
695
+ with the `ListboxButton` stretched `inset-0` across it, and the chips are the
696
+ button's siblings drawn on top.
697
+
698
+ That keeps the button the full-width element, which matters more than it
699
+ sounds: Headless UI anchors the panel to the button and sizes it from
700
+ `--button-width`, and a trigger that stops being full width is how `Combobox`
701
+ once shipped a 20px unreadable menu. Measured after the change, the panel still
702
+ matches the field exactly.
703
+
704
+ Everything decorative in the shell is `pointer-events-none`, so a click on a
705
+ chip's label falls through and opens the menu like the rest of the field. Only
706
+ the dismiss controls take their events back. Each is named after its own chip —
707
+ a row of buttons all called "Remove" is a row a screen reader user cannot tell
708
+ apart — and `removeLabel` takes a function if "Remove NFL" is not the phrasing
709
+ you want. The chips are hidden from the trigger's accessible name, which
710
+ carries a punctuated summary instead: they are `inline-flex` spans, and name
711
+ computation joins them with no whitespace, so "NCAA Football" followed by "NBA"
712
+ announced as "FootballNBA".
713
+
714
+ The chips default to `outline` rather than a soft fill. The default trigger
715
+ paints `--roster-control-bg`, which is `transparent`, so a chip sits on
716
+ whatever the page is: a `soft neutral` chip is `gray-100`, which on this
717
+ library's own `gray-50` page is a 1.04:1 rectangle. It rendered, it measured
718
+ correct, and it could not be seen.
719
+
720
+ `outline`'s identity is a border rather than a fill, but that alone was not
721
+ enough either. Chip holds its border to 3:1 against a *page*, and this
722
+ component can be told to paint its own surface — `variant="slate"` paints
723
+ `gray-700`, byte-identical to the neutral chip's own `gray-700` label, which
724
+ rendered chip-shaped holes at 1.00:1. So the chips take `text-inherit` and
725
+ `border-current`, in both schemes, and cannot disagree with the surface they
726
+ were placed on. The dismiss and clear controls inherit the same way, because
727
+ the shell is what carries the trigger's color and they all sit inside it.
728
+ Measured across all five variants in both schemes: chip label 9.42 to 18.11,
729
+ chip border 4.33 to 8.99, glyphs 5.59 to 8.99.
730
+
731
+ Each dismiss control's hit area is grown to 31x28 with an invisible `::after`.
732
+ The glyph's own box is 15x12, and WCAG 2.5.8 asks 24x24 — the spacing exception
733
+ does not rescue it, because the trigger is another target sitting directly
734
+ underneath. That fix landed in `Chip` rather than here, so every dismissible
735
+ chip gets it.
736
+
737
+ `maxChips` collapses the tail into a `+N` chip and `display="count"` replaces
738
+ the chips with `N selected`, so a field with eight selections does not become
739
+ four rows tall. A selected value with no matching option renders as its own id
740
+ rather than vanishing from a field that still reports it.
741
+
742
+ #### CheckboxGroup
743
+
744
+ Three things it adds over the version mega-squad's squad form hand-rolled:
745
+
746
+ - **Each option is a Headless UI `Field`**, so its label is wired to its own
747
+ checkbox and clicking the text toggles it. The pointer cursor sits on the
748
+ label rather than on the row, because the label *is* the hit area: at two
749
+ columns, a short option like `NFL` fills about a quarter of its grid cell,
750
+ and a row-wide affordance would advertise the other three quarters as
751
+ clickable.
752
+ - **Each category is a nested fieldset**, so `Football` is the accessible name
753
+ of the group under it rather than a styled `div` a screen reader walks past.
754
+ The heading is still a `<span>`, not a heading element, so it does not appear
755
+ in a screen reader's heading list — the name is reachable by entering the
756
+ group, which is what the nesting buys.
757
+ - **`errorMessage` reaches the fieldset** as `aria-describedby`, wired by hand
758
+ rather than through Headless UI's `Description`. `Fieldset` collects
759
+ descendant labels but never calls `useDescriptions` and provides no
760
+ description context, so a `Description` dropped inside one throws rather than
761
+ degrading quietly. Per-option `description` text does go through `Field`,
762
+ which wires it properly — the distinction is real: a group description is
763
+ announced on entering the group, an option's on focusing that checkbox.
764
+
765
+ Both `aria-describedby` and `aria-invalid` are set **after** `...props` is
766
+ spread, and the describedby is merged with any the caller passes. Set before,
767
+ a caller's own `aria-describedby` would replace the error association with no
768
+ type error and no warning: a group rendering red text and reporting itself
769
+ valid.
770
+
771
+ `variant="panel"` draws the enclosing box for the long-list case. It
772
+ deliberately does not read `--roster-popover-*`: that family is for surfaces
773
+ that float over the page, and this one is in the flow, so it takes `Card`'s
774
+ `soft` fill. Its border is two ramp steps heavier than that card's, and that
775
+ part is not shared: a card's hairline separates content from the page, while
776
+ this one has to read as the wall a scroll region ends at.
777
+
778
+ `maxHeight` is applied inline rather than as a class, because the useful values
779
+ are arbitrary and a Tailwind class built from a prop is a class that does not
780
+ exist at build time. A capped panel takes a tab stop of its own — Chrome and
781
+ Firefox focus scrollers by themselves now, Safari does not, and a `disabled`
782
+ group has no focusable option to tab to at all, so the region would be
783
+ unscrollable from the keyboard.
784
+
785
+ `errorMessage` repaints less here than it does on `Input` or `Select`, on
786
+ purpose. `panel` gets an error border; `plain` has no boundary to repaint, so
787
+ the message is the whole error state. A checkbox is not the thing that is
788
+ invalid — the selection is — so nothing rings the individual controls.
789
+
790
+ ```tsx
791
+ <CheckboxGroup
792
+ label="Select supported sports"
793
+ helperText="You can always add more later."
794
+ variant="panel"
795
+ columns={2}
796
+ maxHeight={240}
797
+ options={[
798
+ { category: "Football", options: [{ value: "nfl", label: "NFL" }] },
799
+ { category: "Hockey", options: [{ value: "nhl", label: "NHL" }] },
800
+ ]}
801
+ value={sports}
802
+ onChange={setSports}
803
+ />
804
+ ```
805
+
806
+ `onChange` appends and filters rather than rebuilding the array from `options`.
807
+ A rebuild sorts the result into declaration order, which reads tidier and
808
+ silently drops any selected value whose option has not loaded yet.
809
+
810
+ ### Multi-selection is a checklist, not a menu
811
+
812
+ `CheckboxGroup` exists instead of a multi-select `Select`, and the reason is
813
+ that neither multi-selection UI in the portfolio was ever a dropdown.
814
+ mega-squad's squad form picks sports from a grouped, scrollable checklist with
815
+ category headings; bb-memorial's share form picks tags from a flat row of
816
+ toggles. Collapsing either into a menu would have hidden the options behind a
817
+ click to save vertical space neither screen was short of, so the component
818
+ follows the shape that already worked. A multi-select dropdown is still the
819
+ right answer for a list too long to show — when something in the portfolio has
820
+ one, that is when to build it.
821
+
822
+ Three things it adds over the hand-rolled version it replaces:
823
+
824
+ - **Each option is a Headless UI `Field`**, so its label is wired to its own
825
+ checkbox and clicking the text toggles it. The pointer cursor sits on the
826
+ label rather than on the row, because the label *is* the hit area: at two
827
+ columns, a short option like `NFL` fills about a quarter of its grid cell,
828
+ and a row-wide affordance would advertise the other three quarters as
829
+ clickable.
830
+ - **Each category is a nested fieldset**, so `Football` is the accessible name
831
+ of the group under it rather than a styled `div` a screen reader walks past.
832
+ The heading is still a `<span>`, not a heading element, so it does not appear
833
+ in a screen reader's heading list — the name is reachable by entering the
834
+ group, which is what the nesting buys.
835
+ - **`errorMessage` reaches the fieldset** as `aria-describedby`, wired by hand
836
+ rather than through Headless UI's `Description`. `Fieldset` collects
837
+ descendant labels but never calls `useDescriptions` and provides no
838
+ description context, so a `Description` dropped inside one throws rather than
839
+ degrading quietly. Per-option `description` text does go through `Field`,
840
+ which wires it properly — the distinction is real: a group description is
841
+ announced on entering the group, an option's on focusing that checkbox.
842
+
843
+ Both `aria-describedby` and `aria-invalid` are set **after** `...props` is
844
+ spread, and the describedby is merged with any the caller passes. Set before,
845
+ a caller's own `aria-describedby` would replace the error association with no
846
+ type error and no warning: a group rendering red text and reporting itself
847
+ valid.
848
+
849
+ `variant="panel"` draws the enclosing box for the long-list case. It
850
+ deliberately does not read `--roster-popover-*`: that family is for surfaces
851
+ that float over the page, and this one is in the flow, so it takes `Card`'s
852
+ `soft` fill. Its border is two ramp steps heavier than that card's, and that
853
+ part is not shared: a card's hairline separates content from the page, while
854
+ this one has to read as the wall a scroll region ends at.
855
+
856
+ `maxHeight` is applied inline rather than as a class, because the useful values
857
+ are arbitrary and a Tailwind class built from a prop is a class that does not
858
+ exist at build time. A capped panel takes a tab stop of its own — Chrome and
859
+ Firefox focus scrollers by themselves now, Safari does not, and a `disabled`
860
+ group has no focusable option to tab to at all, so the region would be
861
+ unscrollable from the keyboard.
862
+
863
+ `errorMessage` repaints less here than it does on `Input` or `Select`, on
864
+ purpose. `panel` gets an error border; `plain` has no boundary to repaint, so
865
+ the message is the whole error state. A checkbox is not the thing that is
866
+ invalid — the selection is — so nothing rings the individual controls.
867
+
868
+ ```tsx
869
+ <CheckboxGroup
870
+ label="Select supported sports"
871
+ helperText="You can always add more later."
872
+ variant="panel"
873
+ columns={2}
874
+ maxHeight={240}
875
+ options={[
876
+ { category: "Football", options: [{ value: "nfl", label: "NFL" }] },
877
+ { category: "Hockey", options: [{ value: "nhl", label: "NHL" }] },
878
+ ]}
879
+ value={sports}
880
+ onChange={setSports}
881
+ />
882
+ ```
883
+
884
+ `onChange` appends and filters rather than rebuilding the array from `options`.
885
+ A rebuild sorts the result into declaration order, which reads tidier and
886
+ silently drops any selected value whose option has not loaded yet.
887
+
517
888
  ### Components that render links
518
889
 
519
890
  `Breadcrumbs` renders a plain `<a>` by default, which is right for a static
@@ -570,12 +941,15 @@ function App() {
570
941
  | `Button` | Primary interactive element: solid, soft, outline, ghost, link variants |
571
942
  | `Card` | Bordered surface container |
572
943
  | `Checkbox` | Accessible checkbox with label support |
944
+ | `Combobox` | A `Select` you can type into: filters as you type, same panel and size scale |
945
+ | `Chip` | The interactive one of Badge / Pill / Chip: removable, selectable, or both |
573
946
  | `Disclosure` | Show/hide toggle using HeadlessUI |
574
947
  | `Eyebrow` | Small tracked-out uppercase label above a heading or beside a rule; polymorphic via `as` |
575
948
  | `InlineCode` | Inline `<code>` for identifiers in running prose |
576
949
  | `Input` | Text input with label, error state, icon slots, and a size scale matching `Button` |
577
950
  | `LabeledDivider` | Horizontal rule carrying a label, with an optional trailing count |
578
951
  | `Link` | Styled anchor with variant support |
952
+ | `MultiSelect` | A `Select` that holds more than one value: dismissible chips, same panel |
579
953
  | `PasswordInput` | Password field with a show/hide reveal toggle |
580
954
  | `Pill` | Inline phrase chrome: social proof, live state, applied filters |
581
955
  | `AvatarStrip` | Stacked avatar row with overflow chip, dismiss button, trailing slot, and label area |
@@ -597,12 +971,14 @@ function App() {
597
971
  | `Accordion` | Collapsible content sections (single or multi-expand) |
598
972
  | `Alert` | Inline notice strip with optional title and dismiss |
599
973
  | `Breadcrumbs` | Navigation trail; pass `linkComponent` to keep navigation client-side |
974
+ | `CheckboxGroup` | Checkbox set reporting one `string[]`; groups, columns, scroll, error |
600
975
  | `CallToAction` | Prominent hero-style CTA block |
601
976
  | `DescriptionList` | Label and value pairs as a real `<dl>`: inline, stacked, or split |
602
977
  | `EmptyState` | Zero-data placeholder with icon and action slot |
603
978
  | `ErrorState` | Error display with retry action |
604
979
  | `MatchupCard` | Head-to-head comparison card |
605
980
  | `Pullquote` | A line lifted out of prose, as `<figure>` + `<blockquote>` + `<figcaption>` |
981
+ | `Toast` | Transient floating notice; `Toaster` + the imperative `toast` handle |
606
982
 
607
983
  ### Organisms
608
984
 
@@ -4880,8 +4880,8 @@ export {
4880
4880
  ss as $,
4881
4881
  cs as A,
4882
4882
  Rs as B,
4883
- ds as C,
4884
- Df as D,
4883
+ Df as C,
4884
+ ds as D,
4885
4885
  ks as E,
4886
4886
  xf as F,
4887
4887
  He as G,
@@ -4896,22 +4896,22 @@ export {
4896
4896
  Hf as P,
4897
4897
  su as Q,
4898
4898
  Uf as R,
4899
- Yf as S,
4899
+ Jf as S,
4900
4900
  Pf as T,
4901
- Qf as U,
4901
+ Yf as U,
4902
4902
  Ns as V,
4903
- fu as W,
4904
- ru as X,
4903
+ Qf as W,
4904
+ fu as X,
4905
4905
  Ps as Y,
4906
- Zf as Z,
4906
+ ru as Z,
4907
4907
  Bf as _,
4908
4908
  Tf as a,
4909
- Jf as a0,
4910
- qf as a1,
4911
- tu as a2,
4912
- Kf as a3,
4913
- iu as a4,
4914
- _s as a5,
4909
+ Zf as a0,
4910
+ _s as a1,
4911
+ qf as a2,
4912
+ tu as a3,
4913
+ Kf as a4,
4914
+ iu as a5,
4915
4915
  _f as a6,
4916
4916
  If as a7,
4917
4917
  js as a8,
@@ -0,0 +1,57 @@
1
+ import { VariantProps } from 'class-variance-authority';
2
+ import { chipVariants } from './chip-variants';
3
+ import * as React from "react";
4
+ export interface ChipProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, "onSelect">, Omit<VariantProps<typeof chipVariants>, "disabled"> {
5
+ children: React.ReactNode;
6
+ /** Leading icon or avatar. */
7
+ leadingIcon?: React.ReactNode;
8
+ /**
9
+ * Presence makes the chip removable, adding a dismiss control.
10
+ *
11
+ * This is what Multi-select consumes for its selected values, and the reason
12
+ * Chip exists as a component rather than a prop on Badge.
13
+ */
14
+ onRemove?: () => void;
15
+ /**
16
+ * Accessible name for the dismiss control.
17
+ *
18
+ * Defaults to `Remove {children}` when `children` is a string, because eight
19
+ * chips in a row all named "Remove" is a list a screen reader user cannot
20
+ * navigate. Pass this explicitly whenever the label is not plain text.
21
+ */
22
+ removeLabel?: string;
23
+ /** Presence makes the body of the chip a toggle. */
24
+ onSelectedChange?: (selected: boolean) => void;
25
+ /**
26
+ * Pressed state for a selectable chip.
27
+ *
28
+ * Requires `onSelectedChange`. On its own it does nothing at all — the chip
29
+ * stays a plain span with no `aria-pressed` — because a chip that reports a
30
+ * pressed state and cannot be pressed is worse than one that reports
31
+ * nothing. Chip never styles this itself either; color is the consumer's
32
+ * call, as in the `Selectable` story.
33
+ */
34
+ selected?: boolean;
35
+ disabled?: boolean;
36
+ }
37
+ /**
38
+ * A chip: a label that does something. Removable, selectable, or both.
39
+ *
40
+ * The element it renders depends on what it can do, because that is what
41
+ * decides how it is announced and whether it can be reached:
42
+ *
43
+ * - neither -> `<span>`, and you probably wanted `Pill`
44
+ * - select -> the whole chip is a `<button aria-pressed>`
45
+ * - remove -> a `<span>` wrapping the label and a dismiss `<button>`
46
+ * - both -> a `<span>` wrapping TWO sibling buttons
47
+ *
48
+ * The last case is why the body is not simply made clickable when a dismiss
49
+ * button is present: a button inside a button is invalid HTML. Browsers do not
50
+ * rescue it either — measured, the HTML parser splits the pair into siblings,
51
+ * while the DOM API that React uses leaves it nested, so what actually ships is
52
+ * a focusable control whose every click also fires the button around it. Two
53
+ * siblings in a styled wrapper is the only structure that gives both actions a
54
+ * name, a focus ring and a tab stop.
55
+ */
56
+ declare const Chip: React.ForwardRefExoticComponent<ChipProps & React.RefAttributes<HTMLElement>>;
57
+ export { Chip };
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Chip vs Pill vs Badge.
3
+ *
4
+ * Badge is a compact label attached to something else: a count on an avatar, a
5
+ * status on a table row. Pill is standalone inline chrome carrying a short
6
+ * phrase: social proof, live state, a filter that reads as a sentence fragment.
7
+ * Neither does anything when you click it.
8
+ *
9
+ * Chip is the interactive one. It is removable, selectable, or both, and it is
10
+ * the only one of the three that is a control rather than decoration. That is
11
+ * also why it is a component and not a `interactive` prop on Badge: it renders
12
+ * real buttons with real names and real focus rings, and bolting that onto a
13
+ * `<span>` would either lie to assistive tech or turn Badge into two
14
+ * components wearing one name.
15
+ *
16
+ * The visual language deliberately matches Pill — fully rounded, roomy
17
+ * horizontal padding — because a selected filter and a filter that merely
18
+ * reads as one should not look like different species. The difference the
19
+ * viewer should notice is that this one responds.
20
+ */
21
+ export declare const chipVariants: (props?: ({
22
+ colorScheme?: "primary" | "amber" | "success" | "error" | "neutral" | "info" | null | undefined;
23
+ variant?: "soft" | "solid" | "outline" | null | undefined;
24
+ size?: "sm" | "md" | null | undefined;
25
+ disabled?: boolean | null | undefined;
26
+ } & import('class-variance-authority/types').ClassProp) | undefined) => string;
@@ -0,0 +1,52 @@
1
+ import { VariantProps } from 'class-variance-authority';
2
+ import { inputVariants } from '../Input/input-variants';
3
+ import { SelectOption } from '../Select/Select';
4
+ import * as React from "react";
5
+ export interface ComboboxProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange">, VariantProps<typeof inputVariants> {
6
+ options: SelectOption[];
7
+ value: string | number | null;
8
+ /**
9
+ * Receives `null` when the field is cleared.
10
+ *
11
+ * Headless UI reports a cleared input as `null`, and swallowing that made
12
+ * the selection unclearable: emptying the input fired nothing and Escape
13
+ * silently restored the old label.
14
+ */
15
+ onChange: (value: string | number | null) => void;
16
+ placeholder?: string;
17
+ disabled?: boolean;
18
+ label?: string;
19
+ helperText?: string;
20
+ errorMessage?: string;
21
+ /** Classes for the text input itself. `className` lands on the wrapper. */
22
+ inputClassName?: string;
23
+ /** Classes for the popup panel. */
24
+ optionsClassName?: string;
25
+ /** Shown in the panel when the query matches nothing. */
26
+ emptyMessage?: string;
27
+ /**
28
+ * Replaces the match rule.
29
+ *
30
+ * The default is a case-insensitive substring on `label`, which is what
31
+ * every hand-rolled version in the portfolio did. Pass this for fuzzy
32
+ * matching, matching on `value`, or filtering server-side by ignoring the
33
+ * query here entirely.
34
+ */
35
+ filter?: (options: SelectOption[], query: string) => SelectOption[];
36
+ }
37
+ /**
38
+ * A `Select` you can type into.
39
+ *
40
+ * Built on Headless UI's `Combobox` rather than on a Roster primitive, for the
41
+ * same reason `Select` is built on its `Listbox`: that IS the primitive, and
42
+ * it already handles the parts worth not rewriting — active-option tracking,
43
+ * the keyboard model, and the input's `aria-activedescendant` wiring.
44
+ *
45
+ * What Roster adds is the presentation, and it is deliberately the SAME
46
+ * presentation `Select` uses: the panel, its surface tokens, the `.dark` carry
47
+ * across the portal, and the option rows all come from shared internals. A
48
+ * combobox that opened a different-looking menu than a select on the same form
49
+ * would be the bug this component is meant to prevent.
50
+ */
51
+ declare const Combobox: ({ options, value, onChange, placeholder, disabled, variant, size, error, label, helperText, errorMessage, className, inputClassName, optionsClassName, emptyMessage, filter, ...props }: ComboboxProps) => import("react/jsx-runtime").JSX.Element;
52
+ export { Combobox };