@scalewing/react 1.18.0 → 1.20.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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @scalewing/react
2
2
 
3
+ ## 1.20.0
4
+
5
+ ### Minor Changes
6
+
7
+ - cfcac1a: `Tooltip` `relationship` (`docs/requests/teisoro-tooltip-labels.md`, Teisoro's workspace navigation). `relationship?: 'description' | 'label'`, default `'description'`, and the exported type `TooltipRelationship`. `'label'` makes the tooltip its trigger's accessible name: the trigger gets `aria-labelledby` with the tooltip's id (after its own, if any) instead of `aria-describedby`, so an icon-only control's name is read once rather than as both name and description. The tooltip stays in the page, hidden, so it names the trigger while not shown. With `disabled` there is no tooltip and no reference to it; the trigger then needs its own name (visible text or `aria-label`). Without `relationship` the markup and behavior are unchanged. No new classes, tokens or dependencies.
8
+
9
+ ## 1.19.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 596b74b: `Tooltip` `disabled` (`docs/requests/teisoro-tooltip-disabled.md`, Teisoro's workspace navigation). `disabled?: boolean` turns the tooltip off, not its trigger, which stays mounted, enabled and focusable: the tooltip element is not rendered, the trigger keeps only its own `aria-describedby`, and Escape reaches the page. Focus, hover and touch are still followed, so a trigger that still has focus, the pointer or an open touch toggle shows the tooltip as soon as it is enabled again. `disabled` must match between the server render and the first client render. A product toggles it at a breakpoint instead of rendering the trigger with and without a `Tooltip`, which mounted a new trigger and lost its focus. Without `disabled` the markup and behavior are unchanged. No new classes, tokens or dependencies.
14
+
3
15
  ## 1.18.0
4
16
 
5
17
  ### Minor Changes
package/README.md CHANGED
@@ -247,7 +247,7 @@ An option's optional `description` (a `ReactNode`) is secondary text for that op
247
247
 
248
248
  `Progress` shows a known value between zero and a positive maximum. It uses native progressbar semantics and displays the value and maximum beside the localized label. Invalid bounds throw instead of silently clamping. Optional `tone` is `accent`, `success`, or `danger`. `showCount={false}` hides the visible `value / max` count when the page shows its own count caption, so the count appears once; the progress bar still exposes its value and maximum.
249
249
 
250
- `Tooltip` adds supplementary plain-text help to one labelled, focusable trigger. Supply localized `content` and an existing trigger element with its own accessible name. It opens on hover or focus, closes on pointer leave, blur, Escape, or outside touch, and toggles on touch. Keep required instructions visible outside the tooltip.
250
+ `Tooltip` adds supplementary plain-text help to one labelled, focusable trigger, or names an icon-only one (`relationship="label"`, below). Supply localized `content` and an existing trigger element; by default it has its own accessible name. It opens on hover or focus, closes on pointer leave, blur, Escape, or outside touch, and toggles on touch. Keep required instructions visible outside the tooltip. `disabled` turns the tooltip off, not its trigger: the trigger stays mounted, enabled and focusable, and there is no tooltip, no `aria-describedby` from it, and Escape is left to the page. Use it when what the tooltip says is visible at some widths, such as text shown only from `md` up, instead of rendering the trigger with and without a `Tooltip`, which mounts a new trigger and drops its focus. Focus, hover and touch are still followed, so a trigger that still has focus, the pointer or an open touch toggle shows the tooltip as soon as it is enabled again (Escape pressed while disabled reaches the page and does not stop that). `disabled` must be the same in the server render and the first client render, or hydration mismatches; read a breakpoint through a hydration-safe store, such as `useSyncExternalStore` with a server snapshot. `relationship="label"` makes the tooltip its trigger's name instead of its description, for an icon-only control whose tooltip says what it is: the trigger gets `aria-labelledby` (after its own, if any) and no `aria-describedby` from the tooltip, so the text is read once. The tooltip stays in the page, hidden, so it names the trigger while it is not shown, and it wins over an `aria-label`. While `disabled` there is no tooltip to name the trigger, so give the trigger its own name for that state: visible text, such as a label shown only from `md` up, or an `aria-label`. Do not use `relationship="label"` while the trigger shows visible text that differs from `content`: the tooltip would replace that visible label as the name (WCAG 2.5.3, label in name). The default, `relationship="description"`, is the supplementary help above. `relationship` must also be the same in the server render and the first client render.
251
251
 
252
252
  `Separator` divides sections with a token-colored line. It is horizontal by default; use `orientation="vertical"` in a flex row. Set `decorative` when the line only supports layout so assistive technology ignores it.
253
253
 
@@ -1,8 +1,31 @@
1
1
  import { type ReactElement } from 'react';
2
+ import { type TooltipRelationship, type TooltipTriggerAria } from './tooltip-trigger-aria.js';
3
+ export type { TooltipRelationship };
2
4
  export type TooltipProps = {
3
5
  content: string;
4
- trigger: ReactElement<{
5
- 'aria-describedby'?: string;
6
- }>;
6
+ /**
7
+ * Turns the tooltip off, not its trigger: no tooltip, no description and no
8
+ * Escape handling, while the trigger stays mounted, enabled and focusable
9
+ * (and keeps its focus). Focus, hover and touch are still followed, so the
10
+ * tooltip shows as soon as it is enabled again on a trigger that still has
11
+ * focus, the pointer or an open touch toggle; Escape pressed while disabled
12
+ * reaches the page and does not stop that. It must match between the server
13
+ * render and the first client render.
14
+ */
15
+ disabled?: boolean;
16
+ /**
17
+ * `'description'` (default) adds supplementary help to a trigger that has
18
+ * its own name, through `aria-describedby`. `'label'` makes `content` the
19
+ * trigger's accessible name, through `aria-labelledby`, for an icon-only
20
+ * control: the text is read once, as its name. The tooltip stays in the
21
+ * DOM, hidden, so it names the trigger while it is not shown. While
22
+ * `disabled` there is no tooltip to name it, so the trigger then needs its
23
+ * own name: visible text, or an `aria-label` (which the tooltip overrides
24
+ * while enabled). The trigger's own `aria-labelledby` and `aria-describedby`
25
+ * are kept. It must match between the server render and the first client
26
+ * render.
27
+ */
28
+ relationship?: TooltipRelationship;
29
+ trigger: ReactElement<TooltipTriggerAria>;
7
30
  };
8
- export declare function Tooltip({ content, trigger }: TooltipProps): import("react").JSX.Element;
31
+ export declare function Tooltip({ content, disabled, relationship, trigger, }: TooltipProps): import("react").JSX.Element;
@@ -1,15 +1,17 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { cloneElement, useEffect, useId, useRef, useState, } from 'react';
4
- export function Tooltip({ content, trigger }) {
4
+ import { tooltipTriggerAria, } from './tooltip-trigger-aria.js';
5
+ export function Tooltip({ content, disabled = false, relationship = 'description', trigger, }) {
5
6
  if (!content.trim())
6
7
  throw new RangeError('content must not be empty');
8
+ // `open` follows focus, hover and touch even while disabled; `shown` is
9
+ // what the page gets.
7
10
  const [open, setOpen] = useState(false);
11
+ const shown = open && !disabled;
8
12
  const id = useId();
9
13
  const wrapperRef = useRef(null);
10
- const describedBy = [trigger.props['aria-describedby'], id]
11
- .filter(Boolean)
12
- .join(' ');
14
+ const triggerAria = tooltipTriggerAria(trigger.props, disabled ? undefined : id, relationship);
13
15
  useEffect(() => {
14
16
  if (!open)
15
17
  return;
@@ -27,7 +29,7 @@ export function Tooltip({ content, trigger }) {
27
29
  }, onFocusCapture: () => setOpen(true), onKeyDownCapture: (event) => {
28
30
  // Escape hides a visible tooltip and nothing else, so a surrounding
29
31
  // Dialog does not also close. A hidden tooltip leaves Escape alone.
30
- if (event.key !== 'Escape' || !open)
32
+ if (event.key !== 'Escape' || !shown)
31
33
  return;
32
34
  event.preventDefault();
33
35
  setOpen(false);
@@ -40,5 +42,5 @@ export function Tooltip({ content, trigger }) {
40
42
  }, onPointerDownCapture: (event) => {
41
43
  if (event.pointerType === 'touch')
42
44
  setOpen((current) => !current);
43
- }, ref: wrapperRef, children: [cloneElement(trigger, { 'aria-describedby': describedBy }), _jsx("span", { className: "sw-tooltip", hidden: !open, id: id, role: "tooltip", children: content })] }));
45
+ }, ref: wrapperRef, children: [cloneElement(trigger, triggerAria), disabled ? null : (_jsx("span", { className: "sw-tooltip", hidden: !shown, id: id, role: "tooltip", children: content }))] }));
44
46
  }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * What a tooltip is to its trigger. A `description` supplements a trigger
