@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 +387 -11
- 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 +6007 -3976
- package/package.json +6 -4
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
|
|
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
|
|
369
|
-
|
|
370
|
-
|
|
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: #
|
|
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: #
|
|
405
|
+
--roster-control-border: #a8a29e; /* gray-400 */
|
|
381
406
|
--roster-control-bg: transparent;
|
|
382
407
|
--roster-control-text: #f5f5f4;
|
|
383
|
-
--roster-control-border-focus: #
|
|
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
|
-
|
|
391
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|