@kennycorrea/ui 1.7.0 → 1.8.1
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/CHANGELOG.md +12 -0
- package/dist/atoms/TabBar/TabBar.d.ts +8 -2
- package/dist/atoms/TabBar/TabBar.js +4 -3
- package/dist/organisms/Accordion/Accordion.d.ts +34 -0
- package/dist/organisms/Accordion/Accordion.js +63 -0
- package/dist/organisms/Accordion/index.d.ts +1 -0
- package/dist/organisms/Accordion/index.js +1 -0
- package/dist/organisms/index.d.ts +1 -0
- package/dist/organisms/index.js +1 -0
- package/package.json +1 -1
- package/tokens.css +39 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.8.1 — 2026-10-30
|
|
4
|
+
|
|
5
|
+
TabBar label contrast.
|
|
6
|
+
|
|
7
|
+
- **`TabBar` is opaque by default.** Over coloured content the translucent bar left its 10px labels below WCAG AA (gray-11 at 3.3:1 in light). The see-through material is now opt-in with `translucent`, which switches labels to step 12 (≥ 6:1 over any backdrop). The active label uses `accent-12` in both modes, because `accent-11` on the canvas fails for eleven accents in light; the icon keeps the brand colour. The active item is also bolder and has an indicator pill, so colour is not the only difference. Checked against saturated backdrops in `test/TabBar-contrast.test.tsx`; demo in `examples/TabBars.tsx`.
|
|
8
|
+
|
|
9
|
+
## 1.8.0 — 2026-10-27
|
|
10
|
+
|
|
11
|
+
An `Accordion` organism.
|
|
12
|
+
|
|
13
|
+
- **`Accordion`, `AccordionItem`, `AccordionTrigger`, `AccordionContent`.** Built on native `<details>`/`<summary>`, so keyboard and screen-reader support come from the browser. `type="multiple"` (default) or `"single"`; controlled with `value`/`onValueChange` or uncontrolled with `defaultValue`. The trigger takes any content and wraps; a chevron turns when open and stays still under reduced motion. Content mounts only while its item is open, unless `keepMounted` is set on the Accordion or the item. No built-in strings. Example in `examples/Accordions.tsx`; axe-checked in light and dark in `test/Accordion.test.tsx`.
|
|
14
|
+
|
|
3
15
|
## 1.7.0 — 2026-10-23
|
|
4
16
|
|
|
5
17
|
Sidebar groups as an accordion, and select-all for stacked tables.
|
|
@@ -26,16 +26,22 @@ export interface TabBarProps {
|
|
|
26
26
|
onValueChange?: (value: string) => void;
|
|
27
27
|
/** Breakpoint (Radix Themes' scale) from which the bar is hidden. Default `md`. */
|
|
28
28
|
hideFrom?: TabBarBreakpoint;
|
|
29
|
+
/**
|
|
30
|
+
* Blurred see-through material instead of the default opaque bar. Labels switch to step 12 so
|
|
31
|
+
* they stay AA over whatever scrolls beneath.
|
|
32
|
+
*/
|
|
33
|
+
translucent?: boolean;
|
|
29
34
|
'aria-label'?: string;
|
|
30
35
|
className?: string;
|
|
31
36
|
}
|
|
32
37
|
/**
|
|
33
38
|
* A fixed bottom navigation bar for small screens. 3–5 items; the active one is in the brand
|
|
34
|
-
* colour and carries `aria-current="page"`.
|
|
39
|
+
* colour, bolder and on an indicator pill, and carries `aria-current="page"`. Opaque unless
|
|
40
|
+
* `translucent`. Height is `--tabbar-height` (49px + `--safe-bottom`);
|
|
35
41
|
* render a {@link TabBarSpacer} (or pad by that variable) so content is not covered.
|
|
36
42
|
* Press feedback is `:active`; nothing depends on hover.
|
|
37
43
|
*/
|
|
38
|
-
export declare function TabBar({ items, value, onValueChange, hideFrom, className, 'aria-label': ariaLabel, }: TabBarProps): import("react").JSX.Element;
|
|
44
|
+
export declare function TabBar({ items, value, onValueChange, hideFrom, translucent, className, 'aria-label': ariaLabel, }: TabBarProps): import("react").JSX.Element;
|
|
39
45
|
/** Reserves the bar's height in the flow, hidden at the same breakpoint as the bar. */
|
|
40
46
|
export declare function TabBarSpacer({ hideFrom }: {
|
|
41
47
|
hideFrom?: TabBarBreakpoint;
|
|
@@ -8,12 +8,13 @@ function badgeText(badge) {
|
|
|
8
8
|
}
|
|
9
9
|
/**
|
|
10
10
|
* A fixed bottom navigation bar for small screens. 3–5 items; the active one is in the brand
|
|
11
|
-
* colour and carries `aria-current="page"`.
|
|
11
|
+
* colour, bolder and on an indicator pill, and carries `aria-current="page"`. Opaque unless
|
|
12
|
+
* `translucent`. Height is `--tabbar-height` (49px + `--safe-bottom`);
|
|
12
13
|
* render a {@link TabBarSpacer} (or pad by that variable) so content is not covered.
|
|
13
14
|
* Press feedback is `:active`; nothing depends on hover.
|
|
14
15
|
*/
|
|
15
|
-
export function TabBar({ items, value, onValueChange, hideFrom = 'md', className, 'aria-label': ariaLabel, }) {
|
|
16
|
-
return (_jsx("nav", { className: ['ui-tabbar', className].filter(Boolean).join(' '), "data-hide-from": hideFrom, "aria-label": ariaLabel, children: items.map((item) => {
|
|
16
|
+
export function TabBar({ items, value, onValueChange, hideFrom = 'md', translucent = false, className, 'aria-label': ariaLabel, }) {
|
|
17
|
+
return (_jsx("nav", { className: ['ui-tabbar', className].filter(Boolean).join(' '), "data-hide-from": hideFrom, "data-material": translucent ? 'translucent' : 'opaque', "aria-label": ariaLabel, children: items.map((item) => {
|
|
17
18
|
const active = item.value === value;
|
|
18
19
|
const text = badgeText(item.badge);
|
|
19
20
|
const children = (_jsxs(_Fragment, { children: [_jsxs("span", { className: "ui-tabbar__icon", "aria-hidden": "true", children: [active && item.activeIcon ? item.activeIcon : item.icon, text !== null && (_jsx("span", { className: "ui-tabbar__badge", "data-dot": text === '' ? 'true' : undefined, children: text }))] }), _jsx("span", { className: "ui-tabbar__label", children: item.label })] }));
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type ComponentPropsWithoutRef, type ReactNode } from 'react';
|
|
2
|
+
export interface AccordionProps {
|
|
3
|
+
/** `multiple` lets any number be open; `single` closes the others when one opens. */
|
|
4
|
+
type?: 'multiple' | 'single';
|
|
5
|
+
/** The open items. Controlled when given, with `onValueChange`. */
|
|
6
|
+
value?: string[];
|
|
7
|
+
defaultValue?: string[];
|
|
8
|
+
onValueChange?: (value: string[]) => void;
|
|
9
|
+
/** Keep every item's content mounted while closed. Each item can override it. */
|
|
10
|
+
keepMounted?: boolean;
|
|
11
|
+
className?: string;
|
|
12
|
+
children: ReactNode;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* A stack of collapsible sections, each a native `<details>`/`<summary>`: the summary is the
|
|
16
|
+
* button, so keyboard (Enter/Space), focus and the expanded state reach assistive technology
|
|
17
|
+
* without any ARIA of our own. Open state lives in React, so `value` can be controlled.
|
|
18
|
+
* Content mounts only while open unless `keepMounted` is set.
|
|
19
|
+
*/
|
|
20
|
+
export declare function Accordion({ type, value, defaultValue, onValueChange, keepMounted, className, children, }: AccordionProps): import("react").JSX.Element;
|
|
21
|
+
export interface AccordionItemProps extends Omit<ComponentPropsWithoutRef<'details'>, 'open' | 'onToggle'> {
|
|
22
|
+
value: string;
|
|
23
|
+
/** Overrides the Accordion's `keepMounted` for this item. */
|
|
24
|
+
keepMounted?: boolean;
|
|
25
|
+
}
|
|
26
|
+
export declare function AccordionItem({ value, keepMounted, className, children, ...rest }: AccordionItemProps): import("react").JSX.Element;
|
|
27
|
+
export interface AccordionTriggerProps extends ComponentPropsWithoutRef<'summary'> {
|
|
28
|
+
/** Hide the chevron, e.g. when the trigger draws its own. */
|
|
29
|
+
hideChevron?: boolean;
|
|
30
|
+
}
|
|
31
|
+
/** The row that opens and closes the item. Takes any content: a logo, a name, badges, meta. */
|
|
32
|
+
export declare function AccordionTrigger({ className, children, onClick, hideChevron, ...rest }: AccordionTriggerProps): import("react").JSX.Element;
|
|
33
|
+
export type AccordionContentProps = ComponentPropsWithoutRef<'div'>;
|
|
34
|
+
export declare function AccordionContent({ className, ...rest }: AccordionContentProps): import("react").JSX.Element | null;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { ChevronDownIcon } from '@radix-ui/react-icons';
|
|
3
|
+
import { createContext, useCallback, useContext, useState, } from 'react';
|
|
4
|
+
import { cn } from '../../utils/index.js';
|
|
5
|
+
const AccordionContext = createContext(null);
|
|
6
|
+
const ItemContext = createContext(null);
|
|
7
|
+
function useAccordion() {
|
|
8
|
+
const ctx = useContext(AccordionContext);
|
|
9
|
+
if (!ctx)
|
|
10
|
+
throw new Error('AccordionItem must be used inside an Accordion');
|
|
11
|
+
return ctx;
|
|
12
|
+
}
|
|
13
|
+
function useItem() {
|
|
14
|
+
const ctx = useContext(ItemContext);
|
|
15
|
+
if (!ctx)
|
|
16
|
+
throw new Error('AccordionTrigger and AccordionContent must be inside an AccordionItem');
|
|
17
|
+
return ctx;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* A stack of collapsible sections, each a native `<details>`/`<summary>`: the summary is the
|
|
21
|
+
* button, so keyboard (Enter/Space), focus and the expanded state reach assistive technology
|
|
22
|
+
* without any ARIA of our own. Open state lives in React, so `value` can be controlled.
|
|
23
|
+
* Content mounts only while open unless `keepMounted` is set.
|
|
24
|
+
*/
|
|
25
|
+
export function Accordion({ type = 'multiple', value, defaultValue, onValueChange, keepMounted = false, className, children, }) {
|
|
26
|
+
const [inner, setInner] = useState(defaultValue ?? []);
|
|
27
|
+
const open = value ?? inner;
|
|
28
|
+
const toggle = useCallback((item) => {
|
|
29
|
+
const next = open.includes(item)
|
|
30
|
+
? open.filter((v) => v !== item)
|
|
31
|
+
: type === 'single'
|
|
32
|
+
? [item]
|
|
33
|
+
: [...open, item];
|
|
34
|
+
if (value === undefined)
|
|
35
|
+
setInner(next);
|
|
36
|
+
onValueChange?.(next);
|
|
37
|
+
}, [open, type, value, onValueChange]);
|
|
38
|
+
const isOpen = useCallback((item) => open.includes(item), [open]);
|
|
39
|
+
return (_jsx(AccordionContext.Provider, { value: { isOpen, toggle, keepMounted }, children: _jsx("div", { className: cn('ui-accordion', className), children: children }) }));
|
|
40
|
+
}
|
|
41
|
+
export function AccordionItem({ value, keepMounted, className, children, ...rest }) {
|
|
42
|
+
const acc = useAccordion();
|
|
43
|
+
const open = acc.isOpen(value);
|
|
44
|
+
return (_jsx(ItemContext.Provider, { value: { value, open, keepMounted: keepMounted ?? acc.keepMounted }, children: _jsx("details", { ...rest, open: open, className: cn('ui-accordion__item', className), children: children }) }));
|
|
45
|
+
}
|
|
46
|
+
/** The row that opens and closes the item. Takes any content: a logo, a name, badges, meta. */
|
|
47
|
+
export function AccordionTrigger({ className, children, onClick, hideChevron = false, ...rest }) {
|
|
48
|
+
const acc = useAccordion();
|
|
49
|
+
const item = useItem();
|
|
50
|
+
const handleClick = (e) => {
|
|
51
|
+
onClick?.(e);
|
|
52
|
+
// The browser would flip `open` itself; React owns it instead so `value` stays the truth.
|
|
53
|
+
e.preventDefault();
|
|
54
|
+
acc.toggle(item.value);
|
|
55
|
+
};
|
|
56
|
+
return (_jsxs("summary", { ...rest, onClick: handleClick, className: cn('ui-accordion__trigger', className), children: [children, hideChevron ? null : (_jsx("span", { "aria-hidden": "true", className: "ui-accordion__chevron", children: _jsx(ChevronDownIcon, {}) }))] }));
|
|
57
|
+
}
|
|
58
|
+
export function AccordionContent({ className, ...rest }) {
|
|
59
|
+
const item = useItem();
|
|
60
|
+
if (!item.open && !item.keepMounted)
|
|
61
|
+
return null;
|
|
62
|
+
return _jsx("div", { ...rest, className: cn('ui-accordion__content', className) });
|
|
63
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './Accordion.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './Accordion.js';
|
package/dist/organisms/index.js
CHANGED
package/package.json
CHANGED
package/tokens.css
CHANGED
|
@@ -796,18 +796,32 @@
|
|
|
796
796
|
:root { --tabbar-height: calc(49px + var(--safe-bottom, 0px)); }
|
|
797
797
|
.ui-tabbar { position: fixed; inset: auto 0 0 0; z-index: 30; display: flex; box-sizing: border-box;
|
|
798
798
|
height: var(--tabbar-height); padding: 0 var(--safe-right, 0px) var(--safe-bottom, 0px) var(--safe-left, 0px);
|
|
799
|
-
background-color: var(--
|
|
799
|
+
background-color: var(--canvas-grouped, var(--color-panel-solid, #fff));
|
|
800
800
|
box-shadow: inset 0 0.5px 0 var(--color-hairline); }
|
|
801
|
+
/* Opaque by default: gray-11 on the canvas is AA whatever scrolls underneath. `translucent` brings
|
|
802
|
+
the material back; 80% canvas over saturated content leaves gray-11 at 3.3:1 and accent-11 at
|
|
803
|
+
2.5:1 in light, so the labels step up to 12 there (≥ 6:1 over any backdrop). Numbers in
|
|
804
|
+
`test/TabBar-contrast.test.tsx`. */
|
|
805
|
+
.ui-tabbar[data-material="translucent"] { background-color: var(--material-bar);
|
|
806
|
+
-webkit-backdrop-filter: var(--material-bar-filter); backdrop-filter: var(--material-bar-filter); }
|
|
807
|
+
.ui-tabbar[data-material="translucent"] .ui-tabbar__item { color: var(--gray-12); }
|
|
808
|
+
.ui-tabbar[data-material="translucent"] .ui-tabbar__item[data-active="true"] { color: var(--accent-12); }
|
|
801
809
|
.ui-tabbar-spacer { height: var(--tabbar-height); flex: none; }
|
|
802
810
|
.ui-tabbar__cell { flex: 1 1 0; min-width: 0; display: flex; }
|
|
803
811
|
.ui-tabbar__item { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px;
|
|
804
812
|
min-width: 44px; padding: 0; border: 0; background: none; font: inherit; color: var(--gray-11); text-decoration: none;
|
|
805
813
|
cursor: pointer; -webkit-tap-highlight-color: transparent; touch-action: manipulation; }
|
|
806
814
|
.ui-tabbar__item[data-active="true"] { color: var(--accent-11); }
|
|
815
|
+
/* Not by colour alone: the active item is bolder and its icon sits on an indicator pill. The icon
|
|
816
|
+
keeps the brand step; the 10px label takes 12, since accent-11 on gray-3 is under 4.5:1 for
|
|
817
|
+
eleven of the accents in light. */
|
|
818
|
+
.ui-tabbar__item[data-active="true"] .ui-tabbar__label { font-weight: 700; color: var(--accent-12); }
|
|
819
|
+
.ui-tabbar__item[data-active="true"] .ui-tabbar__icon::before { content: ''; position: absolute; inset: -2px -12px; z-index: -1;
|
|
820
|
+
border-radius: 999px; background: var(--accent-a4); }
|
|
807
821
|
.ui-tabbar__item { border-radius: 8px; transition: transform var(--press-duration) ease-out, background-color var(--press-duration) ease-out; }
|
|
808
822
|
.ui-tabbar__item:active { background: var(--gray-a3); transform: scale(var(--press-scale)); }
|
|
809
823
|
.ui-tabbar__item:focus-visible { outline: 2px solid var(--focus-8); outline-offset: -2px; border-radius: 8px; }
|
|
810
|
-
.ui-tabbar__icon { position: relative; display: inline-flex; font-size: 24px; line-height: 1; }
|
|
824
|
+
.ui-tabbar__icon { position: relative; isolation: isolate; display: inline-flex; font-size: 24px; line-height: 1; }
|
|
811
825
|
.ui-tabbar__label { font-size: 10px; line-height: 12px; font-weight: 500; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
812
826
|
.ui-tabbar__badge { position: absolute; top: -4px; left: calc(100% - 8px); min-width: 16px; height: 16px; padding: 0 4px; box-sizing: border-box;
|
|
813
827
|
border-radius: 8px; background: var(--red-9); color: white; font-size: 11px; line-height: 16px; font-weight: 600; text-align: center; }
|
|
@@ -1415,3 +1429,26 @@
|
|
|
1415
1429
|
.rt-TextAreaRoot:has(> textarea[aria-invalid="true"]),
|
|
1416
1430
|
.rt-SelectTrigger[aria-invalid="true"],
|
|
1417
1431
|
.ui-date-input.rt-Button[aria-invalid="true"] { box-shadow: inset 0 0 0 1px var(--red-8); }
|
|
1432
|
+
|
|
1433
|
+
/* Accordion: native <details>, its marker replaced by a chevron that turns when open. */
|
|
1434
|
+
.ui-accordion { display: flex; flex-direction: column; gap: 8px; }
|
|
1435
|
+
.ui-accordion__item {
|
|
1436
|
+
border-radius: var(--radius-3, 8px);
|
|
1437
|
+
border: 1px solid var(--color-hairline);
|
|
1438
|
+
background: var(--color-panel-solid);
|
|
1439
|
+
color: var(--color-ink, inherit);
|
|
1440
|
+
}
|
|
1441
|
+
.ui-accordion__trigger {
|
|
1442
|
+
display: flex; flex-wrap: wrap; align-items: center; column-gap: 12px; row-gap: 4px;
|
|
1443
|
+
padding: 16px; cursor: pointer; list-style: none; border-radius: inherit;
|
|
1444
|
+
}
|
|
1445
|
+
.ui-accordion__trigger::-webkit-details-marker { display: none; }
|
|
1446
|
+
.ui-accordion__trigger::marker { content: ""; }
|
|
1447
|
+
.ui-accordion__trigger:focus-visible { outline: 2px solid var(--focus-8, var(--accent-8)); outline-offset: 2px; }
|
|
1448
|
+
.ui-accordion__chevron { display: inline-flex; margin-inline-start: auto; transition: transform 150ms ease; }
|
|
1449
|
+
.ui-accordion__item[open] > .ui-accordion__trigger .ui-accordion__chevron { transform: rotate(180deg); }
|
|
1450
|
+
.ui-accordion__content { border-top: 1px solid var(--color-hairline); padding: 16px; }
|
|
1451
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1452
|
+
.ui-accordion__chevron { transition: none; }
|
|
1453
|
+
.ui-accordion__item[open] > .ui-accordion__trigger .ui-accordion__chevron { transform: none; }
|
|
1454
|
+
}
|