@kennycorrea/ui 0.11.0 → 0.11.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 CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.11.1 — 2026-10-19
4
+
5
+ - **`ScrollStrip`** (axe `scrollable-region-focusable`) — now `role="region"` with an accessible name (`aria-label`, default `UiStrings.scrollableRegion` "Scrollable content"). It is a tab stop (`tabIndex=0`) only while its content overflows (ResizeObserver), with a `:focus-visible` ring; ArrowLeft/Right scroll about one item, Home/End jump. Layout, snap and `-mx-4` usage unchanged.
6
+ - **`SegmentedControl`** (axe `target-size`, WCAG 2.5.8) — items no longer shrink below their content, so five items on a 375px screen scroll the track horizontally instead of dropping under 24px; each item has a 24px minimum width, and on `(pointer: coarse)` a 44px hit area via `::before` (visual size unchanged). Labels now sit in a `.ui-segmented__label` span. Arrow-key radio behaviour unchanged.
7
+ - **`ChipGroup`** — 44px hit area on coarse pointers via `::before`. `TabBar` already met both points (pressed state via `aria-current`, 44px items).
8
+ - New string: `UiStrings.scrollableRegion`. New dev dependency: `axe-core` (tests).
9
+
3
10
  ## 0.11.0 — 2026-10-19
4
11
 
5
12
  - **Touch feedback** — Button, list rows, `StatTile` and `TabBar` items share a pressed state (`:active` scale `--press-scale` 0.97 + tint, `--press-duration` 120 ms); hover styles are in `@media (hover: hover)`. TabBar no longer dims on press.
package/README.md CHANGED
@@ -85,7 +85,7 @@ Do the same in your own code: set appearance on `<Theme>`, not with a `dark` cla
85
85
  ## Strings
86
86
 
87
87
  Components never contain literal text. The handful they need — a spinner's label, a select's
88
- placeholder, close/expand/collapse names — come from `UiStringsProvider`, which takes a partial
88
+ placeholder, close/expand/collapse names, the `ScrollStrip` region name (`scrollableRegion`) — come from `UiStringsProvider`, which takes a partial
89
89
  `UiStrings` object. Defaults are English. Most components also accept the label as a prop.
90
90
 
91
91
  ## Mobile presentation layer
@@ -279,3 +279,9 @@ under Node's ESM resolution as well as bundlers.
279
279
 
280
280
  Publishing happens from the GitHub workflow on a release; `prepublishOnly` rebuilds `dist`.
281
281
  Changes are listed in [CHANGELOG.md](CHANGELOG.md).
282
+
283
+ ## Accessibility notes (mobile)
284
+
285
+ - **ScrollStrip** is a named `region` (`aria-label`, default `UiStrings.scrollableRegion`) and a tab stop only while it overflows; arrow keys scroll it by about one item.
286
+ - **SegmentedControl** scrolls horizontally when its items do not fit rather than shrinking them below 24px; on coarse pointers hit areas are 44px. With more than 4 options on a phone, prefer `ChipGroup`.
287
+ - **ChipGroup** chips are `aria-pressed` buttons with a 44px coarse-pointer hit area.
@@ -1,4 +1,4 @@
1
- import type { HTMLAttributes } from 'react';
1
+ import { type HTMLAttributes } from 'react';
2
2
  export type ScrollStripBreakpoint = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
