@blakesteve/roster 4.8.2 → 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
@@ -550,6 +551,340 @@ shared size, so they cannot drift apart. `Button` also has `xs` and `icon`,
550
551
  which neither field has an equivalent for. `Textarea` is not on this scale — it
551
552
  is multi-line, so the answer there is a min-height rather than a height.
552
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
+
553
888
  ### Components that render links
554
889
 
555
890
  `Breadcrumbs` renders a plain `<a>` by default, which is right for a static
@@ -606,12 +941,15 @@ function App() {
606
941
  | `Button` | Primary interactive element: solid, soft, outline, ghost, link variants |
607
942
  | `Card` | Bordered surface container |
608
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 |
609
946
  | `Disclosure` | Show/hide toggle using HeadlessUI |
610
947
  | `Eyebrow` | Small tracked-out uppercase label above a heading or beside a rule; polymorphic via `as` |
611
948
  | `InlineCode` | Inline `<code>` for identifiers in running prose |
612
949
  | `Input` | Text input with label, error state, icon slots, and a size scale matching `Button` |
613
950
  | `LabeledDivider` | Horizontal rule carrying a label, with an optional trailing count |
614
951
  | `Link` | Styled anchor with variant support |
952
+ | `MultiSelect` | A `Select` that holds more than one value: dismissible chips, same panel |
615
953
  | `PasswordInput` | Password field with a show/hide reveal toggle |
616
954
  | `Pill` | Inline phrase chrome: social proof, live state, applied filters |
617
955
  | `AvatarStrip` | Stacked avatar row with overflow chip, dismiss button, trailing slot, and label area |
@@ -633,12 +971,14 @@ function App() {
633
971
  | `Accordion` | Collapsible content sections (single or multi-expand) |
634
972
  | `Alert` | Inline notice strip with optional title and dismiss |
635
973
  | `Breadcrumbs` | Navigation trail; pass `linkComponent` to keep navigation client-side |
974
+ | `CheckboxGroup` | Checkbox set reporting one `string[]`; groups, columns, scroll, error |
636
975
  | `CallToAction` | Prominent hero-style CTA block |
637
976
  | `DescriptionList` | Label and value pairs as a real `<dl>`: inline, stacked, or split |
638
977
  | `EmptyState` | Zero-data placeholder with icon and action slot |
639
978
  | `ErrorState` | Error display with retry action |
640
979
  | `MatchupCard` | Head-to-head comparison card |
641
980
  | `Pullquote` | A line lifted out of prose, as `<figure>` + `<blockquote>` + `<figcaption>` |
981
+ | `Toast` | Transient floating notice; `Toaster` + the imperative `toast` handle |
642
982
 
643
983
  ### Organisms
644
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 };
@@ -0,0 +1,75 @@
1
+ import { default as React } from 'react';
2
+ import { VariantProps } from 'class-variance-authority';
3
+ import { selectTriggerVariants } from '../Select/select-variants';
4
+ import { SelectOption } from '../Select/Select';
5
+ import { Chip } from '../Chip/Chip';
6
+ export type MultiSelectValue = string | number;
7
+ export interface MultiSelectProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange" | "defaultValue">, VariantProps<typeof selectTriggerVariants> {
8
+ /** Same option shape as `Select`. */
9
+ options: SelectOption[];
10
+ value: MultiSelectValue[];
11
+ onChange: (value: MultiSelectValue[]) => void;
12
+ placeholder?: string;
13
+ disabled?: boolean;
14
+ className?: string;
15
+ label?: string;
16
+ /** Classes for the trigger button itself. Mirrors `Select`. */
17
+ triggerClassName?: string;
18
+ /** Classes for the popup panel. Mirrors `Select`. */
19
+ optionsClassName?: string;
20
+ /** Supporting text under the trigger. */
21
+ helperText?: string;
22
+ /**
23
+ * The message for an invalid selection. Implies `error`, so a caller cannot
24
+ * set the red ring and forget to say why. Mirrors `Select`.
25
+ */
26
+ errorMessage?: string;
27
+ /**
28
+ * How the selection reads in the closed trigger.
29
+ *
30
+ * `chips` names every selection, which is the point of the component when
31
+ * the list is short. `count` is the escape hatch for a field that would
32
+ * otherwise grow to five rows on a narrow screen — `maxChips` handles the
33
+ * middle ground.
34
+ */
35
+ display?: "chips" | "count";
36
+ /**
37
+ * Chips to show before the rest collapse into a `+N` chip. `undefined` shows
38
+ * all of them.
39
+ */
40
+ maxChips?: number;
41
+ /** Adds a control that clears the whole selection. */
42
+ clearable?: boolean;
43
+ /** Color scheme for the selection chips. */
44
+ chipColorScheme?: React.ComponentProps<typeof Chip>["colorScheme"];
45
+ /**
46
+ * Fill for the selection chips. `outline` by default, and deliberately.
47
+ *
48
+ * The trigger's own default variant paints `--roster-control-bg`, which is
49
+ * `transparent` — so a chip sits on whatever the page is, not on a surface
50
+ * this component controls. A `soft neutral` chip is `gray-100`, which on the
51
+ * library's own `gray-50` page is a 1.04:1 rectangle: the chips rendered,
52
+ * measured correct, and could not be seen. `outline` is the one fill whose
53
+ * identity is a border rather than a background.
54
+ *
55
+ * Chip's own outline palette is not enough on its own, though, and that is
56
+ * why the chips below take `text-inherit` and `border-current`. Chip holds
57
+ * its border to 3:1 against a PAGE; this component can be told to paint its
58
+ * own surface, and `variant="slate"` paints `gray-700` — byte-identical to
59
+ * the neutral chip's own `gray-700` label. That rendered chip-shaped holes
60
+ * with no readable text at 1.00:1. Inheriting means the chip cannot disagree
61
+ * with the surface it was just placed on.
62
+ */
63
+ chipVariant?: React.ComponentProps<typeof Chip>["variant"];
64
+ /** Accessible name for the clear control. */
65
+ clearLabel?: string;
66
+ /**
67
+ * Builds the accessible name for a chip's dismiss control.
68
+ *
69
+ * A row of buttons all named "Remove" is a row a screen reader user cannot
70
+ * tell apart, so this takes the label rather than being a constant.
71
+ */
72
+ removeLabel?: (label: string) => string;
73
+ }
74
+ declare const MultiSelect: ({ options, value, onChange, placeholder, disabled, variant, size, error, className, triggerClassName, optionsClassName, helperText, errorMessage, label, display, maxChips, clearable, chipColorScheme, chipVariant, clearLabel, removeLabel, ...props }: MultiSelectProps) => import("react/jsx-runtime").JSX.Element;
75
+ export { MultiSelect };