3
+ * that already has a name; a `label` is the trigger's name, for an icon-only
4
+ * control whose tooltip says what it is.
5
+ */
6
+ export type TooltipRelationship = 'description' | 'label';
7
+ /** The trigger's own references, kept and joined with the tooltip's id. */
8
+ export type TooltipTriggerAria = {
9
+ 'aria-describedby'?: string;
10
+ 'aria-labelledby'?: string;
11
+ };
12
+ export declare function tooltipTriggerAria(own: TooltipTriggerAria, tooltipId: string | undefined, relationship: TooltipRelationship): TooltipTriggerAria;
@@ -0,0 +1,23 @@
1
+ /*
2
+ * A description tooltip adds its id to the trigger's own `aria-describedby`
3
+ * and leaves its `aria-labelledby` exactly as it was. A label tooltip adds
4
+ * its id to the trigger's own `aria-labelledby` instead and leaves its
5
+ * `aria-describedby` as it was, so its text is the trigger's name (it wins
6
+ * over an `aria-label`) and is read once, not as both name and description.
7
+ * A disabled tooltip is not rendered (`tooltipId` undefined), so the trigger
8
+ * never points at a missing element.
9
+ */
10
+ export function tooltipTriggerAria(own, tooltipId, relationship) {
11
+ if (relationship === 'label')
12
+ return {
13
+ 'aria-describedby': own['aria-describedby'],
14
+ 'aria-labelledby': joinIdRefs(own['aria-labelledby'], tooltipId),
15
+ };
16
+ return {
17
+ 'aria-describedby': joinIdRefs(own['aria-describedby'], tooltipId),
18
+ 'aria-labelledby': own['aria-labelledby'],
19
+ };
20
+ }
21
+ function joinIdRefs(...ids) {
22
+ return ids.filter(Boolean).join(' ') || undefined;
23
+ }
package/dist/index.d.ts CHANGED
@@ -8,7 +8,7 @@ export { CalendarButton, type CalendarButtonLabels, type CalendarButtonProps, }
8
8
  export { Checkbox, type CheckboxProps } from './components/Checkbox.js';