3
3
  export interface ScrollStripProps extends HTMLAttributes<HTMLDivElement> {
4
4
  /** From this breakpoint the strip stops scrolling and lays its children out as a grid. */
@@ -13,5 +13,9 @@ export interface ScrollStripProps extends HTMLAttributes<HTMLDivElement> {
13
13
  * the scroll padding equals the page gutter (`--page-gutter`, 16px unless the app sets it) so a
14
14
  * snapped item lines up with the content above it, and the scrollbar is hidden. Items default to
15
15
  * 78% of the strip so the next one shows. Use it for KPI rows and card carousels.
16
+ *
17
+ * Accessibility: it is a `region` named by `aria-label` (default `UiStrings.scrollableRegion`).
18
+ * While its content overflows it is a tab stop, so keyboard users can scroll it: ArrowLeft/Right
19
+ * move by about one item, Home/End jump to the ends. When everything fits it is not tabbable.
16
20
  */
17
- export declare function ScrollStrip({ gridFrom, columns, itemWidth, className, style, ...props }: ScrollStripProps): import("react").JSX.Element;
21
+ export declare function ScrollStrip({ gridFrom, columns, itemWidth, className, style, onKeyDown, ...props }: ScrollStripProps): import("react").JSX.Element;
@@ -1,15 +1,62 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useCallback, useEffect, useRef, useState, } from 'react';
3
+ import { useUiStrings } from '../../strings.js';
2
4
  import { cn } from '../../utils/cn.js';
3
5
  /**
4
6
  * A horizontal scroll-snap row that becomes a grid at a breakpoint. Items snap to the start edge,
5
7
  * the scroll padding equals the page gutter (`--page-gutter`, 16px unless the app sets it) so a
6
8
  * snapped item lines up with the content above it, and the scrollbar is hidden. Items default to
7
9
  * 78% of the strip so the next one shows. Use it for KPI rows and card carousels.
10
+ *
11
+ * Accessibility: it is a `region` named by `aria-label` (default `UiStrings.scrollableRegion`).
12
+ * While its content overflows it is a tab stop, so keyboard users can scroll it: ArrowLeft/Right
13
+ * move by about one item, Home/End jump to the ends. When everything fits it is not tabbable.
8
14
  */
9
- export function ScrollStrip({ gridFrom = 'md', columns = 3, itemWidth, className, style, ...props }) {
10
- return (_jsx("div", { className: cn('ui-strip', className), "data-grid-from": gridFrom, style: {
15
+ export function ScrollStrip({ gridFrom = 'md', columns = 3, itemWidth, className, style, onKeyDown, ...props }) {
16
+ const label = useUiStrings().scrollableRegion;
17
+ const ref = useRef(null);
18
+ const [overflowing, setOverflowing] = useState(false);
19
+ const measure = useCallback(() => {
20
+ const el = ref.current;
21
+ if (el)
22
+ setOverflowing(el.scrollWidth > el.clientWidth + 1);
23
+ }, []);
24
+ useEffect(() => {
25
+ const el = ref.current;
26
+ if (!el)
27
+ return;
28
+ measure();
29
+ if (typeof ResizeObserver === 'undefined')
30
+ return;
31
+ const ro = new ResizeObserver(measure);
32
+ ro.observe(el);
33
+ for (const child of Array.from(el.children))
34
+ ro.observe(child);
35
+ return () => ro.disconnect();
36
+ }, [measure]);
37
+ const handleKeyDown = (e) => {
38
+ onKeyDown?.(e);
39
+ const el = ref.current;
40
+ // Only when the strip itself has focus; arrows inside a focused child belong to the child.
41
+ if (e.defaultPrevented || !el || e.target !== el)
42
+ return;
43
+ const first = el.firstElementChild;
44
+ const step = (first?.offsetWidth ?? el.clientWidth * 0.78) + 12;
45
+ if (e.key === 'ArrowRight')
46
+ el.scrollBy({ left: step });
47
+ else if (e.key === 'ArrowLeft')
48
+ el.scrollBy({ left: -step });
49
+ else if (e.key === 'Home')
50
+ el.scrollTo({ left: 0 });
51
+ else if (e.key === 'End')
52
+ el.scrollTo({ left: el.scrollWidth });
53
+ else
54
+ return;
55
+ e.preventDefault();
56
+ };
57
+ return (_jsx("div", { ref: ref, role: "region", "aria-label": label, tabIndex: overflowing ? 0 : undefined, className: cn('ui-strip', className), "data-grid-from": gridFrom, style: {
11
58
  '--strip-cols': columns,
12
59
  ...(itemWidth ? { '--strip-item': itemWidth } : null),
13
60
  ...style,
14
- }, ...props }));
61
+ }, onKeyDown: handleKeyDown, ...props }));
15
62
  }
@@ -19,5 +19,8 @@ export interface SegmentedControlProps {
19
19
  /**
20
20
  * A pill track with a sliding thumb: one choice from 2–5. Radio semantics with a roving tabindex —
21
21
  * arrow keys move and select (wrapping, skipping disabled), Home/End jump to the ends.
22
+ * Items never shrink below their content, so a narrow container scrolls the track horizontally
23
+ * instead of squeezing segments under 24px (WCAG 2.5.8); on coarse pointers each hit area is 44px.
24
+ * Above 4 options on a phone, ChipGroup is usually the better fit.
22
25
  */
23
26
  export declare function SegmentedControl({ options, value, onValueChange, size, fullWidth, className, ...aria }: SegmentedControlProps): import("react").JSX.Element;
@@ -3,6 +3,9 @@ import { useRef } from 'react';
3
3
  /**
4
4
  * A pill track with a sliding thumb: one choice from 2–5. Radio semantics with a roving tabindex —
5
5
  * arrow keys move and select (wrapping, skipping disabled), Home/End jump to the ends.
6
+ * Items never shrink below their content, so a narrow container scrolls the track horizontally
7
+ * instead of squeezing segments under 24px (WCAG 2.5.8); on coarse pointers each hit area is 44px.
8
+ * Above 4 options on a phone, ChipGroup is usually the better fit.
6
9
  */
7
10
  export function SegmentedControl({ options, value, onValueChange, size = 'md', fullWidth = false, className, ...aria }) {
8
11
  const refs = useRef([]);
@@ -35,7 +38,7 @@ export function SegmentedControl({ options, value, onValueChange, size = 'md', f
35
38
  return;
36
39
  e.preventDefault();
37
40
  };
38
- return (_jsxs("div", { role: "radiogroup", className: ['ui-segmented', className].filter(Boolean).join(' '), "data-size": size, "data-full": fullWidth ? 'true' : undefined, style: { '--seg-count': count, '--seg-index': Math.max(active, 0) }, ...aria, children: [active >= 0 && _jsx("span", { className: "ui-segmented__thumb", "aria-hidden": "true" }), options.map((o, i) => (_jsx("button", { ref: (el) => {
39
- refs.current[i] = el;
40
- }, type: "button", role: "radio", "aria-checked": i === active, disabled: o.disabled, tabIndex: i === tabbable ? 0 : -1, className: "ui-segmented__item", onClick: () => onValueChange?.(o.value), onKeyDown: (e) => onKeyDown(e, i), children: o.label }, o.value)))] }));
41
+ return (_jsx("div", { role: "radiogroup", className: ['ui-segmented', className].filter(Boolean).join(' '), "data-size": size, "data-full": fullWidth ? 'true' : undefined, ...aria, children: _jsxs("div", { className: "ui-segmented__track", style: { '--seg-count': count, '--seg-index': Math.max(active, 0) }, children: [active >= 0 && _jsx("span", { className: "ui-segmented__thumb", "aria-hidden": "true" }), options.map((o, i) => (_jsx("button", { ref: (el) => {
42
+ refs.current[i] = el;
43
+ }, type: "button", role: "radio", "aria-checked": i === active, disabled: o.disabled, tabIndex: i === tabbable ? 0 : -1, className: "ui-segmented__item", onClick: () => onValueChange?.(o.value), onKeyDown: (e) => onKeyDown(e, i), children: _jsx("span", { className: "ui-segmented__label", children: o.label }) }, o.value)))] }) }));
41
44
  }
package/dist/strings.d.ts CHANGED
@@ -44,6 +44,8 @@ export interface UiStrings {
44
44
  back: string;
45
45
  /** PullToRefresh: announced while a refresh is in progress. */
46
46
  refreshing: string;
47
+ /** ScrollStrip: the accessible name of the scrollable region, when none is passed. */
48
+ scrollableRegion: string;
47
49
  }
48
50
  /**
49
51
  * Feeds the atoms their strings, once, near the root.
package/dist/strings.js CHANGED
@@ -19,6 +19,7 @@ const DEFAULTS = {
19
19
  confirm: 'Confirm',
20
20
  back: 'Back',
21
21
  refreshing: 'Refreshing',
22
+ scrollableRegion: 'Scrollable content',
22
23
  };
23
24
  const StringsContext = createContext(DEFAULTS);
24
25
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kennycorrea/ui",
3
- "version": "0.11.0",
3
+ "version": "0.11.1",
4
4
  "description": "A React + TypeScript component library built on Radix Themes: atoms, a Tailwind token bridge and theming.",
5
5
  "private": false,
6
6
  "type": "module",
@@ -55,6 +55,7 @@
55
55
  "@types/node": "^24.19.2",
56
56
  "@types/react": "^19.2.18",
57
57
  "@types/react-dom": "^19.2.7",
58
+ "axe-core": "^4.10.2",
58
59
  "jsdom": "^30.1.2",
59
60
  "react": "^19.2.8",
60
61
  "react-dom": "^19.2.8",
package/tokens.css CHANGED
@@ -826,21 +826,28 @@
826
826
  @media (prefers-reduced-motion: reduce) { .ui-navbar, .ui-navbar__title, .ui-backbutton { transition: none; } }
827
827
 
828
828
  /* SegmentedControl */
829
- .ui-segmented { --seg-pad: 2px; position: relative; display: inline-grid; grid-auto-flow: column; grid-auto-columns: 1fr;
829
+ .ui-segmented { --seg-pad: 2px; position: relative; display: inline-flex; max-width: 100%; vertical-align: top; overflow-x: auto; overscroll-behavior-x: contain; scrollbar-width: none;
830
830
  padding: var(--seg-pad); box-sizing: border-box; border-radius: 999px; background: var(--gray-a4); }
831
- .ui-segmented[data-full="true"] { display: grid; width: 100%; }
832
- .ui-segmented__thumb { position: absolute; top: var(--seg-pad); bottom: var(--seg-pad); left: var(--seg-pad);
833
- width: calc((100% - 2 * var(--seg-pad)) / var(--seg-count)); border-radius: 999px; background: var(--color-panel-solid, #fff);
831
+ .ui-segmented::-webkit-scrollbar { display: none; }
832
+ .ui-segmented[data-full="true"] { display: flex; width: 100%; }
833
+ .ui-segmented__track { position: relative; display: grid; grid-auto-flow: column; grid-auto-columns: minmax(max-content, 1fr); flex: 1 0 auto; min-width: 0; }
834
+ .ui-segmented__thumb { position: absolute; top: 0; bottom: 0; left: 0;
835
+ width: calc(100% / var(--seg-count)); border-radius: 999px; background: var(--color-panel-solid, #fff);
834
836
  box-shadow: 0 1px 3px var(--black-a4), 0 0 0 0.5px var(--black-a3);
835
837
  transform: translateX(calc(var(--seg-index) * 100%)); transition: transform 220ms cubic-bezier(0.3, 0.7, 0.2, 1); }
836
- .ui-segmented__item { position: relative; z-index: 1; min-width: 0; border: 0; background: none; font: inherit; font-weight: 500;
837
- color: var(--gray-11); border-radius: 999px; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
838
+ .ui-segmented__item { position: relative; z-index: 1; min-width: 24px; border: 0; background: none; font: inherit; font-weight: 500;
839
+ color: var(--gray-11); border-radius: 999px; cursor: pointer; white-space: nowrap;
838
840
  -webkit-tap-highlight-color: transparent; touch-action: manipulation; transition: color 150ms; }
841
+ .ui-segmented__label { display: block; overflow: hidden; text-overflow: ellipsis; }
839
842
  .ui-segmented[data-size="md"] .ui-segmented__item { min-height: 32px; padding: 0 16px; font-size: 14px; }
840
843
  .ui-segmented[data-size="sm"] .ui-segmented__item { min-height: 24px; padding: 0 12px; font-size: 12px; }
841
844
  .ui-segmented__item[aria-checked="true"] { color: var(--gray-12); }
842
845
  .ui-segmented__item:disabled { opacity: 0.4; cursor: not-allowed; }
843
846
  .ui-segmented__item:focus-visible { outline: 2px solid var(--focus-8); outline-offset: 1px; }
847
+ /* Coarse pointers: grow the hit area to 44px without growing the visual. */
848
+ @media (pointer: coarse) {
849
+ .ui-segmented__item::before, .ui-chips__chip::before { content: ''; position: absolute; top: 50%; left: 0; right: 0; height: max(100%, var(--tap-min, 44px)); transform: translateY(-50%); }
850
+ }
844
851
  @media (prefers-reduced-motion: reduce) { .ui-segmented__thumb, .ui-segmented__item { transition: none; } }
845
852
 
846
853
  /* ChipGroup */
@@ -851,7 +858,7 @@
851
858
  .ui-chips[data-fade-start="true"] .ui-chips__scroller { -webkit-mask-image: linear-gradient(to right, transparent, #000 var(--chips-fade)); mask-image: linear-gradient(to right, transparent, #000 var(--chips-fade)); }
852
859
  .ui-chips[data-fade-end="true"] .ui-chips__scroller { -webkit-mask-image: linear-gradient(to left, transparent, #000 var(--chips-fade)); mask-image: linear-gradient(to left, transparent, #000 var(--chips-fade)); }
853
860
  .ui-chips[data-fade-start="true"][data-fade-end="true"] .ui-chips__scroller { -webkit-mask-image: linear-gradient(to right, transparent, #000 var(--chips-fade), #000 calc(100% - var(--chips-fade)), transparent); mask-image: linear-gradient(to right, transparent, #000 var(--chips-fade), #000 calc(100% - var(--chips-fade)), transparent); }
854
- .ui-chips__chip { flex: none; scroll-snap-align: start; scroll-margin-inline: var(--chips-fade); min-height: 32px; padding: 0 14px;
861
+ .ui-chips__chip { position: relative; flex: none; scroll-snap-align: start; scroll-margin-inline: var(--chips-fade); min-height: 32px; padding: 0 14px;
855
862
  border: 0; border-radius: 999px; background: var(--gray-a4); color: var(--gray-12); font: inherit; font-size: 14px; font-weight: 500;
856
863
  white-space: nowrap; cursor: pointer; -webkit-tap-highlight-color: transparent; touch-action: manipulation; transition: background-color 120ms, color 120ms; }
857
864
  .ui-chips__chip[data-selected="true"] { background: var(--accent-9); color: var(--accent-contrast); }
@@ -881,6 +888,7 @@
881
888
  /* ---- ScrollStrip ---- */
882
889
  .ui-strip { --strip-item: 78%; display: flex; gap: 12px; overflow-x: auto; overscroll-behavior-x: contain; scroll-snap-type: x mandatory; padding-inline: var(--page-gutter, 16px); scroll-padding-inline: var(--page-gutter, 16px); scrollbar-width: none; -webkit-overflow-scrolling: touch; }
883
890
  .ui-strip::-webkit-scrollbar { display: none; }
891
+ .ui-strip:focus-visible { outline: 2px solid var(--focus-8); outline-offset: -2px; border-radius: 8px; }
884
892
  .ui-strip > * { flex: 0 0 var(--strip-item); min-width: 0; scroll-snap-align: start; }
885
893
  @media (prefers-reduced-motion: no-preference) { .ui-strip { scroll-behavior: smooth; } }
886
894
  @media (min-width: 520px) { .ui-strip[data-grid-from="xs"] { display: grid; grid-template-columns: repeat(var(--strip-cols), minmax(0, 1fr)); overflow: visible; scroll-snap-type: none; padding-inline: 0; } .ui-strip[data-grid-from="xs"] > * { scroll-snap-align: none; } }