@blakesteve/roster 4.8.2 → 4.10.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 +371 -1
- package/dist/{Table--O-sD53L.js → Table-C1JbdA7q.js} +13 -13
- package/dist/components/atoms/Chip/Chip.d.ts +57 -0
- package/dist/components/atoms/Chip/chip-variants.d.ts +26 -0
- package/dist/components/atoms/Combobox/Combobox.d.ts +52 -0
- package/dist/components/atoms/MultiSelect/MultiSelect.d.ts +75 -0
- package/dist/components/molecules/CheckboxGroup/CheckboxGroup.d.ts +58 -0
- package/dist/components/molecules/CheckboxGroup/checkbox-group-variants.d.ts +34 -0
- package/dist/components/molecules/Toast/Toast.d.ts +30 -0
- package/dist/components/molecules/Toast/Toaster.d.ts +33 -0
- package/dist/components/molecules/Toast/toast-api.d.ts +38 -0
- package/dist/components/molecules/Toast/toast-variants.d.ts +22 -0
- package/dist/data-table.es.js +1 -1
- package/dist/index.d.ts +10 -0
- package/dist/internal/PopupOption.d.ts +21 -0
- package/dist/internal/popup.d.ts +103 -0
- package/dist/meta.json +3 -3
- package/dist/roster.cjs.js +5 -2
- package/dist/roster.css +1 -1
- package/dist/roster.es.js +5993 -3976
- package/package.json +6 -4
package/README.md
CHANGED
|
@@ -186,13 +186,44 @@ 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
|
|
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
|
|
193
194
|
object under WCAG 1.4.11, not text. It used to be excluded from the suite
|
|
194
195
|
entirely, which measured it against nothing.
|
|
195
196
|
|
|
197
|
+
`CallToAction` is **not** in that suite, and its light surface changed: the
|
|
198
|
+
fills moved a ramp step and the borders moved four, because the old surface
|
|
199
|
+
measured 1.03:1 against a light page with a 1.30:1 border — a banner you could
|
|
200
|
+
not see. **Installing this version changes how your existing CTAs look**, the
|
|
201
|
+
same way the 1.4.11 pass changed every field in 4.8.2. Its `info` variant also
|
|
202
|
+
moves from Tailwind's stock `blue-*` to Roster's `info-*`, so it now answers to
|
|
203
|
+
`--roster-info-*` like every other scheme.
|
|
204
|
+
|
|
205
|
+
Worth knowing what that border step is and is not. It is a **prominence**
|
|
206
|
+
decision, not a 1.4.11 one: a tinted banner is not a control, so the criterion
|
|
207
|
+
no longer reaches it once the surface has a fill. A call to action is the one
|
|
208
|
+
component whose job is to be looked at first, so it is deliberately the most
|
|
209
|
+
defined bordered block in the library. The filled surfaces it sits beside are
|
|
210
|
+
much fainter — `Card` soft at 1.04:1, `ErrorState` at 1.39:1, `Toast` and
|
|
211
|
+
`Pill` between 1.34:1 and 1.82:1. `Alert` is the closest, and only because its
|
|
212
|
+
4px left stripe is already a `-500`, at 6.10:1 for primary.
|
|
213
|
+
|
|
214
|
+
`CallToAction` and `Countdown` also lay themselves out with **CSS container
|
|
215
|
+
queries** rather than `md:` and `sm:`. The switch is now the CARD's width, not
|
|
216
|
+
the window's — a 228px card on a 1200px page used to lay out as a row and
|
|
217
|
+
squeeze its own text until it overflowed. Three things follow:
|
|
218
|
+
|
|
219
|
+
- **The breakpoint number changed** from a 768px viewport to a 512px card. A
|
|
220
|
+
CTA in a narrow page column will stack where it did not before.
|
|
221
|
+
- **`container-type: inline-size` collapses inside a content-sized ancestor.**
|
|
222
|
+
Give any `width: fit-content`, `inline-block` or table-cell ancestor a
|
|
223
|
+
definite width, or the card measures zero.
|
|
224
|
+
- **Container queries are required**: Chrome 105+, Safari 16+, Firefox 110+.
|
|
225
|
+
Without them a `CallToAction` renders in its narrow form at every width.
|
|
226
|
+
|
|
196
227
|
#### Ink follows the fill
|
|
197
228
|
|
|
198
229
|
The foreground is a token per fill, not per family:
|
|
@@ -550,6 +581,340 @@ shared size, so they cannot drift apart. `Button` also has `xs` and `icon`,
|
|
|
550
581
|
which neither field has an equivalent for. `Textarea` is not on this scale — it
|
|
551
582
|
is multi-line, so the answer there is a min-height rather than a height.
|
|
552
583
|
|
|
584
|
+
### Toasts
|
|
585
|
+
|
|
586
|
+
Mount `Toaster` once near the root, then call `toast` from anywhere:
|
|
587
|
+
|
|
588
|
+
```tsx
|
|
589
|
+
import { Toaster, toast } from "@blakesteve/roster";
|
|
590
|
+
|
|
591
|
+
<Toaster position="bottom-right" />;
|
|
592
|
+
|
|
593
|
+
toast.success("Saved");
|
|
594
|
+
toast.error("That name is taken");
|
|
595
|
+
toast.info("Week 4 matchups are up");
|
|
596
|
+
toast.warning("Picks close in ten minutes");
|
|
597
|
+
```
|
|
598
|
+
|
|
599
|
+
It wraps [`react-hot-toast`](https://react-hot-toast.com), which is a **peer
|
|
600
|
+
dependency** — install it alongside Roster. It has to be external rather than
|
|
601
|
+
bundled: its queue lives in module scope, so a bundled copy would give you two
|
|
602
|
+
stores, and toasts fired from your own `import { toast } from "react-hot-toast"`
|
|
603
|
+
would silently never appear.
|
|
604
|
+
|
|
605
|
+
An app already using it keeps its call sites and only changes the import. What changes is
|
|
606
|
+
the styling: the toast body is a real `Toast` component reading `--roster-*`
|
|
607
|
+
rather than the inline hex each app was pinning.
|
|
608
|
+
|
|
609
|
+
Two of those four helpers are Roster's own. `react-hot-toast` ships `success`,
|
|
610
|
+
`error`, `loading` and `blank` — there is no `info` and no `warning`, so an app
|
|
611
|
+
that wants one improvises, and at least one improvised by calling `toast.error`
|
|
612
|
+
for `info`.
|
|
613
|
+
|
|
614
|
+
**Errors are announced assertively, everything else politely.** That is also
|
|
615
|
+
Roster's decision rather than the library's: `react-hot-toast` marks every
|
|
616
|
+
toast `status` / `polite`, and a polite live region is read when the reader
|
|
617
|
+
next pauses — which for a message that disappears in four seconds can mean
|
|
618
|
+
never.
|
|
619
|
+
|
|
620
|
+
`variant` on `Toaster` sets the fill for the whole queue:
|
|
621
|
+
|
|
622
|
+
- **`soft`** (default) — the tinted fill `Alert` uses. Quiet enough that a stack
|
|
623
|
+
does not shout, which is the common case.
|
|
624
|
+
- **`solid`** — the `-500` fill and ink token, step for step with `Pill` and
|
|
625
|
+
`Chip`. The one to reach for when toasts land over photography, video, or any
|
|
626
|
+
surface the app does not own: `bg-success-50` is a whisper on a white page
|
|
627
|
+
and illegible over an image.
|
|
628
|
+
- **`glass`** — a neutral translucent surface at 60% with a backdrop blur,
|
|
629
|
+
matching `Dialog`'s variant of the same name. The tone lives on the **border
|
|
630
|
+
only**; the surface and the text are neutral for every scheme.
|
|
631
|
+
|
|
632
|
+
Glass is neutral for a reason, and the border-only tone is the cost. Anything
|
|
633
|
+
whose contrast depends on the backdrop is a guess: a colored fill measured
|
|
634
|
+
2.60:1, and colored text on a neutral fill measured 2.81:1 — worse. Only the
|
|
635
|
+
neutral ink is safe in both directions, at 17.49 over white, 6.14 over black
|
|
636
|
+
and 9.22 over a brand color — 60% is as far as that allows, with 4.69 the worst
|
|
637
|
+
case in dark. If the tone needs to be unmistakable, use `solid`.
|
|
638
|
+
|
|
639
|
+
`src/contrast.test.ts` measures the `solid` fills and deliberately does not
|
|
640
|
+
measure `glass` — a number there would be a number for one background.
|
|
641
|
+
|
|
642
|
+
### Anchored popups share their internals
|
|
643
|
+
|
|
644
|
+
`Select`, `Combobox` and `MultiSelect` open the same panel, and deliberately
|
|
645
|
+
so: a combobox that opened a different-looking menu than a select on the same
|
|
646
|
+
form is the bug worth preventing. The panel classes, the option rows and the dark-mode carry
|
|
647
|
+
live in `src/internal/`, which is **not exported** — its contract is
|
|
648
|
+
with those components, not with you. The escape hatch you reach for is
|
|
649
|
+
`optionsClassName` on the component itself.
|
|
650
|
+
|
|
651
|
+
Two things in there are worth knowing about even from outside, because both
|
|
652
|
+
look like oversights until you know why:
|
|
653
|
+
|
|
654
|
+
- **The panel sets no `max-height` and no `overflow`.** Headless UI's `size`
|
|
655
|
+
middleware writes both inline whenever `anchor` is set, so a utility would
|
|
656
|
+
lose to it anyway. To cap a menu shorter than the viewport allows, set the
|
|
657
|
+
variable that inline rule reads:
|
|
658
|
+
`optionsClassName="rst:[--anchor-max-height:20rem]"`.
|
|
659
|
+
- **The panel is portaled to `<body>`**, so a `.dark` scoped to part of your
|
|
660
|
+
page does not reach it. All three components copy the nearest `.dark` onto
|
|
661
|
+
the panel to fix that. Custom properties are not carried the same way — set
|
|
662
|
+
`--roster-popover-*` at `:root`.
|
|
663
|
+
- **`variant="slate"` opens on the dark palette**, in light mode too. That
|
|
664
|
+
variant paints a dark trigger, and its panel was rendering white: a dark
|
|
665
|
+
control opening a white sheet, which is the mismatch `--roster-popover-*`
|
|
666
|
+
exists to prevent. The token family only ever covered the variant that reads
|
|
667
|
+
tokens, and the ones that name a surface were left behind. It reuses the dark
|
|
668
|
+
palette rather than introducing a slate one, because every option state there
|
|
669
|
+
already exists and has already been measured.
|
|
670
|
+
|
|
671
|
+
### Two ways to select more than one thing
|
|
672
|
+
|
|
673
|
+
`MultiSelect` and `CheckboxGroup` both hold a set. They are not a fallback for
|
|
674
|
+
each other:
|
|
675
|
+
|
|
676
|
+
- **`MultiSelect`** when the field is one row of a form and the options do not
|
|
677
|
+
need to be read at a glance. It costs a click to see the list, which is what
|
|
678
|
+
makes it the right control for a dialog. Note that the default trigger still
|
|
679
|
+
grows as chips wrap — `maxChips` or `display="count"` is what actually pins
|
|
680
|
+
it to one row.
|
|
681
|
+
- **`CheckboxGroup`** when scanning every option matters more than saving the
|
|
682
|
+
space, or when the options arrive in labeled categories. Nothing is hidden
|
|
683
|
+
and nothing needs a click to be read.
|
|
684
|
+
|
|
685
|
+
Density is the axis, not correctness. The same eight sports are a good
|
|
686
|
+
`MultiSelect` in a cramped creation dialog and a good `CheckboxGroup` on a
|
|
687
|
+
settings page.
|
|
688
|
+
|
|
689
|
+
Swapping one for the other is not free, though, and it is worth saying which
|
|
690
|
+
parts move. `label`, `helperText` and `errorMessage` are the same three names
|
|
691
|
+
in both. The value types are not: `CheckboxGroup` is `string[]`, `MultiSelect`
|
|
692
|
+
is `(string | number)[]`, so state typed for one does not assign to the other.
|
|
693
|
+
Neither do the options — `CheckboxGroupOption` carries a `description` that
|
|
694
|
+
`SelectOption` has no field for, and `CheckboxGroup` additionally accepts
|
|
695
|
+
`{ category, options }` groups that `MultiSelect` cannot take at all. Headless
|
|
696
|
+
UI's `Listbox` has no grouping primitive, so option groups in the dropdown
|
|
697
|
+
would be Roster's to build.
|
|
698
|
+
|
|
699
|
+
#### MultiSelect
|
|
700
|
+
|
|
701
|
+
The same Headless UI `Listbox` as `Select`, with `multiple` set, opening the
|
|
702
|
+
same panel from `src/internal/popup.ts`. The panel stays open across picks;
|
|
703
|
+
clicking a selected option again removes it.
|
|
704
|
+
|
|
705
|
+
```tsx
|
|
706
|
+
<MultiSelect
|
|
707
|
+
label="Sports"
|
|
708
|
+
options={SPORTS}
|
|
709
|
+
value={sports}
|
|
710
|
+
onChange={setSports}
|
|
711
|
+
maxChips={3}
|
|
712
|
+
clearable
|
|
713
|
+
/>
|
|
714
|
+
```
|
|
715
|
+
|
|
716
|
+
Selections render as chips in the trigger, and each chip is individually
|
|
717
|
+
dismissible without opening the menu.
|
|
718
|
+
|
|
719
|
+
Getting that required a specific structure, and it is the interesting part of
|
|
720
|
+
the component. A dismiss control is a `<button>` and `ListboxButton` is a
|
|
721
|
+
button, so the chips cannot be its children — that pair is invalid HTML, and
|
|
722
|
+
browsers do not rescue it either: React builds the DOM through the DOM API
|
|
723
|
+
rather than the parser, which leaves it nested, so it would ship as a focusable
|
|
724
|
+
control whose every click also opens the listbox. So the trigger is a **shell**
|
|
725
|
+
with the `ListboxButton` stretched `inset-0` across it, and the chips are the
|
|
726
|
+
button's siblings drawn on top.
|
|
727
|
+
|
|
728
|
+
That keeps the button the full-width element, which matters more than it
|
|
729
|
+
sounds: Headless UI anchors the panel to the button and sizes it from
|
|
730
|
+
`--button-width`, and a trigger that stops being full width is how `Combobox`
|
|
731
|
+
once shipped a 20px unreadable menu. Measured after the change, the panel still
|
|
732
|
+
matches the field exactly.
|
|
733
|
+
|
|
734
|
+
Everything decorative in the shell is `pointer-events-none`, so a click on a
|
|
735
|
+
chip's label falls through and opens the menu like the rest of the field. Only
|
|
736
|
+
the dismiss controls take their events back. Each is named after its own chip —
|
|
737
|
+
a row of buttons all called "Remove" is a row a screen reader user cannot tell
|
|
738
|
+
apart — and `removeLabel` takes a function if "Remove NFL" is not the phrasing
|
|
739
|
+
you want. The chips are hidden from the trigger's accessible name, which
|
|
740
|
+
carries a punctuated summary instead: they are `inline-flex` spans, and name
|
|
741
|
+
computation joins them with no whitespace, so "NCAA Football" followed by "NBA"
|
|
742
|
+
announced as "FootballNBA".
|
|
743
|
+
|
|
744
|
+
The chips default to `outline` rather than a soft fill. The default trigger
|
|
745
|
+
paints `--roster-control-bg`, which is `transparent`, so a chip sits on
|
|
746
|
+
whatever the page is: a `soft neutral` chip is `gray-100`, which on this
|
|
747
|
+
library's own `gray-50` page is a 1.04:1 rectangle. It rendered, it measured
|
|
748
|
+
correct, and it could not be seen.
|
|
749
|
+
|
|
750
|
+
`outline`'s identity is a border rather than a fill, but that alone was not
|
|
751
|
+
enough either. Chip holds its border to 3:1 against a *page*, and this
|
|
752
|
+
component can be told to paint its own surface — `variant="slate"` paints
|
|
753
|
+
`gray-700`, byte-identical to the neutral chip's own `gray-700` label, which
|
|
754
|
+
rendered chip-shaped holes at 1.00:1. So the chips take `text-inherit` and
|
|
755
|
+
`border-current`, in both schemes, and cannot disagree with the surface they
|
|
756
|
+
were placed on. The dismiss and clear controls inherit the same way, because
|
|
757
|
+
the shell is what carries the trigger's color and they all sit inside it.
|
|
758
|
+
Measured across all five variants in both schemes: chip label 9.42 to 18.11,
|
|
759
|
+
chip border 4.33 to 8.99, glyphs 5.59 to 8.99.
|
|
760
|
+
|
|
761
|
+
Each dismiss control's hit area is grown to 31x28 with an invisible `::after`.
|
|
762
|
+
The glyph's own box is 15x12, and WCAG 2.5.8 asks 24x24 — the spacing exception
|
|
763
|
+
does not rescue it, because the trigger is another target sitting directly
|
|
764
|
+
underneath. That fix landed in `Chip` rather than here, so every dismissible
|
|
765
|
+
chip gets it.
|
|
766
|
+
|
|
767
|
+
`maxChips` collapses the tail into a `+N` chip and `display="count"` replaces
|
|
768
|
+
the chips with `N selected`, so a field with eight selections does not become
|
|
769
|
+
four rows tall. A selected value with no matching option renders as its own id
|
|
770
|
+
rather than vanishing from a field that still reports it.
|
|
771
|
+
|
|
772
|
+
#### CheckboxGroup
|
|
773
|
+
|
|
774
|
+
Three things it adds over the version mega-squad's squad form hand-rolled:
|
|
775
|
+
|
|
776
|
+
- **Each option is a Headless UI `Field`**, so its label is wired to its own
|
|
777
|
+
checkbox and clicking the text toggles it. The pointer cursor sits on the
|
|
778
|
+
label rather than on the row, because the label *is* the hit area: at two
|
|
779
|
+
columns, a short option like `NFL` fills about a quarter of its grid cell,
|
|
780
|
+
and a row-wide affordance would advertise the other three quarters as
|
|
781
|
+
clickable.
|
|
782
|
+
- **Each category is a nested fieldset**, so `Football` is the accessible name
|
|
783
|
+
of the group under it rather than a styled `div` a screen reader walks past.
|
|
784
|
+
The heading is still a `<span>`, not a heading element, so it does not appear
|
|
785
|
+
in a screen reader's heading list — the name is reachable by entering the
|
|
786
|
+
group, which is what the nesting buys.
|
|
787
|
+
- **`errorMessage` reaches the fieldset** as `aria-describedby`, wired by hand
|
|
788
|
+
rather than through Headless UI's `Description`. `Fieldset` collects
|
|
789
|
+
descendant labels but never calls `useDescriptions` and provides no
|
|
790
|
+
description context, so a `Description` dropped inside one throws rather than
|
|
791
|
+
degrading quietly. Per-option `description` text does go through `Field`,
|
|
792
|
+
which wires it properly — the distinction is real: a group description is
|
|
793
|
+
announced on entering the group, an option's on focusing that checkbox.
|
|
794
|
+
|
|
795
|
+
Both `aria-describedby` and `aria-invalid` are set **after** `...props` is
|
|
796
|
+
spread, and the describedby is merged with any the caller passes. Set before,
|
|
797
|
+
a caller's own `aria-describedby` would replace the error association with no
|
|
798
|
+
type error and no warning: a group rendering red text and reporting itself
|
|
799
|
+
valid.
|
|
800
|
+
|
|
801
|
+
`variant="panel"` draws the enclosing box for the long-list case. It
|
|
802
|
+
deliberately does not read `--roster-popover-*`: that family is for surfaces
|
|
803
|
+
that float over the page, and this one is in the flow, so it takes `Card`'s
|
|
804
|
+
`soft` fill. Its border is two ramp steps heavier than that card's, and that
|
|
805
|
+
part is not shared: a card's hairline separates content from the page, while
|
|
806
|
+
this one has to read as the wall a scroll region ends at.
|
|
807
|
+
|
|
808
|
+
`maxHeight` is applied inline rather than as a class, because the useful values
|
|
809
|
+
are arbitrary and a Tailwind class built from a prop is a class that does not
|
|
810
|
+
exist at build time. A capped panel takes a tab stop of its own — Chrome and
|
|
811
|
+
Firefox focus scrollers by themselves now, Safari does not, and a `disabled`
|
|
812
|
+
group has no focusable option to tab to at all, so the region would be
|
|
813
|
+
unscrollable from the keyboard.
|
|
814
|
+
|
|
815
|
+
`errorMessage` repaints less here than it does on `Input` or `Select`, on
|
|
816
|
+
purpose. `panel` gets an error border; `plain` has no boundary to repaint, so
|
|
817
|
+
the message is the whole error state. A checkbox is not the thing that is
|
|
818
|
+
invalid — the selection is — so nothing rings the individual controls.
|
|
819
|
+
|
|
820
|
+
```tsx
|
|
821
|
+
<CheckboxGroup
|
|
822
|
+
label="Select supported sports"
|
|
823
|
+
helperText="You can always add more later."
|
|
824
|
+
variant="panel"
|
|
825
|
+
columns={2}
|
|
826
|
+
maxHeight={240}
|
|
827
|
+
options={[
|
|
828
|
+
{ category: "Football", options: [{ value: "nfl", label: "NFL" }] },
|
|
829
|
+
{ category: "Hockey", options: [{ value: "nhl", label: "NHL" }] },
|
|
830
|
+
]}
|
|
831
|
+
value={sports}
|
|
832
|
+
onChange={setSports}
|
|
833
|
+
/>
|
|
834
|
+
```
|
|
835
|
+
|
|
836
|
+
`onChange` appends and filters rather than rebuilding the array from `options`.
|
|
837
|
+
A rebuild sorts the result into declaration order, which reads tidier and
|
|
838
|
+
silently drops any selected value whose option has not loaded yet.
|
|
839
|
+
|
|
840
|
+
### Multi-selection is a checklist, not a menu
|
|
841
|
+
|
|
842
|
+
`CheckboxGroup` exists instead of a multi-select `Select`, and the reason is
|
|
843
|
+
that neither multi-selection UI in the portfolio was ever a dropdown.
|
|
844
|
+
mega-squad's squad form picks sports from a grouped, scrollable checklist with
|
|
845
|
+
category headings; bb-memorial's share form picks tags from a flat row of
|
|
846
|
+
toggles. Collapsing either into a menu would have hidden the options behind a
|
|
847
|
+
click to save vertical space neither screen was short of, so the component
|
|
848
|
+
follows the shape that already worked. A multi-select dropdown is still the
|
|
849
|
+
right answer for a list too long to show — when something in the portfolio has
|
|
850
|
+
one, that is when to build it.
|
|
851
|
+
|
|
852
|
+
Three things it adds over the hand-rolled version it replaces:
|
|
853
|
+
|
|
854
|
+
- **Each option is a Headless UI `Field`**, so its label is wired to its own
|
|
855
|
+
checkbox and clicking the text toggles it. The pointer cursor sits on the
|
|
856
|
+
label rather than on the row, because the label *is* the hit area: at two
|
|
857
|
+
columns, a short option like `NFL` fills about a quarter of its grid cell,
|
|
858
|
+
and a row-wide affordance would advertise the other three quarters as
|
|
859
|
+
clickable.
|
|
860
|
+
- **Each category is a nested fieldset**, so `Football` is the accessible name
|
|
861
|
+
of the group under it rather than a styled `div` a screen reader walks past.
|
|
862
|
+
The heading is still a `<span>`, not a heading element, so it does not appear
|
|
863
|
+
in a screen reader's heading list — the name is reachable by entering the
|
|
864
|
+
group, which is what the nesting buys.
|
|
865
|
+
- **`errorMessage` reaches the fieldset** as `aria-describedby`, wired by hand
|
|
866
|
+
rather than through Headless UI's `Description`. `Fieldset` collects
|
|
867
|
+
descendant labels but never calls `useDescriptions` and provides no
|
|
868
|
+
description context, so a `Description` dropped inside one throws rather than
|
|
869
|
+
degrading quietly. Per-option `description` text does go through `Field`,
|
|
870
|
+
which wires it properly — the distinction is real: a group description is
|
|
871
|
+
announced on entering the group, an option's on focusing that checkbox.
|
|
872
|
+
|
|
873
|
+
Both `aria-describedby` and `aria-invalid` are set **after** `...props` is
|
|
874
|
+
spread, and the describedby is merged with any the caller passes. Set before,
|
|
875
|
+
a caller's own `aria-describedby` would replace the error association with no
|
|
876
|
+
type error and no warning: a group rendering red text and reporting itself
|
|
877
|
+
valid.
|
|
878
|
+
|
|
879
|
+
`variant="panel"` draws the enclosing box for the long-list case. It
|
|
880
|
+
deliberately does not read `--roster-popover-*`: that family is for surfaces
|
|
881
|
+
that float over the page, and this one is in the flow, so it takes `Card`'s
|
|
882
|
+
`soft` fill. Its border is two ramp steps heavier than that card's, and that
|
|
883
|
+
part is not shared: a card's hairline separates content from the page, while
|
|
884
|
+
this one has to read as the wall a scroll region ends at.
|
|
885
|
+
|
|
886
|
+
`maxHeight` is applied inline rather than as a class, because the useful values
|
|
887
|
+
are arbitrary and a Tailwind class built from a prop is a class that does not
|
|
888
|
+
exist at build time. A capped panel takes a tab stop of its own — Chrome and
|
|
889
|
+
Firefox focus scrollers by themselves now, Safari does not, and a `disabled`
|
|
890
|
+
group has no focusable option to tab to at all, so the region would be
|
|
891
|
+
unscrollable from the keyboard.
|
|
892
|
+
|
|
893
|
+
`errorMessage` repaints less here than it does on `Input` or `Select`, on
|
|
894
|
+
purpose. `panel` gets an error border; `plain` has no boundary to repaint, so
|
|
895
|
+
the message is the whole error state. A checkbox is not the thing that is
|
|
896
|
+
invalid — the selection is — so nothing rings the individual controls.
|
|
897
|
+
|
|
898
|
+
```tsx
|
|
899
|
+
<CheckboxGroup
|
|
900
|
+
label="Select supported sports"
|
|
901
|
+
helperText="You can always add more later."
|
|
902
|
+
variant="panel"
|
|
903
|
+
columns={2}
|
|
904
|
+
maxHeight={240}
|
|
905
|
+
options={[
|
|
906
|
+
{ category: "Football", options: [{ value: "nfl", label: "NFL" }] },
|
|
907
|
+
{ category: "Hockey", options: [{ value: "nhl", label: "NHL" }] },
|
|
908
|
+
]}
|
|
909
|
+
value={sports}
|
|
910
|
+
onChange={setSports}
|
|
911
|
+
/>
|
|
912
|
+
```
|
|
913
|
+
|
|
914
|
+
`onChange` appends and filters rather than rebuilding the array from `options`.
|
|
915
|
+
A rebuild sorts the result into declaration order, which reads tidier and
|
|
916
|
+
silently drops any selected value whose option has not loaded yet.
|
|
917
|
+
|
|
553
918
|
### Components that render links
|
|
554
919
|
|
|
555
920
|
`Breadcrumbs` renders a plain `<a>` by default, which is right for a static
|
|
@@ -606,12 +971,15 @@ function App() {
|
|
|
606
971
|
| `Button` | Primary interactive element: solid, soft, outline, ghost, link variants |
|
|
607
972
|
| `Card` | Bordered surface container |
|
|
608
973
|
| `Checkbox` | Accessible checkbox with label support |
|
|
974
|
+
| `Combobox` | A `Select` you can type into: filters as you type, same panel and size scale |
|
|
975
|
+
| `Chip` | The interactive one of Badge / Pill / Chip: removable, selectable, or both |
|
|
609
976
|
| `Disclosure` | Show/hide toggle using HeadlessUI |
|
|
610
977
|
| `Eyebrow` | Small tracked-out uppercase label above a heading or beside a rule; polymorphic via `as` |
|
|
611
978
|
| `InlineCode` | Inline `<code>` for identifiers in running prose |
|
|
612
979
|
| `Input` | Text input with label, error state, icon slots, and a size scale matching `Button` |
|
|
613
980
|
| `LabeledDivider` | Horizontal rule carrying a label, with an optional trailing count |
|
|
614
981
|
| `Link` | Styled anchor with variant support |
|
|
982
|
+
| `MultiSelect` | A `Select` that holds more than one value: dismissible chips, same panel |
|
|
615
983
|
| `PasswordInput` | Password field with a show/hide reveal toggle |
|
|
616
984
|
| `Pill` | Inline phrase chrome: social proof, live state, applied filters |
|
|
617
985
|
| `AvatarStrip` | Stacked avatar row with overflow chip, dismiss button, trailing slot, and label area |
|
|
@@ -633,12 +1001,14 @@ function App() {
|
|
|
633
1001
|
| `Accordion` | Collapsible content sections (single or multi-expand) |
|
|
634
1002
|
| `Alert` | Inline notice strip with optional title and dismiss |
|
|
635
1003
|
| `Breadcrumbs` | Navigation trail; pass `linkComponent` to keep navigation client-side |
|
|
1004
|
+
| `CheckboxGroup` | Checkbox set reporting one `string[]`; groups, columns, scroll, error |
|
|
636
1005
|
| `CallToAction` | Prominent hero-style CTA block |
|
|
637
1006
|
| `DescriptionList` | Label and value pairs as a real `<dl>`: inline, stacked, or split |
|
|
638
1007
|
| `EmptyState` | Zero-data placeholder with icon and action slot |
|
|
639
1008
|
| `ErrorState` | Error display with retry action |
|
|
640
1009
|
| `MatchupCard` | Head-to-head comparison card |
|
|
641
1010
|
| `Pullquote` | A line lifted out of prose, as `<figure>` + `<blockquote>` + `<figcaption>` |
|
|
1011
|
+
| `Toast` | Transient floating notice; `Toaster` + the imperative `toast` handle |
|
|
642
1012
|
|
|
643
1013
|
### Organisms
|
|
644
1014
|
|
|
@@ -4880,8 +4880,8 @@ export {
|
|
|
4880
4880
|
ss as $,
|
|
4881
4881
|
cs as A,
|
|
4882
4882
|
Rs as B,
|
|
4883
|
-
|
|
4884
|
-
|
|
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
|
-
|
|
4899
|
+
Jf as S,
|
|
4900
4900
|
Pf as T,
|
|
4901
|
-
|
|
4901
|
+
Yf as U,
|
|
4902
4902
|
Ns as V,
|
|
4903
|
-
|
|
4904
|
-
|
|
4903
|
+
Qf as W,
|
|
4904
|
+
fu as X,
|
|
4905
4905
|
Ps as Y,
|
|
4906
|
-
|
|
4906
|
+
ru as Z,
|
|
4907
4907
|
Bf as _,
|
|
4908
4908
|
Tf as a,
|
|
4909
|
-
|
|
4910
|
-
|
|
4911
|
-
|
|
4912
|
-
|
|
4913
|
-
|
|
4914
|
-
|
|
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 };
|