9
9
  export { Spinner, type SpinnerProps, type SpinnerSize, } from './components/Spinner.js';
10
10
  export { Progress, type ProgressProps, type ProgressTone, } from './components/Progress.js';
11
- export { Tooltip, type TooltipProps } from './components/Tooltip.js';
11
+ export { Tooltip, type TooltipProps, type TooltipRelationship, } from './components/Tooltip.js';
12
12
  export { RadioGroup, type RadioGroupOption, type RadioGroupProps, } from './components/RadioGroup.js';
13
13
  export { AppHeader, type AppHeaderProps } from './components/AppHeader.js';
14
14
  export { Badge, type BadgeProps, type BadgeSize, type BadgeTone, badgeSizes, badgeTones, } from './components/Badge.js';
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@ export { CalendarButton, } from './components/CalendarButton.js';
7
7
  export { Checkbox } from './components/Checkbox.js';
8
8
  export { Spinner, } from './components/Spinner.js';
9
9
  export { Progress, } from './components/Progress.js';
10
- export { Tooltip } from './components/Tooltip.js';
10
+ export { Tooltip, } from './components/Tooltip.js';
11
11
  export { RadioGroup, } from './components/RadioGroup.js';
12
12
  export { AppHeader } from './components/AppHeader.js';
13
13
  export { Badge, badgeSizes, badgeTones, } from './components/Badge.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scalewing/react",
3
- "version": "1.18.0",
3
+ "version": "1.20.0",
4
4
  "description": "Scalewing DOM components and package-owned CSS utilities.",
5
5
  "type": "module",
6
6
  "license": "MIT",