@eifi1/ui-kit 0.5.0 → 0.6.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 +50 -1
- package/dist/components/amount-input.d.ts +7 -0
- package/dist/components/autocomplete.d.ts +101 -0
- package/dist/components/autocomplete.js +260 -0
- package/dist/components/autocomplete.js.map +1 -0
- package/dist/components/calculator.d.ts +7 -0
- package/dist/components/chip.d.ts +22 -7
- package/dist/components/chip.js +14 -1
- package/dist/components/chip.js.map +1 -1
- package/dist/components/choice-card.d.ts +100 -0
- package/dist/components/choice-card.js +170 -0
- package/dist/components/choice-card.js.map +1 -0
- package/dist/components/combobox-core.d.ts +76 -6
- package/dist/components/combobox-core.js +119 -49
- package/dist/components/combobox-core.js.map +1 -1
- package/dist/components/combobox.d.ts +12 -2
- package/dist/components/combobox.js +42 -17
- package/dist/components/combobox.js.map +1 -1
- package/dist/components/currency-select.js +10 -2
- package/dist/components/currency-select.js.map +1 -1
- package/dist/components/danger-confirm.d.ts +91 -0
- package/dist/components/danger-confirm.js +181 -0
- package/dist/components/danger-confirm.js.map +1 -0
- package/dist/components/date-picker.js +19 -7
- package/dist/components/date-picker.js.map +1 -1
- package/dist/components/dialog-frame.d.ts +84 -0
- package/dist/components/dialog-frame.js +86 -0
- package/dist/components/dialog-frame.js.map +1 -0
- package/dist/components/disclosure.d.ts +108 -0
- package/dist/components/disclosure.js +127 -0
- package/dist/components/disclosure.js.map +1 -0
- package/dist/components/entity-combobox.d.ts +17 -3
- package/dist/components/entity-combobox.js +25 -5
- package/dist/components/entity-combobox.js.map +1 -1
- package/dist/components/file-button.d.ts +161 -0
- package/dist/components/file-button.js +225 -0
- package/dist/components/file-button.js.map +1 -0
- package/dist/components/file-dropzone.d.ts +72 -23
- package/dist/components/file-dropzone.js +219 -94
- package/dist/components/file-dropzone.js.map +1 -1
- package/dist/components/icon-picker.d.ts +72 -0
- package/dist/components/icon-picker.js +104 -0
- package/dist/components/icon-picker.js.map +1 -0
- package/dist/components/mini-calendar.d.ts +3 -0
- package/dist/components/mini-calendar.js +4 -3
- package/dist/components/mini-calendar.js.map +1 -1
- package/dist/components/modal.d.ts +8 -1
- package/dist/components/modal.js +4 -2
- package/dist/components/modal.js.map +1 -1
- package/dist/components/month-picker.js +16 -5
- package/dist/components/month-picker.js.map +1 -1
- package/dist/components/multi-entity-combobox.d.ts +16 -3
- package/dist/components/multi-entity-combobox.js +25 -5
- package/dist/components/multi-entity-combobox.js.map +1 -1
- package/dist/components/number-field.d.ts +41 -1
- package/dist/components/number-field.js +42 -10
- package/dist/components/number-field.js.map +1 -1
- package/dist/components/number-input.d.ts +35 -2
- package/dist/components/number-input.js +35 -4
- package/dist/components/number-input.js.map +1 -1
- package/dist/components/numpad-sheet.d.ts +7 -0
- package/dist/components/popover.js +4 -1
- package/dist/components/popover.js.map +1 -1
- package/dist/components/search-field.d.ts +16 -0
- package/dist/components/search-field.js +29 -7
- package/dist/components/search-field.js.map +1 -1
- package/dist/components/signature-pad.d.ts +43 -1
- package/dist/components/signature-pad.js +74 -2
- package/dist/components/signature-pad.js.map +1 -1
- package/dist/components/swatch-picker.d.ts +69 -0
- package/dist/components/swatch-picker.js +75 -0
- package/dist/components/swatch-picker.js.map +1 -0
- package/dist/components/tile-radio.d.ts +50 -0
- package/dist/components/tile-radio.js +140 -0
- package/dist/components/tile-radio.js.map +1 -0
- package/dist/components/toggle-group.d.ts +27 -5
- package/dist/components/toggle-group.js +22 -15
- package/dist/components/toggle-group.js.map +1 -1
- package/dist/components/trigger-aria.d.ts +18 -0
- package/dist/components/trigger-aria.js +25 -0
- package/dist/components/trigger-aria.js.map +1 -0
- package/dist/components/ui.d.ts +164 -18
- package/dist/components/ui.js +207 -34
- package/dist/components/ui.js.map +1 -1
- package/dist/i18n/defaults.d.ts +7 -0
- package/dist/i18n/defaults.js +13 -2
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +38 -5
- package/dist/i18n/kit-labels.js +12 -4
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/index.d.ts +16 -7
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/table-text.d.ts +127 -0
- package/dist/lib/table-text.js +82 -0
- package/dist/lib/table-text.js.map +1 -0
- package/dist/rhf/form.d.ts +79 -0
- package/dist/rhf/form.js +143 -0
- package/dist/rhf/form.js.map +1 -0
- package/dist/rhf.d.ts +4 -0
- package/dist/rhf.js +3 -0
- package/dist/rhf.js.map +1 -0
- package/dist/shell/app-shell.js +3 -1
- package/dist/shell/app-shell.js.map +1 -1
- package/dist/table-text.d.ts +1 -0
- package/dist/table-text.js +3 -0
- package/dist/table-text.js.map +1 -0
- package/package.json +14 -1
- package/src/components/autocomplete.tsx +425 -0
- package/src/components/chip.tsx +43 -7
- package/src/components/choice-card.tsx +305 -0
- package/src/components/combobox-core.tsx +228 -58
- package/src/components/combobox.tsx +58 -21
- package/src/components/currency-select.tsx +16 -2
- package/src/components/danger-confirm.tsx +286 -0
- package/src/components/date-picker.tsx +31 -6
- package/src/components/dialog-frame.tsx +179 -0
- package/src/components/disclosure.tsx +259 -0
- package/src/components/entity-combobox.tsx +41 -6
- package/src/components/file-button.tsx +431 -0
- package/src/components/file-dropzone.tsx +323 -117
- package/src/components/icon-picker.tsx +181 -0
- package/src/components/mini-calendar.tsx +7 -3
- package/src/components/modal.tsx +10 -2
- package/src/components/month-picker.tsx +22 -4
- package/src/components/multi-entity-combobox.tsx +40 -6
- package/src/components/number-field.tsx +86 -10
- package/src/components/number-input.tsx +79 -2
- package/src/components/popover.tsx +4 -1
- package/src/components/search-field.tsx +49 -6
- package/src/components/signature-pad.tsx +112 -0
- package/src/components/swatch-picker.tsx +141 -0
- package/src/components/tile-radio.tsx +228 -0
- package/src/components/toggle-group.tsx +54 -18
- package/src/components/trigger-aria.ts +42 -0
- package/src/components/ui.tsx +427 -40
- package/src/i18n/defaults.ts +12 -1
- package/src/i18n/kit-labels.tsx +45 -5
- package/src/index.ts +19 -0
- package/src/lib/table-text.ts +265 -0
- package/src/rhf/form.tsx +300 -0
- package/src/rhf.ts +9 -0
- package/src/shell/app-shell.tsx +3 -1
- package/src/table-text.ts +8 -0
package/src/components/ui.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { forwardRef, useId, useState } from "react";
|
|
2
|
-
import { ChevronDown, Eye, EyeOff, HelpCircle } from "lucide-react";
|
|
1
|
+
import { forwardRef, useId, useLayoutEffect, useRef, useState } from "react";
|
|
2
|
+
import { ChevronDown, Eye, EyeOff, HelpCircle, Plus, X } from "lucide-react";
|
|
3
3
|
import type { ButtonHTMLAttributes, ComponentPropsWithoutRef, InputHTMLAttributes, KeyboardEvent, MouseEvent, ReactNode, SelectHTMLAttributes, TextareaHTMLAttributes } from "react";
|
|
4
4
|
import { cn } from "../lib/cn";
|
|
5
5
|
import { useMediaQuery } from "../hooks/use-media-query";
|
|
@@ -71,33 +71,101 @@ export function Button({ variant = "primary", stretch, className, ...rest }: But
|
|
|
71
71
|
// Canonical square icon-only button. <Button> carries TEXT geometry — px-3 py-2
|
|
72
72
|
// gap-2 — so rendering a bare glyph through it gives a small icon in a wide,
|
|
73
73
|
// text-shaped box. This fixes a square box instead and, crucially, forces the
|
|
74
|
-
// child icon to 20px via `[&_svg]:size
|
|
75
|
-
// it wherever an action is a bare icon
|
|
76
|
-
// top-bar icon buttons and can never
|
|
74
|
+
// child icon to a size set by the BOX (20px at md/sm) via `[&_svg]:size-*` so a
|
|
75
|
+
// caller cannot under-size it. Use it wherever an action is a bare icon
|
|
76
|
+
// (edit/delete/tools) so they all match the top-bar icon buttons and can never
|
|
77
|
+
// drift apart again.
|
|
77
78
|
//
|
|
78
79
|
// Draws its colours from the same `buttonVariantClasses` map as <Button>, so the
|
|
79
80
|
// two re-skin together with the palette.
|
|
80
81
|
const ICON_BUTTON_BASE =
|
|
81
|
-
"inline-flex items-center justify-center rounded-md transition-colors focus:outline-none focus:ring-2 disabled:opacity-50 disabled:cursor-not-allowed
|
|
82
|
+
"inline-flex items-center justify-center rounded-md transition-colors focus:outline-none focus:ring-2 disabled:opacity-50 disabled:cursor-not-allowed";
|
|
83
|
+
|
|
84
|
+
export type IconButtonSize = "md" | "sm" | "xs" | "2xs";
|
|
85
|
+
|
|
86
|
+
// Box and glyph together, so a 24px chip action cannot end up holding a 20px icon
|
|
87
|
+
// that touches its edges. The two small steps are lenkbank's list-row (28px) and
|
|
88
|
+
// chip (24px) actions, which it had hand-rolled beside the kit's 32/36px ones.
|
|
89
|
+
const ICON_BUTTON_SIZES: Record<IconButtonSize, string> = {
|
|
90
|
+
md: "size-9 [&_svg]:size-5",
|
|
91
|
+
sm: "size-8 [&_svg]:size-5",
|
|
92
|
+
xs: "size-7 rounded [&_svg]:size-4",
|
|
93
|
+
"2xs": "size-6 rounded [&_svg]:size-3.5",
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// A tone re-colours the glyph without changing what the variant draws around it.
|
|
97
|
+
// `muted` and `danger` both sit quiet at rest — an action in every row of a list
|
|
98
|
+
// must not shout from every row — and `danger` answers the pointer in the
|
|
99
|
+
// destructive family, so the red arrives only on the one row you are about to
|
|
100
|
+
// act on.
|
|
101
|
+
const ICON_BUTTON_TONES = {
|
|
102
|
+
default: "",
|
|
103
|
+
muted:
|
|
104
|
+
"text-[var(--text-placeholder)] hover:bg-[var(--bg-surface-2)] hover:text-[var(--text-primary)]",
|
|
105
|
+
danger:
|
|
106
|
+
"text-[var(--text-placeholder)] hover:bg-[var(--danger-bg)] hover:text-[var(--danger)] focus:ring-[var(--danger-border)]",
|
|
107
|
+
} as const;
|
|
82
108
|
|
|
83
109
|
export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
84
110
|
variant?: ButtonVariant;
|
|
85
|
-
/** Box size: md = 36px (matches the top bar), sm = 32px
|
|
86
|
-
|
|
111
|
+
/** Box size: md = 36px (matches the top bar), sm = 32px — both with a 20px icon;
|
|
112
|
+
* xs = 28px with a 16px icon (an action in a list row), 2xs = 24px with a 14px
|
|
113
|
+
* icon (an action on a chip or a tab). */
|
|
114
|
+
size?: IconButtonSize;
|
|
115
|
+
/** Glyph colour over the variant. `muted`: placeholder grey, full text colour on
|
|
116
|
+
* hover. `danger`: the same grey at rest, `--danger` on hover and focus — for a
|
|
117
|
+
* remove/delete that repeats down a list. Default: the variant's own colours. */
|
|
118
|
+
tone?: keyof typeof ICON_BUTTON_TONES;
|
|
119
|
+
/** Keep the click (and the Enter/Space that produces it) from reaching an
|
|
120
|
+
* ancestor's handler — for an action inside a clickable table row or card.
|
|
121
|
+
*
|
|
122
|
+
* **Not a licence to put this inside another `<button>`.** HTML forbids any
|
|
123
|
+
* interactive content, and any element with a `tabindex`, inside a button — so
|
|
124
|
+
* the `<span role="button" tabIndex={0}>` workaround is invalid too — and ARIA
|
|
125
|
+
* makes a button's children presentational, so a screen reader flattens the
|
|
126
|
+
* inner one into the outer one's name and it cannot be reached at all. Make the
|
|
127
|
+
* row's main action a button that fills the row and put this one BESIDE it, on
|
|
128
|
+
* top:
|
|
129
|
+
*
|
|
130
|
+
* ```tsx
|
|
131
|
+
* <div className="relative">
|
|
132
|
+
* <button className="w-full pe-16 …" onClick={open}>…row…</button>
|
|
133
|
+
* <div className="absolute inset-y-0 end-2 flex items-center gap-0.5">
|
|
134
|
+
* <IconButton size="xs" tone="danger" aria-label="Delete" onClick={remove}>
|
|
135
|
+
* <Trash2 />
|
|
136
|
+
* </IconButton>
|
|
137
|
+
* </div>
|
|
138
|
+
* </div>
|
|
139
|
+
* ```
|
|
140
|
+
*
|
|
141
|
+
* The pair look exactly like the nested version, click exactly like it, and
|
|
142
|
+
* are two tab stops a screen reader can tell apart. */
|
|
143
|
+
stopPropagation?: boolean;
|
|
87
144
|
}
|
|
88
145
|
|
|
89
146
|
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(function IconButton(
|
|
90
|
-
{ variant = "ghost", size = "md", className, ...rest },
|
|
147
|
+
{ variant = "ghost", size = "md", tone = "default", stopPropagation, className, onClick, onKeyDown, ...rest },
|
|
91
148
|
ref,
|
|
92
149
|
) {
|
|
93
150
|
return (
|
|
94
151
|
<button
|
|
95
152
|
ref={ref}
|
|
96
153
|
{...rest}
|
|
154
|
+
onClick={(e) => {
|
|
155
|
+
if (stopPropagation) e.stopPropagation();
|
|
156
|
+
onClick?.(e);
|
|
157
|
+
}}
|
|
158
|
+
onKeyDown={(e) => {
|
|
159
|
+
// A button turns Enter/Space into a click of its own, but the KEY still
|
|
160
|
+
// bubbles — and a clickable row listening for Enter would act on it too.
|
|
161
|
+
if (stopPropagation && (e.key === "Enter" || e.key === " ")) e.stopPropagation();
|
|
162
|
+
onKeyDown?.(e);
|
|
163
|
+
}}
|
|
97
164
|
className={cn(
|
|
98
165
|
ICON_BUTTON_BASE,
|
|
99
|
-
size
|
|
166
|
+
ICON_BUTTON_SIZES[size],
|
|
100
167
|
buttonVariantClasses[variant],
|
|
168
|
+
ICON_BUTTON_TONES[tone],
|
|
101
169
|
className,
|
|
102
170
|
)}
|
|
103
171
|
/>
|
|
@@ -451,6 +519,54 @@ export function FieldLabel({ children, className, ...rest }: FieldLabelProps) {
|
|
|
451
519
|
);
|
|
452
520
|
}
|
|
453
521
|
|
|
522
|
+
export interface LabelProps extends ComponentPropsWithoutRef<"label"> {
|
|
523
|
+
/** Draw the required mark after the text. The mark is `aria-hidden`: what a
|
|
524
|
+
* screen reader hears is the control's own `required` / `aria-required`, which
|
|
525
|
+
* the caller still sets — a "star" read out as the last word of every name is
|
|
526
|
+
* noise, and a control that is not marked required is not required however its
|
|
527
|
+
* label looks. */
|
|
528
|
+
required?: boolean;
|
|
529
|
+
/** `sm` for a toolbar or a dense filter row (12px), `md` otherwise (14px). */
|
|
530
|
+
size?: "sm" | "md";
|
|
531
|
+
/** Dim the label with its control. A label ABOVE its field is not the field's
|
|
532
|
+
* `peer`, so `peer-disabled:` cannot reach it the way it reaches a floating one. */
|
|
533
|
+
disabled?: boolean;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* The label ABOVE a field, for the places a floating label does not fit: a control
|
|
538
|
+
* the kit did not render (a third-party picker, a range slider, a group of radios),
|
|
539
|
+
* a filter bar whose fields are unlabelled {@link Select}s, a form that sets labels
|
|
540
|
+
* above its fields throughout.
|
|
541
|
+
*
|
|
542
|
+
* A new component rather than a mode of {@link FieldLabel}, because the two share
|
|
543
|
+
* nothing but the word. `FieldLabel` is an absolutely positioned `<span>` inside a
|
|
544
|
+
* trigger's `relative` box and has to stay exactly that for every dropdown that
|
|
545
|
+
* already lines up with it; this is a real `<label>` in normal flow, with `htmlFor`,
|
|
546
|
+
* that a click focuses the control through. Spacing below it belongs to the caller's
|
|
547
|
+
* layout (`space-y-1.5` on the pair, typically), as it would for any block.
|
|
548
|
+
*/
|
|
549
|
+
export function Label({ required, size = "md", disabled, className, children, ...rest }: LabelProps) {
|
|
550
|
+
return (
|
|
551
|
+
<label
|
|
552
|
+
{...rest}
|
|
553
|
+
className={cn(
|
|
554
|
+
"inline-flex items-center gap-0.5 font-medium leading-none text-[var(--text-primary)]",
|
|
555
|
+
size === "sm" ? "text-xs" : "text-sm",
|
|
556
|
+
disabled && "cursor-default opacity-50",
|
|
557
|
+
className,
|
|
558
|
+
)}
|
|
559
|
+
>
|
|
560
|
+
{children}
|
|
561
|
+
{required && (
|
|
562
|
+
<span aria-hidden className="text-[var(--danger)]">
|
|
563
|
+
*
|
|
564
|
+
</span>
|
|
565
|
+
)}
|
|
566
|
+
</label>
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
|
|
454
570
|
/**
|
|
455
571
|
* The message under a field that is wrong, and the wiring that attaches it.
|
|
456
572
|
*
|
|
@@ -482,6 +598,10 @@ function useFieldError(
|
|
|
482
598
|
error: ReactNode,
|
|
483
599
|
invalid: boolean | undefined,
|
|
484
600
|
describedBy: string | undefined,
|
|
601
|
+
/** The caller's own `aria-invalid`. A form library sets THIS (see `@eifi1/ui-kit/rhf`
|
|
602
|
+
* `FormControl`), not our `invalid` prop — and a field that announces invalid should
|
|
603
|
+
* look it too, as NumberField and the pickers already did. */
|
|
604
|
+
ariaInvalid?: unknown,
|
|
485
605
|
) {
|
|
486
606
|
const errorId = useId();
|
|
487
607
|
// `null`, `false` and `""` are what a caller's `touched && errors.iban` evaluates to
|
|
@@ -490,7 +610,7 @@ function useFieldError(
|
|
|
490
610
|
const hasError = error !== undefined && error !== null && error !== false && error !== "";
|
|
491
611
|
return {
|
|
492
612
|
/** A field carrying a message that says what is wrong with it IS wrong. */
|
|
493
|
-
isInvalid: Boolean(invalid) || hasError,
|
|
613
|
+
isInvalid: Boolean(invalid) || hasError || ariaInvalid === true || ariaInvalid === "true",
|
|
494
614
|
describedBy: hasError ? (describedBy ? `${describedBy} ${errorId}` : errorId) : describedBy,
|
|
495
615
|
errorEl: hasError ? (
|
|
496
616
|
<p id={errorId} className={FIELD_ERROR_CLASS}>
|
|
@@ -610,6 +730,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
|
|
610
730
|
error,
|
|
611
731
|
invalid,
|
|
612
732
|
rest["aria-describedby"],
|
|
733
|
+
rest["aria-invalid"],
|
|
613
734
|
);
|
|
614
735
|
const asDisplay = useMediaQuery(PHONE_QUERY, false) && variant === "display";
|
|
615
736
|
// Password fields get a reveal toggle so users can check what they typed.
|
|
@@ -722,7 +843,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
|
|
722
843
|
});
|
|
723
844
|
Input.displayName = "Input";
|
|
724
845
|
|
|
725
|
-
export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
|
846
|
+
export interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement>, "size"> {
|
|
726
847
|
label?: ReactNode;
|
|
727
848
|
/** See {@link Input}'s `invalid`. */
|
|
728
849
|
invalid?: boolean;
|
|
@@ -730,25 +851,50 @@ export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
|
|
730
851
|
error?: ReactNode;
|
|
731
852
|
/** A {@link FieldHint} for the label line — see {@link FloatingField}. */
|
|
732
853
|
hint?: ReactNode;
|
|
854
|
+
/**
|
|
855
|
+
* `"sm"`: a 28px, 12px-type select for a toolbar or a table header, where the
|
|
856
|
+
* 36px field stands a head taller than the buttons beside it. `"md"` (default)
|
|
857
|
+
* is the field every form uses. Unlabelled selects only — a floating label needs
|
|
858
|
+
* the tall box to float in, so a labelled Select ignores `"sm"`.
|
|
859
|
+
*
|
|
860
|
+
* A NUMBER is still the native attribute (the rows of a list box) and is passed
|
|
861
|
+
* straight through, so the one HTML meaning of `size` keeps working.
|
|
862
|
+
*/
|
|
863
|
+
size?: "sm" | "md" | number;
|
|
864
|
+
/** Classes for the `<select>` itself. `className` styles the WRAPPER — the box
|
|
865
|
+
* the chevron is positioned against — so it could set a width and nothing
|
|
866
|
+
* else; this is the way to the element. See {@link Input}'s `inputClassName`. */
|
|
867
|
+
selectClassName?: string;
|
|
733
868
|
}
|
|
734
869
|
|
|
870
|
+
// The compact select: the field's colours, a toolbar button's height. `py-0` and a
|
|
871
|
+
// fixed height rather than a smaller padding, so the box is 28px whatever line
|
|
872
|
+
// height the caller's type brings with it.
|
|
873
|
+
const SELECT_SM = "h-7 py-0 pl-2 pr-7 text-xs";
|
|
874
|
+
|
|
735
875
|
export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select(
|
|
736
|
-
{ className, label, id, children, invalid, error, hint, ...rest },
|
|
876
|
+
{ className, label, id, children, invalid, error, hint, size, selectClassName, ...rest },
|
|
737
877
|
ref,
|
|
738
878
|
) {
|
|
739
879
|
const generated = useId();
|
|
880
|
+
// Only a number reaches the DOM; the two words are this component's own.
|
|
881
|
+
const nativeSize = typeof size === "number" ? size : undefined;
|
|
882
|
+
const small = size === "sm" && label === undefined;
|
|
740
883
|
const fieldId = id ?? generated;
|
|
741
884
|
const { isInvalid, describedBy, errorEl } = useFieldError(
|
|
742
885
|
error,
|
|
743
886
|
invalid,
|
|
744
887
|
rest["aria-describedby"],
|
|
888
|
+
rest["aria-invalid"],
|
|
745
889
|
);
|
|
746
890
|
// Custom chevron (native arrow hidden via appearance-none) so it sits a touch
|
|
747
891
|
// in from the right border and matches both themes — feedback #223. A DISABLED
|
|
748
892
|
// select has no menu to drop, so it drops the chevron too: the arrow is the one
|
|
749
893
|
// thing on the control that promises a choice (Keksdose dev#474, where the
|
|
750
894
|
// account type became read-only and still looked exactly like a picker).
|
|
751
|
-
const chevron = rest.disabled ? null :
|
|
895
|
+
const chevron = rest.disabled ? null : (
|
|
896
|
+
<FieldChevron className={small ? "right-1.5 size-3.5" : undefined} />
|
|
897
|
+
);
|
|
752
898
|
if (label === undefined) {
|
|
753
899
|
return (
|
|
754
900
|
<FieldGroup errorEl={errorEl}>
|
|
@@ -762,6 +908,7 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
|
|
|
762
908
|
// when it was reached. Input and Textarea both forward it here; this is
|
|
763
909
|
// the third one doing the same thing.
|
|
764
910
|
id={id}
|
|
911
|
+
size={nativeSize}
|
|
765
912
|
{...rest}
|
|
766
913
|
// OR-ed with the spread for the reason spelled out on Input's copy: this
|
|
767
914
|
// branch wrote `invalid || undefined`, so passing `aria-invalid` by hand
|
|
@@ -770,7 +917,13 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
|
|
|
770
917
|
// attribute silently dropped. Input has never done that.
|
|
771
918
|
aria-invalid={isInvalid || rest["aria-invalid"] || undefined}
|
|
772
919
|
aria-describedby={describedBy}
|
|
773
|
-
className={cn(
|
|
920
|
+
className={cn(
|
|
921
|
+
FIELD_BASE,
|
|
922
|
+
"appearance-none pr-9",
|
|
923
|
+
small && SELECT_SM,
|
|
924
|
+
selectClassName,
|
|
925
|
+
isInvalid && FIELD_INVALID,
|
|
926
|
+
)}
|
|
774
927
|
>
|
|
775
928
|
{children}
|
|
776
929
|
</select>
|
|
@@ -785,6 +938,7 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
|
|
|
785
938
|
<select
|
|
786
939
|
ref={ref}
|
|
787
940
|
id={fieldId}
|
|
941
|
+
size={nativeSize}
|
|
788
942
|
{...rest}
|
|
789
943
|
aria-invalid={isInvalid || rest["aria-invalid"] || undefined}
|
|
790
944
|
aria-describedby={describedBy}
|
|
@@ -792,6 +946,7 @@ export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select
|
|
|
792
946
|
FIELD_BASE,
|
|
793
947
|
FIELD_FLOATING_PAD,
|
|
794
948
|
"peer appearance-none pr-9",
|
|
949
|
+
selectClassName,
|
|
795
950
|
isInvalid && FIELD_INVALID,
|
|
796
951
|
)}
|
|
797
952
|
>
|
|
@@ -822,6 +977,7 @@ export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function
|
|
|
822
977
|
error,
|
|
823
978
|
invalid,
|
|
824
979
|
rest["aria-describedby"],
|
|
980
|
+
rest["aria-invalid"],
|
|
825
981
|
);
|
|
826
982
|
if (label === undefined) {
|
|
827
983
|
return (
|
|
@@ -968,9 +1124,17 @@ export function CardFooter({ className, ...props }: CardFooterProps) {
|
|
|
968
1124
|
/** A `<span>`'s props plus the words a reader hears — see {@link CardHeaderProps}.
|
|
969
1125
|
* The ring is drawn with a border, so there is nothing inside it to put children in. */
|
|
970
1126
|
export interface SpinnerProps extends Omit<ComponentPropsWithoutRef<"span">, "children"> {
|
|
971
|
-
/**
|
|
972
|
-
*
|
|
973
|
-
|
|
1127
|
+
/**
|
|
1128
|
+
* What the spinner means, for a screen reader. Default: `common.loading` from the
|
|
1129
|
+
* {@link UiKitProvider}, else "Loading…".
|
|
1130
|
+
*
|
|
1131
|
+
* `null` makes it DECORATIVE — no role, no text, hidden from assistive tech. Use it
|
|
1132
|
+
* whenever words are already there: beside visible "Loading…" text (otherwise it is
|
|
1133
|
+
* announced twice), inside a labelled button (otherwise its text joins the button's
|
|
1134
|
+
* name), or inside a live region of the app's own (otherwise that region and this
|
|
1135
|
+
* one both announce).
|
|
1136
|
+
*/
|
|
1137
|
+
label?: string | null;
|
|
974
1138
|
}
|
|
975
1139
|
|
|
976
1140
|
/**
|
|
@@ -984,22 +1148,25 @@ export interface SpinnerProps extends Omit<ComponentPropsWithoutRef<"span">, "ch
|
|
|
984
1148
|
* ignore a name on one.
|
|
985
1149
|
*
|
|
986
1150
|
* `relative` so the `sr-only` text has a local containing block (see
|
|
987
|
-
* sr-only-containment.test).
|
|
988
|
-
*
|
|
989
|
-
*
|
|
1151
|
+
* sr-only-containment.test). Where the words are already on screen, pass
|
|
1152
|
+
* `label={null}`: announcing is right for a spinner standing alone and wrong for one
|
|
1153
|
+
* beside text, inside a button, or inside the app's own live region.
|
|
990
1154
|
*/
|
|
991
1155
|
export function Spinner({ className, label, ...rest }: SpinnerProps) {
|
|
992
|
-
const common = useKitLabels("common", DEFAULT_COMMON_LABELS, {
|
|
1156
|
+
const common = useKitLabels("common", DEFAULT_COMMON_LABELS, {
|
|
1157
|
+
loading: label ?? undefined,
|
|
1158
|
+
});
|
|
1159
|
+
const ring = cn(
|
|
1160
|
+
"relative inline-block h-5 w-5 animate-spin rounded-full border-2 border-[var(--border)] border-t-[var(--text-primary)]",
|
|
1161
|
+
className,
|
|
1162
|
+
);
|
|
1163
|
+
if (label === null) return <span aria-hidden {...rest} className={ring} />;
|
|
993
1164
|
return (
|
|
994
|
-
<span
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
className,
|
|
1000
|
-
)}
|
|
1001
|
-
>
|
|
1002
|
-
<span className="sr-only">{common.loading}</span>
|
|
1165
|
+
<span role="status" {...rest} className={ring}>
|
|
1166
|
+
{/* The trailing space is a separator for the case the caller forgot `label={null}`
|
|
1167
|
+
inside a button: a name is the concatenation of its text, and without it the
|
|
1168
|
+
spinner's word ran straight into the button's — "Loadingconfirm". */}
|
|
1169
|
+
<span className="sr-only">{common.loading} </span>
|
|
1003
1170
|
</span>
|
|
1004
1171
|
);
|
|
1005
1172
|
}
|
|
@@ -1027,6 +1194,49 @@ export function EmptyState({ title, hint, className, ...rest }: EmptyStateProps)
|
|
|
1027
1194
|
);
|
|
1028
1195
|
}
|
|
1029
1196
|
|
|
1197
|
+
/** One tab of a {@link Tabs} strip. */
|
|
1198
|
+
export interface TabItem<T extends string> {
|
|
1199
|
+
id: T;
|
|
1200
|
+
/** A ReactNode so a tab can pair an icon with text. */
|
|
1201
|
+
label: ReactNode;
|
|
1202
|
+
/** Optional trailing node (e.g. a count pill). */
|
|
1203
|
+
badge?: ReactNode;
|
|
1204
|
+
/** Marks a tab that IS a route — see {@link TabsProps}. */
|
|
1205
|
+
href?: string;
|
|
1206
|
+
/** Leading decoration: an icon, or the colour swatches of the lines this tab
|
|
1207
|
+
* draws in a chart above the strip. Hidden from assistive tech — it repeats
|
|
1208
|
+
* what the label says, or it says something the label must say instead. */
|
|
1209
|
+
icon?: ReactNode;
|
|
1210
|
+
/** A second, smaller line under the label — the one fact that tells two tabs
|
|
1211
|
+
* with similar labels apart ("80 km/h", "Level 3"). Part of the tab's name. */
|
|
1212
|
+
detail?: ReactNode;
|
|
1213
|
+
/** A tab that exists as a slot with nothing in it yet, drawn with a dashed
|
|
1214
|
+
* outline so a gap in a sequence looks like a gap. Visual only: say "empty" in
|
|
1215
|
+
* the label or `detail` if a screen reader needs to know. */
|
|
1216
|
+
empty?: boolean;
|
|
1217
|
+
/** `false` keeps this one tab when the strip has `onRemove` — the one sheet a
|
|
1218
|
+
* workbook cannot be without. Default: removable. */
|
|
1219
|
+
removable?: boolean;
|
|
1220
|
+
/** Plain-text name for the labels the strip composes about this tab ("Remove
|
|
1221
|
+
* …"), for when `label` is not a string. Default: `label` if it is a string,
|
|
1222
|
+
* else the id. */
|
|
1223
|
+
name?: string;
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
/** The strings the strip renders on its own behalf — only when it can add or
|
|
1227
|
+
* remove tabs. */
|
|
1228
|
+
export interface TabsLabels {
|
|
1229
|
+
/** The "add" button's visible text when the caller gives no `addLabel`. */
|
|
1230
|
+
add: string;
|
|
1231
|
+
/** Accessible name of a tab's × — "Remove <tab>". */
|
|
1232
|
+
remove: (tab: string) => string;
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
export const DEFAULT_TABS_LABELS: TabsLabels = {
|
|
1236
|
+
add: "Add tab",
|
|
1237
|
+
remove: (tab) => `Remove ${tab}`,
|
|
1238
|
+
};
|
|
1239
|
+
|
|
1030
1240
|
/**
|
|
1031
1241
|
* The strip's own props sit on a `<div>`: the tablist IS the root element, so anything
|
|
1032
1242
|
* a caller hangs on it — a `data-tour` anchor for the kit's guided tour, a test id, an
|
|
@@ -1043,7 +1253,7 @@ export interface TabsProps<T extends string>
|
|
|
1043
1253
|
* feedback #451), while a plain click still goes through `onChange` and stays
|
|
1044
1254
|
* client-side. Tabs that only flip local state leave it unset — a link to a URL
|
|
1045
1255
|
* that does not select the tab would be worse than no link. */
|
|
1046
|
-
tabs:
|
|
1256
|
+
tabs: TabItem<T>[];
|
|
1047
1257
|
active: T;
|
|
1048
1258
|
onChange: (id: T) => void;
|
|
1049
1259
|
className?: string;
|
|
@@ -1109,6 +1319,34 @@ export interface TabsProps<T extends string>
|
|
|
1109
1319
|
* describes a tab as opening something that is not there.
|
|
1110
1320
|
*/
|
|
1111
1321
|
panelId?: string;
|
|
1322
|
+
/**
|
|
1323
|
+
* Makes the tabs removable: an × on the OPEN tab, and Delete on a focused tab.
|
|
1324
|
+
*
|
|
1325
|
+
* The × is on the open tab only — a row of tabs each carrying one is a row of
|
|
1326
|
+
* things to hit by accident, and the tab you can remove should be the one you
|
|
1327
|
+
* are looking at — but its room is reserved on every removable tab, so opening a
|
|
1328
|
+
* tab never widens it and never pushes the rest of the strip along (lenkbank
|
|
1329
|
+
* feedback #72). It is named "Remove <tab>" and is not a tab stop of its own: the
|
|
1330
|
+
* strip stays one stop, and the keyboard path is Delete (announced through
|
|
1331
|
+
* `aria-keyshortcuts`), which removes whichever tab has focus.
|
|
1332
|
+
*
|
|
1333
|
+
* The strip only ASKS: it calls this, and the tab goes when the caller drops it
|
|
1334
|
+
* from `tabs` — after a confirmation, a request, whatever it needs. When it does,
|
|
1335
|
+
* focus that was on the removed tab (or its ×) moves to the open tab if the
|
|
1336
|
+
* removed one was open, else to its neighbour, instead of falling to the page.
|
|
1337
|
+
*/
|
|
1338
|
+
onRemove?: (id: T) => void;
|
|
1339
|
+
/** Renders an "add" button after the last tab, outside the tablist — it is an
|
|
1340
|
+
* action, not a tab, and a tablist may own only tabs. */
|
|
1341
|
+
onAdd?: () => void;
|
|
1342
|
+
/** The add button's text. Say what is added — "Add loop", "Add report" says
|
|
1343
|
+
* more than "Add". Default: `tabs.add` from the {@link UiKitProvider}. */
|
|
1344
|
+
addLabel?: string;
|
|
1345
|
+
/** Overrides for this strip's strings. See {@link TabsLabels}. */
|
|
1346
|
+
labels?: Partial<TabsLabels>;
|
|
1347
|
+
/** An add or a remove is in flight: the × and the add button are disabled and
|
|
1348
|
+
* Delete is ignored, so a double click cannot remove two tabs. Nothing moves. */
|
|
1349
|
+
busy?: boolean;
|
|
1112
1350
|
}
|
|
1113
1351
|
|
|
1114
1352
|
// The two shapes are written out as whole strings rather than as one base plus a
|
|
@@ -1145,6 +1383,15 @@ const TAB_WRAP_ACTIVE_CLASSES =
|
|
|
1145
1383
|
const TAB_WRAP_INACTIVE_CLASSES =
|
|
1146
1384
|
"bg-[var(--bg-surface)] text-[var(--text-primary)] md:border-transparent md:text-[var(--text-muted)] md:hover:border-[var(--border-strong)] md:hover:text-[var(--text-secondary)]";
|
|
1147
1385
|
|
|
1386
|
+
// The add/remove chrome. The × reserves its gutter on every removable tab (see
|
|
1387
|
+
// `onRemove`); `md:pe-8` is spelled out because the wrapped chip's `md:px-3` would
|
|
1388
|
+
// otherwise hand the gutter back from 768px up.
|
|
1389
|
+
const TAB_REMOVABLE_CLASSES = "pe-8 md:pe-8";
|
|
1390
|
+
const TAB_EMPTY_CLASSES = "-mx-1.5 rounded border border-dashed border-[var(--border-strong)] px-1.5";
|
|
1391
|
+
const TAB_ADD_CLASSES =
|
|
1392
|
+
"inline-flex items-center gap-1 whitespace-nowrap rounded-md px-3 py-2 text-sm font-medium text-[var(--text-muted)] transition-colors hover:text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--border-strong)] disabled:cursor-not-allowed disabled:opacity-50 [&_svg]:size-4";
|
|
1393
|
+
const TAB_ADD_WRAP_CLASSES = "px-2.5 text-xs md:px-3 md:text-sm";
|
|
1394
|
+
|
|
1148
1395
|
export function Tabs<T extends string>({
|
|
1149
1396
|
tabs,
|
|
1150
1397
|
active,
|
|
@@ -1153,9 +1400,53 @@ export function Tabs<T extends string>({
|
|
|
1153
1400
|
wrap = false,
|
|
1154
1401
|
label,
|
|
1155
1402
|
panelId,
|
|
1403
|
+
onRemove,
|
|
1404
|
+
onAdd,
|
|
1405
|
+
addLabel,
|
|
1406
|
+
labels,
|
|
1407
|
+
busy = false,
|
|
1156
1408
|
"aria-label": ariaLabel,
|
|
1157
1409
|
...rest
|
|
1158
1410
|
}: TabsProps<T>) {
|
|
1411
|
+
const text = useKitLabels("tabs", DEFAULT_TABS_LABELS, labels);
|
|
1412
|
+
const stripRef = useRef<HTMLDivElement>(null);
|
|
1413
|
+
const isRemovable = (tab: TabItem<T>) => onRemove !== undefined && tab.removable !== false;
|
|
1414
|
+
const nameOf = (tab: TabItem<T>) =>
|
|
1415
|
+
tab.name ?? (typeof tab.label === "string" ? tab.label : tab.id);
|
|
1416
|
+
|
|
1417
|
+
// Where focus goes once a removal the strip asked for has happened. The caller
|
|
1418
|
+
// owns `tabs` and may take its time (a confirmation, a request), so this waits
|
|
1419
|
+
// for the id to actually leave the list rather than guessing at the moment.
|
|
1420
|
+
const pendingFocus = useRef<{ removed: T; wasActive: boolean; neighbour?: T } | null>(null);
|
|
1421
|
+
const requestRemove = (id: T) => {
|
|
1422
|
+
const index = tabs.findIndex((t) => t.id === id);
|
|
1423
|
+
pendingFocus.current = {
|
|
1424
|
+
removed: id,
|
|
1425
|
+
wasActive: id === active,
|
|
1426
|
+
neighbour: (tabs[index + 1] ?? tabs[index - 1])?.id,
|
|
1427
|
+
};
|
|
1428
|
+
onRemove?.(id);
|
|
1429
|
+
};
|
|
1430
|
+
useLayoutEffect(() => {
|
|
1431
|
+
const pending = pendingFocus.current;
|
|
1432
|
+
if (!pending || tabs.some((t) => t.id === pending.removed)) return;
|
|
1433
|
+
pendingFocus.current = null;
|
|
1434
|
+
const strip = stripRef.current;
|
|
1435
|
+
if (!strip) return;
|
|
1436
|
+
// Only focus that fell out of the strip with the removed tab is ours to place.
|
|
1437
|
+
// If the user has moved on — into the panel, into a dialog — leave it there.
|
|
1438
|
+
const focused = document.activeElement;
|
|
1439
|
+
if (focused && focused !== document.body && !strip.contains(focused)) return;
|
|
1440
|
+
// The removed tab was open: focus follows the caller's new selection, which is
|
|
1441
|
+
// also the strip's tab stop. It was not: its neighbour, as the ARIA pattern has it.
|
|
1442
|
+
const target =
|
|
1443
|
+
!pending.wasActive && tabs.some((t) => t.id === pending.neighbour)
|
|
1444
|
+
? pending.neighbour
|
|
1445
|
+
: active;
|
|
1446
|
+
const index = tabs.findIndex((t) => t.id === target);
|
|
1447
|
+
strip.querySelectorAll<HTMLElement>('[role="tab"]')[index]?.focus();
|
|
1448
|
+
}, [tabs, active]);
|
|
1449
|
+
|
|
1159
1450
|
// Arrow keys walk the strip in DOM order (ARIA tabs pattern), which is what keeps
|
|
1160
1451
|
// a WRAPPED strip navigable: the rows flow in DOM order too, so Right off the end
|
|
1161
1452
|
// of row one lands on the first chip of row two rather than nowhere. Home/End jump
|
|
@@ -1169,7 +1460,15 @@ export function Tabs<T extends string>({
|
|
|
1169
1460
|
// the handler on the elements that are natively focusable and actually receive the
|
|
1170
1461
|
// keystroke. Those are the tabs, so the warning goes away by being right rather
|
|
1171
1462
|
// than by being satisfied.
|
|
1172
|
-
const onTabKeyDown = (e: KeyboardEvent<HTMLElement>) => {
|
|
1463
|
+
const onTabKeyDown = (e: KeyboardEvent<HTMLElement>, tab: TabItem<T>) => {
|
|
1464
|
+
// Delete removes the FOCUSED tab (the ARIA pattern's optional key for deletable
|
|
1465
|
+
// tabs) — the keyboard's way to the × that is not a tab stop. `busy` swallows it
|
|
1466
|
+
// rather than letting it fall through to the page.
|
|
1467
|
+
if (e.key === "Delete" && isRemovable(tab)) {
|
|
1468
|
+
e.preventDefault();
|
|
1469
|
+
if (!busy) requestRemove(tab.id);
|
|
1470
|
+
return;
|
|
1471
|
+
}
|
|
1173
1472
|
const step = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0;
|
|
1174
1473
|
if (step === 0 && e.key !== "Home" && e.key !== "End") return;
|
|
1175
1474
|
const strip = e.currentTarget.closest('[role="tablist"]');
|
|
@@ -1186,19 +1485,26 @@ export function Tabs<T extends string>({
|
|
|
1186
1485
|
: (from + step + items.length) % items.length;
|
|
1187
1486
|
items[to]?.focus();
|
|
1188
1487
|
};
|
|
1189
|
-
|
|
1488
|
+
const tablist = (
|
|
1190
1489
|
<div
|
|
1191
1490
|
// Before the role and the name: a caller's arbitrary attribute is welcome on the
|
|
1192
1491
|
// strip, but a `role` or an `aria-label` arriving through a spread props object
|
|
1193
1492
|
// must not be able to unmake the tablist the tabs below are registered against.
|
|
1194
1493
|
{...rest}
|
|
1494
|
+
ref={stripRef}
|
|
1195
1495
|
role="tablist"
|
|
1196
1496
|
// The DOM spelling wins over the deprecated `label`; see {@link TabsProps}.
|
|
1197
1497
|
aria-label={ariaLabel ?? label}
|
|
1198
|
-
|
|
1498
|
+
// With an add button the strip gains an outer box, and `className` goes there
|
|
1499
|
+
// — it is the box a caller's margin or width is meant for.
|
|
1500
|
+
className={cn(
|
|
1501
|
+
wrap ? TABLIST_WRAP_CLASSES : TABLIST_CLASSES,
|
|
1502
|
+
onAdd ? "min-w-0" : className,
|
|
1503
|
+
)}
|
|
1199
1504
|
>
|
|
1200
1505
|
{tabs.map((tab) => {
|
|
1201
1506
|
const isActive = tab.id === active;
|
|
1507
|
+
const removable = isRemovable(tab);
|
|
1202
1508
|
// Written once and worn by either tag below, so a routed tab and a
|
|
1203
1509
|
// state-only tab stay indistinguishable to the eye and to a screen reader.
|
|
1204
1510
|
const shared = {
|
|
@@ -1220,7 +1526,8 @@ export function Tabs<T extends string>({
|
|
|
1220
1526
|
// Only the open tab: see `panelId`.
|
|
1221
1527
|
"aria-controls": isActive ? panelId : undefined,
|
|
1222
1528
|
id: isActive && panelId ? `${panelId}-tab` : undefined,
|
|
1223
|
-
|
|
1529
|
+
"aria-keyshortcuts": removable ? "Delete" : undefined,
|
|
1530
|
+
onKeyDown: (e: KeyboardEvent<HTMLElement>) => onTabKeyDown(e, tab),
|
|
1224
1531
|
className: cn(
|
|
1225
1532
|
wrap ? TAB_WRAP_CLASSES : TAB_CLASSES,
|
|
1226
1533
|
wrap
|
|
@@ -1230,15 +1537,33 @@ export function Tabs<T extends string>({
|
|
|
1230
1537
|
: isActive
|
|
1231
1538
|
? TAB_ACTIVE_CLASSES
|
|
1232
1539
|
: TAB_INACTIVE_CLASSES,
|
|
1540
|
+
removable && TAB_REMOVABLE_CLASSES,
|
|
1233
1541
|
),
|
|
1234
1542
|
};
|
|
1235
1543
|
const inner = (
|
|
1236
|
-
<span className="inline-flex items-center gap-1.5">
|
|
1237
|
-
{tab.
|
|
1544
|
+
<span className={cn("inline-flex items-center gap-1.5", tab.empty && TAB_EMPTY_CLASSES)}>
|
|
1545
|
+
{tab.icon != null && (
|
|
1546
|
+
<span aria-hidden className="inline-flex shrink-0 items-center gap-0.5">
|
|
1547
|
+
{tab.icon}
|
|
1548
|
+
</span>
|
|
1549
|
+
)}
|
|
1550
|
+
{tab.detail != null ? (
|
|
1551
|
+
<span className="flex flex-col items-start text-start">
|
|
1552
|
+
<span>{tab.label}</span>
|
|
1553
|
+
{/* A separator for the NAME only — whitespace between flex items is
|
|
1554
|
+
not drawn, and without it the two lines ran together into one
|
|
1555
|
+
word for a screen reader ("80 km/hloop 2"). */}{" "}
|
|
1556
|
+
<span className="text-[11px] font-normal leading-tight text-[var(--text-muted)]">
|
|
1557
|
+
{tab.detail}
|
|
1558
|
+
</span>
|
|
1559
|
+
</span>
|
|
1560
|
+
) : (
|
|
1561
|
+
tab.label
|
|
1562
|
+
)}
|
|
1238
1563
|
{tab.badge != null ? tab.badge : null}
|
|
1239
1564
|
</span>
|
|
1240
1565
|
);
|
|
1241
|
-
|
|
1566
|
+
const element = tab.href ? (
|
|
1242
1567
|
<a
|
|
1243
1568
|
key={tab.id}
|
|
1244
1569
|
{...shared}
|
|
@@ -1265,7 +1590,7 @@ export function Tabs<T extends string>({
|
|
|
1265
1590
|
// prop overrides the one in `shared`, and an anchor tab that lost the
|
|
1266
1591
|
// arrows would be a strip that is navigable only where it is not a link.
|
|
1267
1592
|
onKeyDown={(e) => {
|
|
1268
|
-
onTabKeyDown(e);
|
|
1593
|
+
onTabKeyDown(e, tab);
|
|
1269
1594
|
if (e.defaultPrevented || e.key !== " ") return;
|
|
1270
1595
|
e.preventDefault();
|
|
1271
1596
|
onChange(tab.id);
|
|
@@ -1278,7 +1603,69 @@ export function Tabs<T extends string>({
|
|
|
1278
1603
|
{inner}
|
|
1279
1604
|
</button>
|
|
1280
1605
|
);
|
|
1606
|
+
if (!removable) return element;
|
|
1607
|
+
return (
|
|
1608
|
+
// The × cannot go INSIDE the tab: a button in a button is invalid HTML, and
|
|
1609
|
+
// a tab's children are presentational, so a reader would flatten it into the
|
|
1610
|
+
// tab's name. It sits beside the tab, over the gutter the tab reserves.
|
|
1611
|
+
<div key={tab.id} className="relative flex shrink-0">
|
|
1612
|
+
{element}
|
|
1613
|
+
{isActive && (
|
|
1614
|
+
<IconButton
|
|
1615
|
+
size="2xs"
|
|
1616
|
+
tone="danger"
|
|
1617
|
+
// Not a tab stop: the strip is one, and Delete is the keyboard's path.
|
|
1618
|
+
// Still a named button, so a screen reader's cursor and a touch
|
|
1619
|
+
// reader's swipe both reach it.
|
|
1620
|
+
tabIndex={-1}
|
|
1621
|
+
type="button"
|
|
1622
|
+
aria-label={text.remove(nameOf(tab))}
|
|
1623
|
+
disabled={busy}
|
|
1624
|
+
onClick={() => requestRemove(tab.id)}
|
|
1625
|
+
className={cn(
|
|
1626
|
+
"absolute inset-y-0 end-1 my-auto",
|
|
1627
|
+
// On a wrapped strip's filled chip the quiet grey would vanish into
|
|
1628
|
+
// the brand fill below `md`.
|
|
1629
|
+
wrap && "text-[var(--brand-contrast)] md:text-[var(--text-placeholder)]",
|
|
1630
|
+
)}
|
|
1631
|
+
>
|
|
1632
|
+
<X />
|
|
1633
|
+
</IconButton>
|
|
1634
|
+
)}
|
|
1635
|
+
</div>
|
|
1636
|
+
);
|
|
1281
1637
|
})}
|
|
1282
1638
|
</div>
|
|
1283
1639
|
);
|
|
1640
|
+
if (!onAdd) return tablist;
|
|
1641
|
+
return (
|
|
1642
|
+
<div
|
|
1643
|
+
className={cn(
|
|
1644
|
+
wrap ? "flex flex-wrap items-end gap-1.5 md:flex-nowrap md:gap-0" : "flex items-end",
|
|
1645
|
+
className,
|
|
1646
|
+
)}
|
|
1647
|
+
>
|
|
1648
|
+
{tablist}
|
|
1649
|
+
{/* Outside the tablist: an action, not a tab — and a tablist may own only
|
|
1650
|
+
tabs. The rule under the strip carries on beneath it. */}
|
|
1651
|
+
<div
|
|
1652
|
+
className={cn(
|
|
1653
|
+
"flex flex-1 items-center",
|
|
1654
|
+
wrap
|
|
1655
|
+
? "md:self-stretch md:border-b md:border-[var(--border)]"
|
|
1656
|
+
: "self-stretch border-b border-[var(--border)]",
|
|
1657
|
+
)}
|
|
1658
|
+
>
|
|
1659
|
+
<button
|
|
1660
|
+
type="button"
|
|
1661
|
+
onClick={onAdd}
|
|
1662
|
+
disabled={busy}
|
|
1663
|
+
className={cn(TAB_ADD_CLASSES, wrap && TAB_ADD_WRAP_CLASSES)}
|
|
1664
|
+
>
|
|
1665
|
+
<Plus aria-hidden />
|
|
1666
|
+
{addLabel ?? text.add}
|
|
1667
|
+
</button>
|
|
1668
|
+
</div>
|
|
1669
|
+
</div>
|
|
1670
|
+
);
|
|
1284
1671
|
}
|