@scalewing/react 1.24.0 → 1.26.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,31 @@
1
1
  # @scalewing/react
2
2
 
3
+ ## 1.26.0
4
+
5
+ ### Minor Changes
6
+
7
+ - e484799: `DescriptionList` and `DescriptionItem` (with `DescriptionListProps` and `DescriptionItemProps`): terms paired with what each says, one per row (`docs/requests/teisoro-description-list.md`, Teisoro F-006-S03).
8
+
9
+ - A `dl` of items, each a `div` grouping a `dt` (`term`) with its `dd` (`children`); the list and each item take their own HTML attributes and `className`.
10
+ - From `md` up the items share the list's two columns (`subgrid`): the term column as wide as the widest term, up to 40% of the list, then the detail. Every row aligns to its top, a hairline (`--sw-color-border`, `CanvasText` in forced colors) divides the rows, and the list is flush above its first row and below its last.
11
+ - Below `md` each term sits over its detail.
12
+ - A term and a detail are columns of lines: each child is its own line with its own line height, so a caption term starts level with a caption detail; each is as wide as its content unless it sets its own width (a `Progress` fills the detail), and long unbreakable words wrap. Wrap inline content in one element.
13
+ - New generated classes `sw-description-list`, `sw-description-item`, `sw-description-term` and `sw-description-detail`. No new tokens or dependencies.
14
+
15
+ ## 1.25.0
16
+
17
+ ### Minor Changes
18
+
19
+ - 34131f3: `InfoTip` and `InfoTipProps`: a toggletip, a ghost icon-only `Button` whose only job is to show its tip (`docs/requests/teisoro-tooltip.md`, 2026-10-09 follow-up, Teisoro F-006-S11). Since 1.22.0 a tap on a `Button` presses it and never opens its `Tooltip`, so an ⓘ beside a figure could not show its help on a touch screen.
20
+
21
+ - `label` (the button's name), `content` (the tip, plain text), `children` (the decorative glyph), `size` (a Button size; `md` by default, and 44 px on a coarse pointer at every size).
22
+ - Hover and a visible focus show the tip as `Tooltip` does. A press (a tap, a click, Enter or Space) shows it and keeps it shown when the pointer leaves; the next press, Escape, blur or a press outside hides it.
23
+ - The tip is the button's description (`aria-describedby`). A press also puts it in a visually hidden polite live region (`role="status"`), so a screen reader user who presses the button hears it. Every InfoTip renders that `role="status"` element, empty until a press, and while a press holds the tip open its text is in the page twice (the bubble and the region): a test that finds a `status` or the tip's text should scope its query.
24
+ - `type="button"`: it never submits a form. An empty `label` or `content` throws a `RangeError`.
25
+ - New generated class `sw-info-tip` (the square icon button, shared with `sw-calendar-button`, whose rules are equivalent: same declarations and specificity, one block reordered).
26
+
27
+ `Tooltip`'s behavior is unchanged for every other trigger: a tap on a control still does what the control does and leaves its tooltip closed. No new tokens or dependencies.
28
+
3
29
  ## 1.24.0
4
30
 
5
31
  ### Minor Changes
package/README.md CHANGED
@@ -20,7 +20,10 @@ import {
20
20
  Spinner,
21
21
  Progress,
22
22
  Tooltip,
23
+ InfoTip,
23
24
  Separator,
25
+ DescriptionList,
26
+ DescriptionItem,
24
27
  DenominationGrid,
25
28
  FilterChips,
26
29
  Grid,
@@ -291,10 +294,45 @@ A header `TableCell` (`as="th"`) with `sort` (`'ascending'`, `'descending'` or `
291
294
 
292
295
  `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.
293
296
 
294
- `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 on a visible focus (`:focus-visible`: the keyboard, not the focus a click or a tap gives), closes on pointer leave, blur, Escape, or outside touch, and a tap toggles it only on a trigger that does nothing else on a tap, such as a focusable badge; a tap on a control (a button, a link, a form control or its label, an editable region, an element with a widget role, or anything inside one) does what the control does. The bubble is placed beside its trigger on the top layer while it shows and stays a `space-2` inset inside the screen. 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.
297
+ `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 on a visible focus (`:focus-visible`: the keyboard, not the focus a click or a tap gives), closes on pointer leave, blur, Escape, or outside touch, and a tap on a control (a button, a link, a form control or its label, an editable region, an element with a widget role, or anything inside one) does what the control does and leaves the tooltip closed, so on a touch screen a `Tooltip` on a `Button` is never shown. For an icon-only hint whose button does nothing but show it, such as an ⓘ beside a figure, use `InfoTip` (below), which a tap, a click, Enter or Space opens; do not make a non-control focusable to get a tap. A tap still toggles a `Tooltip` whose trigger does nothing else on a tap, such as an existing focusable badge. The bubble is placed beside its trigger on the top layer while it shows and stays a `space-2` inset inside the screen. 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.
298
+
299
+ `InfoTip` is a toggletip: a ghost icon-only `Button` whose only job is to show its tip, built on `Tooltip`'s bubble and placement. `label` is the button's accessible name and `content` its tip, plain text as `Tooltip`'s, both localized by the consumer; `children` is the decorative glyph (a Lucide `Info` with `aria-hidden`); `size` is a Button size, and the default `md` and every size on a coarse pointer are a square 44 px target. Hover and a visible focus show the tip as `Tooltip` does. A press of the button (a tap, a click, Enter or Space) shows it and keeps it shown when the pointer leaves, and the next press hides it; Escape (taken only while the tip shows), blur and a press outside also hide it. The tip is the button's description (`aria-describedby`), read once on focus; a press also puts it in a visually hidden polite live region (`role="status"`, empty until a press opens the tip and emptied when it closes), so a screen reader user who presses the button hears it even with descriptions or hints turned off. The button is `type="button"` and never submits a form, and the ref reaches it. An empty `label` or `content` throws a `RangeError`. There is no `disabled`: where the tip is shown as text instead, render no `InfoTip`.
300
+
301
+ ```tsx
302
+ <Inline gap={1}>
303
+ <Text>Expected waders: 128</Text>
304
+ <InfoTip
305
+ content="Counted at the last high tide, before the hides opened"
306
+ label="About the expected waders"
307
+ >
308
+ <Info aria-hidden size={16} />
309
+ </InfoTip>
310
+ </Inline>
311
+ ```
295
312
 
296
313
  `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.
297
314
 
315
+ `DescriptionList` pairs terms with what each says, one `DescriptionItem` per row: a `dl` whose items each group a `dt` (`term`) with its `dd` (`children`). From `md` up the term sits in a start column as wide as the widest term, up to 40% of the list, and the detail beside it; every row aligns to its top, so a term and its detail's first line start level, and a hairline divides the rows, with no space above the first row or below the last. Below `md` each term sits over its detail. The list sets only the layout: pass your own `Text` for the term and the detail. Each child of a term or a detail is its own line with its own line height (a column), as wide as its content unless it sets its own width (a `Progress` fills the detail; a `Field` keeps its input's width): wrap inline content, such as words and a link on one line, in one element such as an `Inline`. Long unbreakable words wrap inside their column. A `dl` has no ARIA role, so name a list with a visible heading, and `aria-labelledby` on the `section` around it, rather than an `aria-label` on the list.
316
+
317
+ ```tsx
318
+ <DescriptionList>
319
+ <DescriptionItem
320
+ term={
321
+ <Text variant="caption" color="muted">
322
+ Wetland
323
+ </Text>
324
+ }
325
+ >
326
+ <Inline align="start" gap={2} wrap>
327
+ <Text variant="data">12 herons</Text>
328
+ <Text variant="caption" color="muted">
329
+ Recounted
330
+ </Text>
331
+ </Inline>
332
+ </DescriptionItem>
333
+ </DescriptionList>
334
+ ```
335
+
298
336
  `Card` defaults to `glass`; `outlined` and `elevated` are solid surfaces, and `filled` is a quiet `--sw-color-subtle` fill with no border for plain information apart from pressable rows.
299
337
 
300
338
  `--sw-color-accentSubtle` is the accent tint from `@scalewing/tokens`: a quiet fill behind accent text or glyphs that keeps 4.5:1. The stylesheet and every palette file set it, and a palette that moves the accent or surface retints it.
@@ -0,0 +1,28 @@
1
+ import { type HTMLAttributes, type ReactNode } from 'react';
2
+ export type DescriptionListProps = Omit<HTMLAttributes<HTMLDListElement>, 'children'> & {
3
+ /** The list's `DescriptionItem`s. */
4
+ children: ReactNode;
5
+ };
6
+ /**
7
+ * Terms and what each one says, one item per row: the term in a start
8
+ * column as wide as the widest term (up to 40% of the list), the detail
9
+ * beside it, a hairline between rows, every row aligned to its top. Below
10
+ * the layout breakpoint (`md`) each term sits over its detail.
11
+ */
12
+ export declare function DescriptionList({ children, className, ...rest }: DescriptionListProps): import("react").JSX.Element;
13
+ export type DescriptionItemProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
14
+ /**
15
+ * What the row describes, such as a name; the consumer's own `Text`. Each
16
+ * child of the term, and of the detail, is its own line.
17
+ */
18
+ term: ReactNode;
19
+ /**
20
+ * What the term says: text, a link, or several lines. Each child is its
21
+ * own line, as wide as its content unless it sets its own width (a
22
+ * `Progress` fills the detail); wrap words and a link that share a line
23
+ * in one element such as `Inline`.
24
+ */
25
+ children: ReactNode;
26
+ };
27
+ /** One row of a `DescriptionList`: a `dt` and its `dd` in one group. */
28
+ export declare function DescriptionItem({ term, children, className, ...rest }: DescriptionItemProps): import("react").JSX.Element;
@@ -0,0 +1,15 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cx } from '../class-names.js';
3
+ /**
4
+ * Terms and what each one says, one item per row: the term in a start
5
+ * column as wide as the widest term (up to 40% of the list), the detail
6
+ * beside it, a hairline between rows, every row aligned to its top. Below
7
+ * the layout breakpoint (`md`) each term sits over its detail.
8
+ */
9
+ export function DescriptionList({ children, className, ...rest }) {
10
+ return (_jsx("dl", { className: cx('sw-description-list', className), ...rest, children: children }));
11
+ }
12
+ /** One row of a `DescriptionList`: a `dt` and its `dd` in one group. */
13
+ export function DescriptionItem({ term, children, className, ...rest }) {
14
+ return (_jsxs("div", { className: cx('sw-description-item', className), ...rest, children: [_jsx("dt", { className: "sw-description-term", children: term }), _jsx("dd", { className: "sw-description-detail", children: children })] }));
15
+ }
@@ -0,0 +1,26 @@
1
+ import { type ReactNode } from 'react';
2
+ import { type ButtonSize } from './Button.js';
3
+ export type InfoTipProps = {
4
+ /**
5
+ * The button's accessible name, such as "About the expected balance".
6
+ * Localized by the consumer.
7
+ */
8
+ label: string;
9
+ /**
10
+ * The tip: plain text, as `Tooltip`'s `content`. It is the button's
11
+ * description, and a press also says it once in a polite live region.
12
+ */
13
+ content: string;
14
+ /** The glyph, such as a Lucide `Info`; decorative, the label names it. */
15
+ children: ReactNode;
16
+ /** A Button size; md (default) is 44 px, and a coarse pointer always gets 44 px. */
17
+ size?: ButtonSize;
18
+ };
19
+ /**
20
+ * A toggletip: a ghost icon-only button whose only job is to show its tip.
21
+ * Hover and a visible focus show it as `Tooltip` does; a press (a tap, a
22
+ * click, Enter or Space) shows it and keeps it shown until the next press,
23
+ * Escape, blur or a press outside. The button never submits a form. The ref
24
+ * reaches the `<button>`.
25
+ */
26
+ export declare const InfoTip: import("react").ForwardRefExoticComponent<InfoTipProps & import("react").RefAttributes<HTMLButtonElement>>;
@@ -0,0 +1,19 @@
1
+ 'use client';
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { forwardRef } from 'react';
4
+ import { Button } from './Button.js';
5
+ import { TooltipAnchor } from './tooltip/TooltipAnchor.js';
6
+ /** The press is the anchor's: it toggles the tip on the button's click. */
7
+ function pressShowsTheTip() { }
8
+ /**
9
+ * A toggletip: a ghost icon-only button whose only job is to show its tip.
10
+ * Hover and a visible focus show it as `Tooltip` does; a press (a tap, a
11
+ * click, Enter or Space) shows it and keeps it shown until the next press,
12
+ * Escape, blur or a press outside. The button never submits a form. The ref
13
+ * reaches the `<button>`.
14
+ */
15
+ export const InfoTip = forwardRef(function InfoTip({ children, content, label, size = 'md' }, ref) {
16
+ if (typeof label !== 'string' || label.trim() === '')
17
+ throw new RangeError('label must be non-empty text');
18
+ return (_jsx(TooltipAnchor, { content: content, pressToggles: true, trigger: _jsx(Button, { "aria-label": label, className: "sw-info-tip", onPress: pressShowsTheTip, ref: ref, size: size, type: "button", variant: "ghost", children: children }) }));
19
+ });
@@ -28,4 +28,9 @@ export type TooltipProps = {
28
28
  relationship?: TooltipRelationship;
29
29
  trigger: ReactElement<TooltipTriggerAria>;
30
30
  };
31
+ /**
32
+ * Supplementary help for a trigger that has its own name, or the name of an
33
+ * icon-only trigger (`relationship="label"`). For a glyph whose only job is
34
+ * to show its help, use `InfoTip`, which a press opens.
35
+ */
31
36
  export declare function Tooltip({ content, disabled, relationship, trigger, }: TooltipProps): import("react").JSX.Element;
@@ -1,62 +1,11 @@
1
1
  'use client';
2
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { cloneElement, useEffect, useId, useRef, useState, } from 'react';
4
- import { focusIsVisible, tapActs } from './tooltip/tooltip-open.js';
5
- import { useTooltipPlacement } from './tooltip/use-tooltip-placement.js';
6
- import { tooltipTriggerAria, } from './tooltip-trigger-aria.js';
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { TooltipAnchor } from './tooltip/TooltipAnchor.js';
4
+ /**
5
+ * Supplementary help for a trigger that has its own name, or the name of an
6
+ * icon-only trigger (`relationship="label"`). For a glyph whose only job is
7
+ * to show its help, use `InfoTip`, which a press opens.
8
+ */
7
9
  export function Tooltip({ content, disabled = false, relationship = 'description', trigger, }) {
8
- if (!content.trim())
9
- throw new RangeError('content must not be empty');
10
- // `open` follows focus, hover and touch even while disabled; `shown` is
11
- // what the page gets.
12
- const [open, setOpen] = useState(false);
13
- const shown = open && !disabled;
14
- const id = useId();
15
- const wrapperRef = useRef(null);
16
- const tooltipRef = useRef(null);
17
- useTooltipPlacement(shown, tooltipRef, wrapperRef);
18
- const triggerAria = tooltipTriggerAria(trigger.props, disabled ? undefined : id, relationship);
19
- useEffect(() => {
20
- if (!open)
21
- return;
22
- function dismissOutside(event) {
23
- if (event.target instanceof Node &&
24
- !wrapperRef.current?.contains(event.target))
25
- setOpen(false);
26
- }
27
- document.addEventListener('pointerdown', dismissOutside);
28
- return () => document.removeEventListener('pointerdown', dismissOutside);
29
- }, [open]);
30
- return (_jsxs("span", { className: "sw-tooltip-anchor", onBlurCapture: (event) => {
31
- if (!event.currentTarget.contains(event.relatedTarget))
32
- setOpen(false);
33
- }, onFocusCapture: (event) => {
34
- // A click or a tap focuses the trigger too; only a visible focus
35
- // (the keyboard) opens the tooltip, so a clicked control does not
36
- // keep its help shown until the focus moves.
37
- if (focusIsVisible(event.target))
38
- setOpen(true);
39
- }, onKeyDownCapture: (event) => {
40
- // Escape hides a visible tooltip and nothing else, so a surrounding
41
- // Dialog does not also close. A hidden tooltip leaves Escape alone.
42
- if (event.key !== 'Escape' || !shown)
43
- return;
44
- event.preventDefault();
45
- setOpen(false);
46
- }, onPointerEnter: (event) => {
47
- if (event.pointerType === 'mouse' || event.pointerType === 'pen')
48
- setOpen(true);
49
- }, onPointerLeave: (event) => {
50
- if (event.pointerType === 'mouse' || event.pointerType === 'pen')
51
- setOpen(false);
52
- }, onPointerDownCapture: (event) => {
53
- if (event.pointerType !== 'touch')
54
- return;
55
- // A tap on a control does what the control does; only a trigger
56
- // that does nothing else on a tap toggles its help.
57
- if (tapActs(event.target, event.currentTarget))
58
- setOpen(false);
59
- else
60
- setOpen((current) => !current);
61
- }, ref: wrapperRef, children: [cloneElement(trigger, triggerAria), disabled ? null : (_jsx("span", { className: "sw-tooltip", hidden: !shown, id: id, ref: tooltipRef, role: "tooltip", children: content }))] }));
10
+ return (_jsx(TooltipAnchor, { content: content, disabled: disabled, relationship: relationship, trigger: trigger }));
62
11
  }
@@ -0,0 +1,19 @@
1
+ import { type ReactElement } from 'react';
2
+ import { type TooltipRelationship, type TooltipTriggerAria } from '../tooltip-trigger-aria.js';
3
+ export type TooltipAnchorProps = {
4
+ content: string;
5
+ disabled?: boolean;
6
+ /**
7
+ * Internal, for `InfoTip`: a press of the trigger toggles the tooltip, and
8
+ * the tooltip a press opens is also said once in a polite live region.
9
+ */
10
+ pressToggles?: boolean;
11
+ relationship?: TooltipRelationship;
12
+ trigger: ReactElement<TooltipTriggerAria>;
13
+ };
14
+ /**
15
+ * The anchor `span` around a trigger and its tooltip: the bubble, its
16
+ * reference from the trigger, and what opens and closes it. `Tooltip` and
17
+ * `InfoTip` are this anchor; only `InfoTip` turns `pressToggles` on.
18
+ */
19
+ export declare function TooltipAnchor({ content, disabled, pressToggles, relationship, trigger, }: TooltipAnchorProps): import("react").JSX.Element;
@@ -0,0 +1,27 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { cloneElement, useId, useRef } from 'react';
4
+ import { tooltipTriggerAria, } from '../tooltip-trigger-aria.js';
5
+ import { useTooltipOpen } from './use-tooltip-open.js';
6
+ import { useTooltipPlacement } from './use-tooltip-placement.js';
7
+ /**
8
+ * The anchor `span` around a trigger and its tooltip: the bubble, its
9
+ * reference from the trigger, and what opens and closes it. `Tooltip` and
10
+ * `InfoTip` are this anchor; only `InfoTip` turns `pressToggles` on.
11
+ */
12
+ export function TooltipAnchor({ content, disabled = false, pressToggles = false, relationship = 'description', trigger, }) {
13
+ if (!content.trim())
14
+ throw new RangeError('content must not be empty');
15
+ const id = useId();
16
+ const wrapperRef = useRef(null);
17
+ const tooltipRef = useRef(null);
18
+ const { handlers, pressed, shown } = useTooltipOpen({
19
+ disabled,
20
+ pressToggles,
21
+ tooltipRef,
22
+ wrapperRef,
23
+ });
24
+ useTooltipPlacement(shown, tooltipRef, wrapperRef);
25
+ const triggerAria = tooltipTriggerAria(trigger.props, disabled ? undefined : id, relationship);
26
+ return (_jsxs("span", { className: "sw-tooltip-anchor", ref: wrapperRef, ...handlers, children: [cloneElement(trigger, triggerAria), disabled ? null : (_jsx("span", { className: "sw-tooltip", hidden: !shown, id: id, ref: tooltipRef, role: "tooltip", children: content })), pressToggles ? (_jsx("span", { className: "sw-sr-only", role: "status", children: pressed && shown ? content : null })) : null] }));
27
+ }
@@ -0,0 +1,33 @@
1
+ import { type FocusEvent, type KeyboardEvent, type MouseEvent, type PointerEvent as ReactPointerEvent, type RefObject } from 'react';
2
+ export type TooltipOpenOptions = {
3
+ /** Whether the tooltip is enabled: only a shown tooltip takes Escape. */
4
+ disabled: boolean;
5
+ /**
6
+ * The toggletip: a press of the trigger (a tap, a click, Enter or Space)
7
+ * shows the tooltip and keeps it shown, and the next press hides it.
8
+ * Without it, a tap toggles only a trigger that does nothing else.
9
+ */
10
+ pressToggles: boolean;
11
+ tooltipRef: RefObject<HTMLElement | null>;
12
+ wrapperRef: RefObject<HTMLElement | null>;
13
+ };
14
+ /**
15
+ * Whether a tooltip is open, and the anchor's handlers that open and close
16
+ * it. `open` follows focus, hover, touch and presses even while disabled;
17
+ * `shown` is what the page gets. `pressed` is a tooltip a press opened: it
18
+ * stays open when the pointer leaves, until the next press, Escape, blur or
19
+ * a press outside the anchor.
20
+ */
21
+ export declare function useTooltipOpen({ disabled, pressToggles, tooltipRef, wrapperRef, }: TooltipOpenOptions): {
22
+ handlers: {
23
+ onBlurCapture(event: FocusEvent<HTMLElement>): void;
24
+ onFocusCapture(event: FocusEvent<HTMLElement>): void;
25
+ onKeyDownCapture(event: KeyboardEvent<HTMLElement>): void;
26
+ onPointerEnter(event: ReactPointerEvent<HTMLElement>): void;
27
+ onPointerLeave(event: ReactPointerEvent<HTMLElement>): void;
28
+ onPointerDownCapture(event: ReactPointerEvent<HTMLElement>): void;
29
+ onClick: ((event: MouseEvent<HTMLElement>) => void) | undefined;
30
+ };
31
+ pressed: boolean;
32
+ shown: boolean;
33
+ };
@@ -0,0 +1,90 @@
1
+ 'use client';
2
+ import { useEffect, useState, } from 'react';
3
+ import { focusIsVisible, tapActs } from './tooltip-open.js';
4
+ function isHover(event) {
5
+ return event.pointerType === 'mouse' || event.pointerType === 'pen';
6
+ }
7
+ /**
8
+ * Whether a tooltip is open, and the anchor's handlers that open and close
9
+ * it. `open` follows focus, hover, touch and presses even while disabled;
10
+ * `shown` is what the page gets. `pressed` is a tooltip a press opened: it
11
+ * stays open when the pointer leaves, until the next press, Escape, blur or
12
+ * a press outside the anchor.
13
+ */
14
+ export function useTooltipOpen({ disabled, pressToggles, tooltipRef, wrapperRef, }) {
15
+ const [open, setOpen] = useState(false);
16
+ const [pressed, setPressed] = useState(false);
17
+ const shown = open && !disabled;
18
+ function close() {
19
+ setOpen(false);
20
+ setPressed(false);
21
+ }
22
+ useEffect(() => {
23
+ if (!open)
24
+ return;
25
+ function dismissOutside(event) {
26
+ if (event.target instanceof Node &&
27
+ !wrapperRef.current?.contains(event.target)) {
28
+ setOpen(false);
29
+ setPressed(false);
30
+ }
31
+ }
32
+ document.addEventListener('pointerdown', dismissOutside);
33
+ return () => document.removeEventListener('pointerdown', dismissOutside);
34
+ }, [open, wrapperRef]);
35
+ const handlers = {
36
+ onBlurCapture(event) {
37
+ if (!event.currentTarget.contains(event.relatedTarget))
38
+ close();
39
+ },
40
+ onFocusCapture(event) {
41
+ // A click or a tap focuses the trigger too; only a visible focus
42
+ // (the keyboard) opens the tooltip, so a clicked control does not
43
+ // keep its help shown until the focus moves.
44
+ if (focusIsVisible(event.target))
45
+ setOpen(true);
46
+ },
47
+ onKeyDownCapture(event) {
48
+ // Escape hides a visible tooltip and nothing else, so a surrounding
49
+ // Dialog does not also close. A hidden tooltip leaves Escape alone.
50
+ if (event.key !== 'Escape' || !shown)
51
+ return;
52
+ event.preventDefault();
53
+ close();
54
+ },
55
+ onPointerEnter(event) {
56
+ if (isHover(event))
57
+ setOpen(true);
58
+ },
59
+ onPointerLeave(event) {
60
+ if (isHover(event) && !pressed)
61
+ setOpen(false);
62
+ },
63
+ onPointerDownCapture(event) {
64
+ // A toggletip's tap is a press, which its click handles.
65
+ if (event.pointerType !== 'touch' || pressToggles)
66
+ return;
67
+ // A tap on a control does what the control does; only a trigger
68
+ // that does nothing else on a tap toggles its help.
69
+ if (tapActs(event.target, event.currentTarget))
70
+ setOpen(false);
71
+ else
72
+ setOpen((current) => !current);
73
+ },
74
+ onClick: pressToggles
75
+ ? (event) => {
76
+ // A press inside the bubble is not a press of the trigger.
77
+ if (event.target instanceof Node &&
78
+ tooltipRef.current?.contains(event.target))
79
+ return;
80
+ if (pressed)
81
+ close();
82
+ else {
83
+ setOpen(true);
84
+ setPressed(true);
85
+ }
86
+ }
87
+ : undefined,
88
+ };
89
+ return { handlers, pressed, shown };
90
+ }
@@ -1,30 +1,10 @@
1
- import { buttonSizes } from '@scalewing/tokens';
2
- import { coarsePointerQuery, touchTarget } from './touch-target.js';
3
- /* Square at every Button size: as wide as the size's control height. */
4
- function squareRules() {
5
- return buttonSizes
6
- .map((size) => `.sw-button-${size}.sw-calendar-button { min-width: var(--sw-control-${size}-min-height); }`)
7
- .join('\n');
8
- }
1
+ import { iconButtonRules } from './css-icon-button.js';
9
2
  export function cssCalendarButtonClasses() {
10
- return `.sw-button.sw-calendar-button {
11
- flex: none;
12
- padding-inline: 0;
13
- }
14
-
15
- ${squareRules()}
3
+ return `${iconButtonRules('sw-calendar-button')}
16
4
 
17
5
  .sw-calendar-button:not(.sw-button-md) .sw-date-field-glyph {
18
6
  height: var(--sw-space-4);
19
7
  width: var(--sw-space-4);
20
- }
21
-
22
- /* A coarse pointer gets the full touch target at the smaller sizes too. */
23
- @media ${coarsePointerQuery} {
24
- .sw-button.sw-calendar-button {
25
- min-height: ${touchTarget};
26
- min-width: ${touchTarget};
27
- }
28
8
  }`;
29
9
  }
30
10
  export function calendarButtonClassCatalog() {
@@ -20,8 +20,10 @@ import { cssTextClasses } from './css-text.js';
20
20
  import { cssSpinnerClasses } from './css-spinner.js';
21
21
  import { cssProgressClasses } from './css-progress.js';
22
22
  import { cssTooltipClasses } from './css-tooltip.js';
23
+ import { cssInfoTipClasses } from './css-info-tip.js';
23
24
  import { cssSeparatorClasses } from './css-separator.js';
24
25
  import { cssStatTileClasses } from './css-stat-tile.js';
26
+ import { cssDescriptionListClasses } from './css-description-list.js';
25
27
  import { cssTabsClasses } from './css-tabs.js';
26
28
  import { cssResponsiveClasses } from './css-responsive.js';
27
29
  import { cssFilterChipsClasses } from './css-filter-chips.js';
@@ -144,10 +146,14 @@ ${cssProgressClasses()}
144
146
 
145
147
  ${cssTooltipClasses()}
146
148
 
149
+ ${cssInfoTipClasses()}
150
+
147
151
  ${cssSeparatorClasses()}
148
152
 
149
153
  ${cssStatTileClasses()}
150
154
 
155
+ ${cssDescriptionListClasses()}
156
+
151
157
  ${cssTabsClasses()}
152
158
 
153
159
  ${cssFilterChipsClasses()}
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The description list: a two-column grid whose items share its columns
3
+ * (`subgrid`), so every term starts the same column and every detail the
4
+ * next. The term column fits the widest term up to 40% of the list. A
5
+ * hairline divides the rows, the list sits flush with no space above the
6
+ * first row or below the last, and each row aligns to its top. Below `md`
7
+ * a term sits over its detail. A term and a detail are columns of lines:
8
+ * each child is its own line with its own line height, so a caption term
9
+ * starts level with a caption detail instead of on the list's taller line.
10
+ */
11
+ export declare function cssDescriptionListClasses(): string;
12
+ export declare function descriptionListClassCatalog(): string[];
@@ -0,0 +1,63 @@
1
+ import { breakpointQuery } from './breakpoints.js';
2
+ /**
3
+ * The description list: a two-column grid whose items share its columns
4
+ * (`subgrid`), so every term starts the same column and every detail the
5
+ * next. The term column fits the widest term up to 40% of the list. A
6
+ * hairline divides the rows, the list sits flush with no space above the
7
+ * first row or below the last, and each row aligns to its top. Below `md`
8
+ * a term sits over its detail. A term and a detail are columns of lines:
9
+ * each child is its own line with its own line height, so a caption term
10
+ * starts level with a caption detail instead of on the list's taller line.
11
+ */
12
+ export function cssDescriptionListClasses() {
13
+ return `.sw-description-list {
14
+ column-gap: var(--sw-space-4);
15
+ display: grid;
16
+ grid-template-columns: fit-content(40%) minmax(0, 1fr);
17
+ margin: 0;
18
+ min-width: 0;
19
+ }
20
+
21
+ .sw-description-item {
22
+ align-items: start;
23
+ display: grid;
24
+ grid-column: 1 / -1;
25
+ grid-template-columns: subgrid;
26
+ padding-block: var(--sw-space-2);
27
+ row-gap: var(--sw-space-1);
28
+ }
29
+
30
+ .sw-description-item:first-child { padding-top: 0; }
31
+
32
+ .sw-description-item:last-child { padding-bottom: 0; }
33
+
34
+ .sw-description-item + .sw-description-item {
35
+ border-top: 1px solid var(--sw-color-border);
36
+ }
37
+
38
+ .sw-description-term,
39
+ .sw-description-detail {
40
+ align-items: flex-start;
41
+ display: flex;
42
+ flex-direction: column;
43
+ margin: 0;
44
+ min-width: 0;
45
+ overflow-wrap: anywhere;
46
+ }
47
+
48
+ @media ${breakpointQuery('below', 'md')} {
49
+ .sw-description-list { grid-template-columns: minmax(0, 1fr); }
50
+ }
51
+
52
+ @media (forced-colors: active) {
53
+ .sw-description-item + .sw-description-item { border-top-color: CanvasText; }
54
+ }`;
55
+ }
56
+ export function descriptionListClassCatalog() {
57
+ return [
58
+ 'sw-description-list',
59
+ 'sw-description-item',
60
+ 'sw-description-term',
61
+ 'sw-description-detail',
62
+ ];
63
+ }
@@ -0,0 +1 @@
1
+ export declare function iconButtonRules(className: string): string;
@@ -0,0 +1,26 @@
1
+ import { buttonSizes } from '@scalewing/tokens';
2
+ import { coarsePointerQuery, touchTarget } from './touch-target.js';
3
+ /*
4
+ * An icon-only Button drawn by `className` (CalendarButton, InfoTip): square
5
+ * at every Button size, as wide as the size's control height, and the full
6
+ * touch target on a coarse pointer at the smaller sizes too. The coarse
7
+ * pointer's rule follows the squares, which it outranks only by order.
8
+ */
9
+ export function iconButtonRules(className) {
10
+ const squares = buttonSizes
11
+ .map((size) => `.sw-button-${size}.${className} { min-width: var(--sw-control-${size}-min-height); }`)
12
+ .join('\n');
13
+ return `.sw-button.${className} {
14
+ flex: none;
15
+ padding-inline: 0;
16
+ }
17
+
18
+ ${squares}
19
+
20
+ @media ${coarsePointerQuery} {
21
+ .sw-button.${className} {
22
+ min-height: ${touchTarget};
23
+ min-width: ${touchTarget};
24
+ }
25
+ }`;
26
+ }
@@ -0,0 +1,2 @@
1
+ export declare function cssInfoTipClasses(): string;
2
+ export declare function infoTipClassCatalog(): string[];
@@ -0,0 +1,8 @@
1
+ import { iconButtonRules } from './css-icon-button.js';
2
+ /* InfoTip's glyph button; its bubble is the generated `.sw-tooltip`. */
3
+ export function cssInfoTipClasses() {
4
+ return iconButtonRules('sw-info-tip');
5
+ }
6
+ export function infoTipClassCatalog() {
7
+ return ['sw-info-tip'];
8
+ }
@@ -10,8 +10,10 @@ import { radioGroupClassCatalog } from './css-radio-group.js';
10
10
  import { spinnerClassCatalog } from './css-spinner.js';
11
11
  import { progressClassCatalog } from './css-progress.js';
12
12
  import { tooltipClassCatalog } from './css-tooltip.js';
13
+ import { infoTipClassCatalog } from './css-info-tip.js';
13
14
  import { separatorClassCatalog } from './css-separator.js';
14
15
  import { statTileClassCatalog } from './css-stat-tile.js';
16
+ import { descriptionListClassCatalog } from './css-description-list.js';
15
17
  import { tabsClassCatalog } from './css-tabs.js';
16
18
  import { responsiveClassCatalog } from './css-responsive.js';
17
19
  import { filterChipsClassCatalog } from './css-filter-chips.js';
@@ -72,8 +74,10 @@ export function utilityClassCatalog() {
72
74
  ...spinnerClassCatalog(),
73
75
  ...progressClassCatalog(),
74
76
  ...tooltipClassCatalog(),
77
+ ...infoTipClassCatalog(),
75
78
  ...separatorClassCatalog(),
76
79
  ...statTileClassCatalog(),
80
+ ...descriptionListClassCatalog(),
77
81
  ...tabsClassCatalog(),
78
82
  ...filterChipsClassCatalog(),
79
83
  ...gridClassCatalog(),
package/dist/index.d.ts CHANGED
@@ -9,6 +9,7 @@ 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
11
  export { Tooltip, type TooltipProps, type TooltipRelationship, } from './components/Tooltip.js';
12
+ export { InfoTip, type InfoTipProps } from './components/InfoTip.js';
12
13
  export { RadioGroup, type RadioGroupOption, type RadioGroupProps, } from './components/RadioGroup.js';
13
14
  export { AppHeader, type AppHeaderProps } from './components/AppHeader.js';
14
15
  export { Badge, type BadgeProps, type BadgeSize, type BadgeTone, badgeSizes, badgeTones, } from './components/Badge.js';
@@ -41,6 +42,8 @@ export type { Breakpoint, BreakpointDirection, VisibilityBreakpoint, } from './c
41
42
  export { breakpointQuery } from './css/breakpoints.js';
42
43
  export { Separator } from './components/Separator.js';
43
44
  export type { SeparatorOrientation, SeparatorProps, } from './components/Separator.js';
45
+ export { DescriptionItem, DescriptionList, } from './components/DescriptionList.js';
46
+ export type { DescriptionItemProps, DescriptionListProps, } from './components/DescriptionList.js';
44
47
  export { Grid } from './components/Grid.js';
45
48
  export type { GridColumns, GridProps } from './components/Grid.js';
46
49
  export type { GridColumnSpan } from './css/css-grid-span.js';
package/dist/index.js CHANGED
@@ -8,6 +8,7 @@ export { Checkbox } from './components/Checkbox.js';
8
8
  export { Spinner, } from './components/Spinner.js';
9
9
  export { Progress, } from './components/Progress.js';
10
10
  export { Tooltip, } from './components/Tooltip.js';
11
+ export { InfoTip } from './components/InfoTip.js';
11
12
  export { RadioGroup, } from './components/RadioGroup.js';
12
13
  export { AppHeader } from './components/AppHeader.js';
13
14
  export { Badge, badgeSizes, badgeTones, } from './components/Badge.js';
@@ -34,6 +35,7 @@ export { ThemeProvider, useTheme, } from './theme/ThemeProvider.js';
34
35
  export { utilityClassCatalog } from './css/stylesheet.js';
35
36
  export { breakpointQuery } from './css/breakpoints.js';
36
37
  export { Separator } from './components/Separator.js';
38
+ export { DescriptionItem, DescriptionList, } from './components/DescriptionList.js';
37
39
  export { Grid } from './components/Grid.js';
38
40
  export { DenominationGrid } from './components/DenominationGrid.js';
39
41
  export { FilterChips } from './components/FilterChips.js';
package/dist/styles.css CHANGED
@@ -1884,12 +1884,6 @@ body {
1884
1884
  .sw-button-sm.sw-calendar-button { min-width: var(--sw-control-sm-min-height); }
1885
1885
  .sw-button-md.sw-calendar-button { min-width: var(--sw-control-md-min-height); }
1886
1886
 
1887
- .sw-calendar-button:not(.sw-button-md) .sw-date-field-glyph {
1888
- height: var(--sw-space-4);
1889
- width: var(--sw-space-4);
1890
- }
1891
-
1892
- /* A coarse pointer gets the full touch target at the smaller sizes too. */
1893
1887
  @media (pointer: coarse) {
1894
1888
  .sw-button.sw-calendar-button {
1895
1889
  min-height: var(--sw-control-md-min-height);
@@ -1897,6 +1891,11 @@ body {
1897
1891
  }
1898
1892
  }
1899
1893
 
1894
+ .sw-calendar-button:not(.sw-button-md) .sw-date-field-glyph {
1895
+ height: var(--sw-space-4);
1896
+ width: var(--sw-space-4);
1897
+ }
1898
+
1900
1899
  .sw-bar-chart {
1901
1900
  display: grid;
1902
1901
  gap: var(--sw-space-2);
@@ -2949,6 +2948,22 @@ body {
2949
2948
  .sw-tooltip { border-color: CanvasText; box-shadow: none; }
2950
2949
  }
2951
2950
 
2951
+ .sw-button.sw-info-tip {
2952
+ flex: none;
2953
+ padding-inline: 0;
2954
+ }
2955
+
2956
+ .sw-button-xs.sw-info-tip { min-width: var(--sw-control-xs-min-height); }
2957
+ .sw-button-sm.sw-info-tip { min-width: var(--sw-control-sm-min-height); }
2958
+ .sw-button-md.sw-info-tip { min-width: var(--sw-control-md-min-height); }
2959
+
2960
+ @media (pointer: coarse) {
2961
+ .sw-button.sw-info-tip {
2962
+ min-height: var(--sw-control-md-min-height);
2963
+ min-width: var(--sw-control-md-min-height);
2964
+ }
2965
+ }
2966
+
2952
2967
  .sw-separator {
2953
2968
  background: var(--sw-color-border);
2954
2969
  border: 0;
@@ -3024,6 +3039,49 @@ body {
3024
3039
  .sw-stat-tile-primary { background: Highlight; }
3025
3040
  }
3026
3041
 
3042
+ .sw-description-list {
3043
+ column-gap: var(--sw-space-4);
3044
+ display: grid;
3045
+ grid-template-columns: fit-content(40%) minmax(0, 1fr);
3046
+ margin: 0;
3047
+ min-width: 0;
3048
+ }
3049
+
3050
+ .sw-description-item {
3051
+ align-items: start;
3052
+ display: grid;
3053
+ grid-column: 1 / -1;
3054
+ grid-template-columns: subgrid;
3055
+ padding-block: var(--sw-space-2);
3056
+ row-gap: var(--sw-space-1);
3057
+ }
3058
+
3059
+ .sw-description-item:first-child { padding-top: 0; }
3060
+
3061
+ .sw-description-item:last-child { padding-bottom: 0; }
3062
+
3063
+ .sw-description-item + .sw-description-item {
3064
+ border-top: 1px solid var(--sw-color-border);
3065
+ }
3066
+
3067
+ .sw-description-term,
3068
+ .sw-description-detail {
3069
+ align-items: flex-start;
3070
+ display: flex;
3071
+ flex-direction: column;
3072
+ margin: 0;
3073
+ min-width: 0;
3074
+ overflow-wrap: anywhere;
3075
+ }
3076
+
3077
+ @media not all and (min-width: 48rem) {
3078
+ .sw-description-list { grid-template-columns: minmax(0, 1fr); }
3079
+ }
3080
+
3081
+ @media (forced-colors: active) {
3082
+ .sw-description-item + .sw-description-item { border-top-color: CanvasText; }
3083
+ }
3084
+
3027
3085
  .sw-tabs {
3028
3086
  border-bottom: 1px solid var(--sw-color-border);
3029
3087
  display: flex;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scalewing/react",
3
- "version": "1.24.0",
3
+ "version": "1.26.0",
4
4
  "description": "Scalewing DOM components and package-owned CSS utilities.",
5
5
  "type": "module",
6
6
  "license": "MIT",