@scalewing/react 1.17.0 → 1.19.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,19 @@
1
1
  # @scalewing/react
2
2
 
3
+ ## 1.19.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 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.
8
+
9
+ ## 1.18.0
10
+
11
+ ### Minor Changes
12
+
13
+ - ea2fdf0: `ActionBar` `status` takes a node (`docs/requests/fantasy-football-action-bar-status.md`, the fantasy-football companion's trade verdict): `status?: ReactNode`, widened from `string`, so a `Badge` and a short line can share the status. The status element is now a `div` (was a `p`) that keeps `className="sw-action-bar-status"` and `role="status"`, is still rendered while empty, and still takes no room when empty. It lays its children on one wrapping row a `space-2` gap apart (`space-1` between wrapped rows), so a line that does not fit beside its badge wraps under it at phone width. A string status renders as before; a phrase that mixes text and elements belongs in one `span`. Consumers whose tests looked for a `P` status element need to update that query. No new classes, tokens or dependencies.
14
+ - d02521e: `Dialog` `sheetBelow` and `closeLabel` (`docs/requests/fantasy-football-dialog-sheet.md`, the fantasy-football companion's phone review dialogs). `sheetBelow?: Breakpoint` (`'md'`): below that breakpoint the dialog is a bottom sheet, docked to the bottom edge at the full viewport width, top corners at `--sw-radius-lg` and bottom corners square, a `space-8` strip of backdrop left above it (the same dvh-aware cap), and bottom padding that clears `env(safe-area-inset-bottom)`; it slides up on open with `--sw-motion-default`, not under `prefers-reduced-motion`, and draws no grabber. From the breakpoint up it is the centered dialog at its `size`. `closeLabel?: string`: an icon-only ghost close `Button` ends the title row, named by the label, and calls `onClose`; the title still names the dialog and `titleLevel` still applies. The button is the first control, so it takes the focus on open unless content has `autoFocus`. Its X is private control chrome drawn in CSS (no icon dependency). Without `closeLabel` the markup is unchanged. New generated classes `sw-dialog-sheet-below-md`, `sw-dialog-header`, `sw-dialog-close`, `sw-dialog-close-glyph`. No new tokens and no new dependencies.
15
+ - c5fd0a9: `Tabs` `sticky` (`docs/requests/fantasy-football-tabs-sticky.md`, the fantasy-football companion's section tabs): `sticky?: boolean`, default `false`, keeps the strip at the top of the viewport (`top: env(safe-area-inset-top, 0px)`) while a long panel scrolls under it, on the sticky `AppHeader`'s `topChrome` layer. The stuck strip is a full-bleed band of the page canvas, not a glass card: `--sw-color-background` at 90 % over the glass blur and saturate tokens, with the strip's existing hairline, solid canvas under `prefers-reduced-transparency` and `Canvas` in forced colors. A sticky element only sticks within its parent, so make the strip a direct child of the page container that holds the panels; each tab's md inline padding (16 px, spacing step 4) lines the first label up with a `space-4` page gutter. New generated class `sw-tabs-sticky`. No new tokens and no new dependencies.
16
+
3
17
  ## 1.17.0
4
18
 
5
19
  ### Minor Changes
package/README.md CHANGED
@@ -126,7 +126,7 @@ A disabled native text control (`<input>`, `<select>` or `<textarea>` on the can
126
126
  </Accordion>
127
127
  ```
128
128
 
129
- `ActionBar` keeps the actions of a long page on a glass bar stuck to the bottom of the viewport, with an optional one-line `status` (such as when the work was last saved). Put it last in the content it acts on: it stays stuck while that content scrolls by and then rests in its own place at the end. `stickyBelow="md"` sticks only on a phone and leaves the bar in page flow from `md` up. The bar clears `env(safe-area-inset-bottom)` (set `viewport-fit=cover` in the page's viewport meta for the inset to apply). Its children are the actions: on a phone they share one row under the status. The status is a polite live region (`role="status"`, kept in the page even while empty), so a new status such as "Draft saved at 5:00 PM" is announced; do not announce the same save a second time with your own notice. An open `Select`, `ActionMenu` or `Tooltip` paints over the bar, including inside a glass `Card` or `Accordion`.
129
+ `ActionBar` keeps the actions of a long page on a glass bar stuck to the bottom of the viewport, with an optional short `status` (such as when the work was last saved): a string, or a node such as a `Badge` and then a short line. Put it last in the content it acts on: it stays stuck while that content scrolls by and then rests in its own place at the end. `stickyBelow="md"` sticks only on a phone and leaves the bar in page flow from `md` up. The bar clears `env(safe-area-inset-bottom)` (set `viewport-fit=cover` in the page's viewport meta for the inset to apply). Its children are the actions: on a phone they share one row under the status. The status is a polite live region (`role="status"`, kept in the page even while empty), so a new status such as "Draft saved at 5:00 PM" is announced; do not announce the same save a second time with your own notice. The status is a `div` whose children sit on one row a `space-2` gap apart and wrap under one another when the row is full, so a line that does not fit beside its badge drops under it on a phone; put a phrase that mixes text and elements in one `span` so it wraps as a sentence. `undefined`, `null` or `false` leaves the status empty. An open `Select`, `ActionMenu` or `Tooltip` paints over the bar, including inside a glass `Card` or `Accordion`.
130
130
 
131
131
  ```tsx
132
132
  <Stack gap={4}>
@@ -155,7 +155,7 @@ A `Button` that toggles something passes `aria-pressed`. The pressed button gets
155
155
 
156
156
  `ActionMenu` opens independent commands from a labelled button. Provide localized command labels and callbacks; use `disabled` for unavailable commands and `destructive` for a dangerous command's presentation. Escape and choosing a command both return focus to the trigger (before the command runs, so a `Dialog` it opens hands focus back to the trigger on close), and outside interaction dismisses the menu. The open menu sits on the popover layer a `space-1` gap below its trigger (above it when it only fits there), at least `space-2` from every viewport edge (a classic scrollbar is not part of the viewport), and aligned to the trigger's inline start (its right edge in right-to-left), or to its other edge when the trigger ends a row and the menu would not fit from its start. `align="end"` (default `"start"`) lines the menu up with the trigger's inline end even where the start would fit, for a trigger that ends a card or a list row, so the menu stays over that card instead of hanging past it; it takes the start when the end would leave the screen. The menu is as wide as its longest command up to the viewport less both insets, and a longer command wraps. The menu follows its trigger through scroll, window resize, and a change in the menu's or the trigger's size. The menu is in the DOM only while it is open. The trigger and its commands use the compact xs control height for a fine pointer; on a coarse pointer the trigger is at least 44 × 44 px (the md control height, as on `CalendarButton`) and every command is at least 44 px tall.
157
157
 
158
- `Dialog` is a modal `<dialog>` controlled by `open`. Escape, a backdrop press, a platform close request, and a `<form method="dialog">` submit (or a submitter with `formmethod="dialog"`) are each prevented and call `onClose`; none of them closes the dialog itself. A consumer `onKeyDown`, `onCancel`, `onSubmit` or `onPointerDown` that prevents the event vetoes that request. Escape in a search field that holds text clears the field first, and a descendant's own `cancel` (a dismissed file picker) is not a close request. Set `open` to false to close it, or keep it true (for example while a form is saving) and it stays shown. `onClose` is not called when `open` turns false, and it is required: a dialog that must not be dismissed passes a callback that keeps `open` true. The `title` is an `h3` in the title style by default; `titleLevel={2}` renders it as an `h2` in the same style, for a dialog whose own section labels are `h3` headings, so they read as parts of the dialog and not as its peers. The default stays `3` so existing dialogs keep their outline; any value other than `2` renders an `h3`.
158
+ `Dialog` is a modal `<dialog>` controlled by `open`. Escape, a backdrop press, a platform close request, and a `<form method="dialog">` submit (or a submitter with `formmethod="dialog"`) are each prevented and call `onClose`; none of them closes the dialog itself. A consumer `onKeyDown`, `onCancel`, `onSubmit` or `onPointerDown` that prevents the event vetoes that request. Escape in a search field that holds text clears the field first, and a descendant's own `cancel` (a dismissed file picker) is not a close request. Set `open` to false to close it, or keep it true (for example while a form is saving) and it stays shown. `onClose` is not called when `open` turns false, and it is required: a dialog that must not be dismissed passes a callback that keeps `open` true. The `title` is an `h3` in the title style by default; `titleLevel={2}` renders it as an `h2` in the same style, for a dialog whose own section labels are `h3` headings, so they read as parts of the dialog and not as its peers. The default stays `3` so existing dialogs keep their outline; any value other than `2` renders an `h3`. `sheetBelow="md"` makes the dialog a bottom sheet below `md`: docked to the bottom edge at the full viewport width, top corners rounded and bottom corners square, a `space-8` strip of backdrop above it, and bottom padding that clears `env(safe-area-inset-bottom)` (with `viewport-fit=cover`); it slides up on open unless reduced motion is asked for, and has no grabber, since there is no drag. From `md` up it is the centered dialog at its `size`. `closeLabel` (such as "Close") adds an icon-only ghost close button at the end of the title row, named by the label, which calls `onClose` like Escape; the title still names the dialog. The button is the dialog's first control, so the browser focuses it on open; give a field `autoFocus` to start there instead. Without `closeLabel` the dialog has no close button and its markup is unchanged.
159
159
 
160
160
  `Toast` is an auto-dismiss confirmation on the popover layer, controlled by `open` and `onOpenChange`; it does not trap focus and closes after `timeoutMs` (default 800, or 6000 for a `warning` or `danger` toast, which must be read). `anchor` and `target` send it from a press to a destination. `tone` (`neutral` by default, `success`, `warning`, `danger`) tints its border and an optional consumer `icon` (a Lucide glyph, hidden from assistive technology); the message keeps the text color, so its words carry the meaning. A `danger` toast is announced as `role="alert"`, the others as `role="status"`, so do not wrap the message in another live region. A toast still dismisses itself: an error the person must act on belongs in the page. A toast is as wide as its message, up to the page's gutter (spacing step 4) on each side, so on a phone a long message wraps inside the gutter instead of running to the screen's edges.
161
161
 
@@ -218,13 +218,36 @@ An option's optional `description` (a `ReactNode`) is secondary text for that op
218
218
  />
219
219
  ```
220
220
 
221
+ `Tabs` is the strip for the sections of one page (`role="tablist"`, one `role="tab"` button per item; arrow keys, Home and End move and select), and `TabPanel` is each section's content, hidden while another tab is current. `sticky` (default `false`) keeps the strip at the top of the viewport, under `env(safe-area-inset-top)`, while a long panel scrolls under it, on the same layer as a sticky `AppHeader` (so a page uses one or the other at the top edge). The stuck strip is a full-bleed band of the page canvas, not a glass card: the canvas color at nine parts in ten over the glass blur, with the strip's hairline under it, solid canvas under Reduce Transparency and in forced colors. A sticky element only sticks within its parent: make the strip a direct child of the long page container that also holds the panels, not of a padded `Box` round the strip alone, and keep `overflow` visible on its ancestors. Put the page's side gutter on the title and the panels instead: each tab's inline padding is the md control's 16 px (spacing step 4), so the first label lines up with a `space-4` gutter when the strip runs edge to edge.
222
+
223
+ ```tsx
224
+ <Stack gap={3}>
225
+ <Box paddingX={4}>
226
+ <Text variant="title">Wetland reserve</Text>
227
+ </Box>
228
+ <Tabs
229
+ aria-label="Reserve log"
230
+ id="reserve"
231
+ items={groups}
232
+ onChange={setGroup}
233
+ sticky
234
+ value={group}
235
+ />
236
+ <Box paddingX={4}>
237
+ <TabPanel id="birds" tabsId="reserve" value={group}>
238
+ {birdLog}
239
+ </TabPanel>
240
+ </Box>
241
+ </Stack>
242
+ ```
243
+
221
244
  `Table` aligns data in rows inside its own keyboard-reachable scroll region, named after the table. While the table is wider than the region, each inline edge with columns scrolled out past it draws a soft inset shade (`sw-scroll-more-start`, `sw-scroll-more-end`, mirrored right to left), so a phone, whose scrollbars stay hidden, still shows that the table goes on; a table that fits draws none, and in forced colors the system scrollbar is the cue. `TableCell` takes `numeric` (tabular numerals, end aligned), `align` and `truncate`; `density="compact"` densifies cells. `TableRow selected` sets `aria-selected` and marks the row with a 4 px accent bar at its inline start (3:1 or more against the surface and the page), the right edge in a right-to-left table. The bar is drawn inside the first cell's padding and takes no layout space, so no column moves when a row is picked, and the row takes no fill, so text, muted and accent text keep their contrast; forced colors keep the bar in the system highlight.
222
245
 
223
246
  `Spinner` shows indeterminate loading in small, medium, or large sizes. Supply localized `label` for the one announced status in a loading region. Use `decorative` on additional indicators beside that status so screen readers do not hear the same message repeatedly. Reduced motion leaves a static accented ring.
224
247
 
225
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.
226
249
 
227
- `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. 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. `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.
228
251
 
229
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.
230
253
 
@@ -4,10 +4,15 @@ export type ActionBarProps = Omit<HTMLAttributes<HTMLDivElement>, 'children'> &
4
4
  /** The actions, usually two or three `Button`s. */
5
5
  children: ReactNode;
6
6
  /**
7
- * One short line about the work, such as when it was last saved. It is a
8
- * polite live region, so a new status is announced without moving focus.
7
+ * One short line about the work, such as when it was last saved, or a
8
+ * `Badge` and then a short line ("Ready" and what is ready). It is a
9
+ * polite live region, so a new status is announced without moving focus;
10
+ * nothing (`undefined`, `null`, `false`) leaves it empty and taking no
11
+ * room. Its direct children sit on one row a small gap apart and wrap
12
+ * under one another when the row is full, so put a phrase that mixes text
13
+ * and elements in one `span` to keep it wrapping as a sentence.
9
14
  */
10
- status?: string;
15
+ status?: ReactNode;
11
16
  /** Sticky only below this breakpoint; from it up the bar sits in page flow. */
12
17
  stickyBelow?: Breakpoint;
13
18
  };
@@ -21,10 +26,15 @@ export declare const ActionBar: import("react").ForwardRefExoticComponent<Omit<H
21
26
  /** The actions, usually two or three `Button`s. */
22
27
  children: ReactNode;
23
28
  /**
24
- * One short line about the work, such as when it was last saved. It is a
25
- * polite live region, so a new status is announced without moving focus.
29
+ * One short line about the work, such as when it was last saved, or a
30
+ * `Badge` and then a short line ("Ready" and what is ready). It is a
31
+ * polite live region, so a new status is announced without moving focus;
32
+ * nothing (`undefined`, `null`, `false`) leaves it empty and taking no
33
+ * room. Its direct children sit on one row a small gap apart and wrap
34
+ * under one another when the row is full, so put a phrase that mixes text
35
+ * and elements in one `span` to keep it wrapping as a sentence.
26
36
  */
27
- status?: string;
37
+ status?: ReactNode;
28
38
  /** Sticky only below this breakpoint; from it up the bar sits in page flow. */
29
39
  stickyBelow?: Breakpoint;
30
40
  } & import("react").RefAttributes<HTMLDivElement>>;
@@ -9,5 +9,5 @@ import { actionBarStickyClass } from '../css/css-action-bar.js';
9
9
  * in its own place at the end.
10
10
  */
11
11
  export const ActionBar = forwardRef(function ActionBar({ children, className, status, stickyBelow, ...rest }, ref) {
12
- return (_jsxs("div", { ref: ref, className: cx('sw-action-bar', actionBarStickyClass(stickyBelow), className), ...rest, children: [_jsx("p", { className: "sw-action-bar-status", role: "status", children: status }), _jsx("div", { className: "sw-action-bar-actions", children: children })] }));
12
+ return (_jsxs("div", { ref: ref, className: cx('sw-action-bar', actionBarStickyClass(stickyBelow), className), ...rest, children: [_jsx("div", { className: "sw-action-bar-status", role: "status", children: status }), _jsx("div", { className: "sw-action-bar-actions", children: children })] }));
13
13
  });
@@ -1,4 +1,5 @@
1
1
  import { type DialogHTMLAttributes, type ReactNode } from 'react';
2
+ import { type Breakpoint } from '../css/breakpoints.js';
2
3
  /**
3
4
  * `md` is the reading width (32rem) for a message or a short form; `lg`
4
5
  * (56rem) holds a row of six fields or a data grid without folding it.
@@ -31,6 +32,24 @@ export type DialogProps = Omit<DialogHTMLAttributes<HTMLDialogElement>, 'onClose
31
32
  * Any value other than 2 (from an untyped caller) renders an `h3`.
32
33
  */
33
34
  titleLevel?: DialogTitleLevel;
35
+ /**
36
+ * Below this breakpoint the dialog is a bottom sheet: docked to the
37
+ * bottom edge at the full viewport width, its top corners rounded and its
38
+ * bottom corners square, a strip of backdrop left in view above it, and
39
+ * its bottom padding clearing `env(safe-area-inset-bottom)`. It slides up
40
+ * on open unless reduced motion is asked for. There is no drag grabber
41
+ * (and no drag): it closes as any dialog does. From the breakpoint up it
42
+ * is the centered dialog, at its `size`.
43
+ */
44
+ sheetBelow?: Breakpoint;
45
+ /**
46
+ * The accessible name of a close button, such as "Close". When given, an
47
+ * icon-only ghost button ends the title row and asks `onClose`, like
48
+ * Escape. It is the first control in the dialog, so it takes the focus on
49
+ * open unless a control in the content has `autoFocus`. Left out, the
50
+ * dialog has no close button and its markup is unchanged.
51
+ */
52
+ closeLabel?: string;
34
53
  children: ReactNode;
35
54
  };
36
- export declare function Dialog({ children, className, onCancel, onClose, onKeyDown, onPointerDown, onSubmit, open, size, title, titleLevel, ...rest }: DialogProps): import("react").JSX.Element;
55
+ export declare function Dialog({ children, className, closeLabel, onCancel, onClose, onKeyDown, onPointerDown, onSubmit, open, sheetBelow, size, title, titleLevel, ...rest }: DialogProps): import("react").JSX.Element;
@@ -2,11 +2,12 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useId } from 'react';
4
4
  import { cx } from '../class-names.js';
5
+ import { dialogSheetClass } from '../css/css-dialog.js';
5
6
  import { spacingClassNames } from '../spacing-classes.js';
6
7
  import { useDialogCloseRequests } from './dialog-close-requests.js';
8
+ import { DialogTitle } from './dialog/DialogTitle.js';
7
9
  import { Stack } from './Stack.js';
8
- import { Text } from './Text.js';
9
- export function Dialog({ children, className, onCancel, onClose, onKeyDown, onPointerDown, onSubmit, open, size = 'md', title, titleLevel = 3, ...rest }) {
10
+ export function Dialog({ children, className, closeLabel, onCancel, onClose, onKeyDown, onPointerDown, onSubmit, open, sheetBelow, size = 'md', title, titleLevel = 3, ...rest }) {
10
11
  const titleId = useId();
11
12
  const { nodeRef, handlers } = useDialogCloseRequests({
12
13
  open,
@@ -16,5 +17,5 @@ export function Dialog({ children, className, onCancel, onClose, onKeyDown, onPo
16
17
  onPointerDown,
17
18
  onSubmit,
18
19
  });
19
- return (_jsx("dialog", { ...rest, ref: nodeRef, "aria-labelledby": titleId, className: cx('sw-dialog', size === 'lg' && 'sw-dialog-lg', ...spacingClassNames({ padding: 5 }), className), ...handlers, children: _jsxs(Stack, { gap: 4, children: [_jsx(Text, { as: titleLevel === 2 ? 'h2' : 'h3', id: titleId, variant: "title", children: title }), children] }) }));
20
+ return (_jsx("dialog", { ...rest, ref: nodeRef, "aria-labelledby": titleId, className: cx('sw-dialog', size === 'lg' && 'sw-dialog-lg', sheetBelow && dialogSheetClass(sheetBelow), ...spacingClassNames({ padding: 5 }), className), ...handlers, children: _jsxs(Stack, { gap: 4, children: [_jsx(DialogTitle, { closeLabel: closeLabel, id: titleId, level: titleLevel === 2 ? 2 : 3, onClose: onClose, title: title }), children] }) }));
20
21
  }
@@ -16,15 +16,35 @@ export type TabsProps = TabsLabel & {
16
16
  items: readonly TabItem[];
17
17
  value: string;
18
18
  onChange: (id: string) => void;
19
+ /**
20
+ * Keep the strip at the top of the viewport, under the top safe area,
21
+ * while a long panel scrolls under it. Off by default. The stuck strip is
22
+ * a full-bleed band of the page canvas, not a glass card: the canvas
23
+ * color, a little see-through over the glass blur, with its hairline
24
+ * underneath; solid under Reduce Transparency and in forced colors. It
25
+ * shares the sticky `AppHeader`'s layer, so a page uses one or the other
26
+ * at the top edge.
27
+ *
28
+ * A sticky element only sticks within its parent. Make the strip a direct
29
+ * child of the long page container that also holds the panels, not of a
30
+ * padded `Box` round the strip alone; an ancestor whose `overflow` is not
31
+ * `visible` becomes the box it sticks to. Put the page's side gutter on
32
+ * the title and the panels rather than on that container: each tab
33
+ * already has the md control's inline padding (16 px, spacing step 4), so
34
+ * the first label lines up with a `space-4` page gutter when the strip
35
+ * runs edge to edge.
36
+ */
37
+ sticky?: boolean;
19
38
  };
20
39
  export declare function tabId(tabsId: string, id: string): string;
21
40
  export declare function tabPanelId(tabsId: string, id: string): string;
22
41
  /**
23
42
  * A tab strip with `tablist` semantics for the sections of one page. Arrow
24
43
  * keys, Home and End move the focus and select at once; the strip scrolls
25
- * sideways when it overflows. Pair each item with a `TabPanel`.
44
+ * sideways when it overflows. Pair each item with a `TabPanel`. `sticky`
45
+ * keeps it at the top of the viewport over a long panel.
26
46
  */
27
- export declare function Tabs({ id, items, onChange, value, ...labelProps }: TabsProps): import("react").JSX.Element;
47
+ export declare function Tabs({ id, items, onChange, sticky, value, ...labelProps }: TabsProps): import("react").JSX.Element;
28
48
  export type TabPanelProps = Omit<HTMLAttributes<HTMLDivElement>, 'id'> & {
29
49
  /** The `id` of the `Tabs` this panel belongs to. */
30
50
  tabsId: string;
@@ -10,9 +10,10 @@ export function tabPanelId(tabsId, id) {
10
10
  /**
11
11
  * A tab strip with `tablist` semantics for the sections of one page. Arrow
12
12
  * keys, Home and End move the focus and select at once; the strip scrolls
13
- * sideways when it overflows. Pair each item with a `TabPanel`.
13
+ * sideways when it overflows. Pair each item with a `TabPanel`. `sticky`
14
+ * keeps it at the top of the viewport over a long panel.
14
15
  */
15
- export function Tabs({ id, items, onChange, value, ...labelProps }) {
16
+ export function Tabs({ id, items, onChange, sticky = false, value, ...labelProps }) {
16
17
  const tabs = useRef(new Map());
17
18
  function select(next) {
18
19
  if (!next || next.id === value) {
@@ -42,7 +43,7 @@ export function Tabs({ id, items, onChange, value, ...labelProps }) {
42
43
  event.preventDefault();
43
44
  select(target);
44
45
  }
45
- return (_jsx("div", { className: "sw-tabs", onKeyDown: onKeyDown, role: "tablist", ...labelProps, children: items.map((item) => {
46
+ return (_jsx("div", { className: cx('sw-tabs', sticky && 'sw-tabs-sticky'), onKeyDown: onKeyDown, role: "tablist", ...labelProps, children: items.map((item) => {
46
47
  const selected = item.id === value;
47
48
  return (_jsx("button", { "aria-controls": tabPanelId(id, item.id), "aria-selected": selected, className: cx('sw-tab', selected && 'sw-tab-selected'), id: tabId(id, item.id), onClick: () => select(item), ref: (node) => {
48
49
  if (node) {
@@ -1,8 +1,18 @@
1
1
  import { type ReactElement } from 'react';
2
2
  export type TooltipProps = {
3
3
  content: string;
4
+ /**
5
+ * Turns the tooltip off, not its trigger: no tooltip, no description and no
6
+ * Escape handling, while the trigger stays mounted, enabled and focusable
7
+ * (and keeps its focus). Focus, hover and touch are still followed, so the
8
+ * tooltip shows as soon as it is enabled again on a trigger that still has
9
+ * focus, the pointer or an open touch toggle; Escape pressed while disabled
10
+ * reaches the page and does not stop that. It must match between the server
11
+ * render and the first client render.
12
+ */
13
+ disabled?: boolean;
4
14
  trigger: ReactElement<{
5
15
  'aria-describedby'?: string;
6
16
  }>;
7
17
  };
8
- export declare function Tooltip({ content, trigger }: TooltipProps): import("react").JSX.Element;
18
+ export declare function Tooltip({ content, disabled, trigger }: TooltipProps): import("react").JSX.Element;
@@ -1,15 +1,18 @@
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
+ export function Tooltip({ content, disabled = false, trigger }) {
5
5
  if (!content.trim())
6
6
  throw new RangeError('content must not be empty');
7
+ // `open` follows focus, hover and touch even while disabled; `shown` is
8
+ // what the page gets.
7
9
  const [open, setOpen] = useState(false);
10
+ const shown = open && !disabled;
8
11
  const id = useId();
9
12
  const wrapperRef = useRef(null);
10
- const describedBy = [trigger.props['aria-describedby'], id]
13
+ const describedBy = [trigger.props['aria-describedby'], disabled ? undefined : id]
11
14
  .filter(Boolean)
12
- .join(' ');
15
+ .join(' ') || undefined;
13
16
  useEffect(() => {
14
17
  if (!open)
15
18
  return;
@@ -27,7 +30,7 @@ export function Tooltip({ content, trigger }) {
27
30
  }, onFocusCapture: () => setOpen(true), onKeyDownCapture: (event) => {
28
31
  // Escape hides a visible tooltip and nothing else, so a surrounding
29
32
  // Dialog does not also close. A hidden tooltip leaves Escape alone.
30
- if (event.key !== 'Escape' || !open)
33
+ if (event.key !== 'Escape' || !shown)
31
34
  return;
32
35
  event.preventDefault();
33
36
  setOpen(false);
@@ -40,5 +43,5 @@ export function Tooltip({ content, trigger }) {
40
43
  }, onPointerDownCapture: (event) => {
41
44
  if (event.pointerType === 'touch')
42
45
  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 })] }));
46
+ }, ref: wrapperRef, children: [cloneElement(trigger, { 'aria-describedby': describedBy }), disabled ? null : (_jsx("span", { className: "sw-tooltip", hidden: !shown, id: id, role: "tooltip", children: content }))] }));
44
47
  }
@@ -0,0 +1,15 @@
1
+ type DialogTitleProps = {
2
+ id: string;
3
+ level: 2 | 3;
4
+ title: string;
5
+ /** The close button's accessible name; without it there is no button. */
6
+ closeLabel?: string;
7
+ onClose: () => void;
8
+ };
9
+ /**
10
+ * The dialog's title, which names it through `aria-labelledby`. With a
11
+ * `closeLabel` it shares a row with an icon-only ghost close button at its
12
+ * end; without one it is the title alone, as it always was.
13
+ */
14
+ export declare function DialogTitle({ closeLabel, id, level, onClose, title, }: DialogTitleProps): import("react").JSX.Element;
15
+ export {};
@@ -0,0 +1,14 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Button } from '../Button.js';
3
+ import { Text } from '../Text.js';
4
+ /**
5
+ * The dialog's title, which names it through `aria-labelledby`. With a
6
+ * `closeLabel` it shares a row with an icon-only ghost close button at its
7
+ * end; without one it is the title alone, as it always was.
8
+ */
9
+ export function DialogTitle({ closeLabel, id, level, onClose, title, }) {
10
+ const heading = (_jsx(Text, { as: level === 2 ? 'h2' : 'h3', id: id, variant: "title", children: title }));
11
+ if (!closeLabel)
12
+ return heading;
13
+ return (_jsxs("div", { className: "sw-dialog-header", children: [heading, _jsxs(Button, { className: "sw-dialog-close", onPress: onClose, size: "sm", variant: "ghost", children: [_jsx("span", { className: "sw-sr-only", children: closeLabel }), _jsx("span", { "aria-hidden": "true", className: "sw-dialog-close-glyph" })] })] }));
14
+ }
@@ -50,14 +50,21 @@ export function cssActionBarClasses() {
50
50
  padding-right: max(var(--sw-space-4), env(safe-area-inset-right, 0px));
51
51
  }
52
52
 
53
+ /* A Badge and a short line sit on one row a small gap apart; a line that
54
+ does not fit beside the badge wraps under it. */
53
55
  .sw-action-bar-status {
56
+ align-items: center;
54
57
  color: var(--sw-color-muted);
58
+ column-gap: var(--sw-space-2);
59
+ display: flex;
55
60
  flex: 1 1 auto;
61
+ flex-wrap: wrap;
56
62
  font-family: var(--sw-font-sans);
57
63
  font-size: ${caption.fontSize}px;
58
64
  line-height: ${caption.lineHeight}px;
59
65
  margin: 0;
60
66
  min-width: 0;
67
+ row-gap: var(--sw-space-1);
61
68
  }
62
69
 
63
70
  /* With no status the live region stays in the page, so a first status is
@@ -1,2 +1,4 @@
1
+ import { type Breakpoint } from './breakpoints.js';
2
+ export declare function dialogSheetClass(sheetBelow: Breakpoint): string;
1
3
  export declare function cssDialogClasses(): string;
2
4
  export declare function dialogClassCatalog(): string[];
@@ -1,3 +1,112 @@
1
+ import { typographyVariants } from '@scalewing/tokens';
2
+ import { breakpointQuery, breakpoints, } from './breakpoints.js';
3
+ import { coarsePointerQuery, touchTarget } from './touch-target.js';
4
+ const title = typographyVariants.title;
5
+ /* The modal's height cap: a space-8 strip of backdrop stays in view. */
6
+ const maxHeight = 'min(100vh - var(--sw-space-8), 100dvh - var(--sw-space-8))';
7
+ export function dialogSheetClass(sheetBelow) {
8
+ return `sw-dialog-sheet-below-${sheetBelow}`;
9
+ }
10
+ /*
11
+ * Below the breakpoint the dialog is a bottom sheet: docked to the bottom
12
+ * edge at the full viewport width, the top corners rounded and the bottom
13
+ * ones square, the same height cap (so the whole space-8 strip of backdrop
14
+ * shows above it), and padding that clears the safe areas. Emitted after
15
+ * `sw-dialog-lg` so it wins at equal specificity. No grabber: there is no
16
+ * drag gesture.
17
+ */
18
+ function sheetRules() {
19
+ return breakpoints
20
+ .map((breakpoint) => `@media ${breakpointQuery('below', breakpoint)} {
21
+ .${dialogSheetClass(breakpoint)} {
22
+ border-radius: var(--sw-radius-lg) var(--sw-radius-lg) 0 0;
23
+ border-width: 1px 0 0;
24
+ margin: auto 0 0;
25
+ max-height: ${maxHeight};
26
+ max-width: none;
27
+ padding-bottom: calc(var(--sw-space-5) + env(safe-area-inset-bottom, 0px));
28
+ padding-left: max(var(--sw-space-5), env(safe-area-inset-left, 0px));
29
+ padding-right: max(var(--sw-space-5), env(safe-area-inset-right, 0px));
30
+ width: 100%;
31
+ }
32
+
33
+ .${dialogSheetClass(breakpoint)}[open] {
34
+ animation: sw-dialog-sheet-in var(--sw-motion-default) var(--sw-motion-easing);
35
+ }
36
+ }`)
37
+ .join('\n\n');
38
+ }
39
+ function sheetMotionRules() {
40
+ const still = breakpoints
41
+ .map((breakpoint) => ` .${dialogSheetClass(breakpoint)}[open] { animation: none; }`)
42
+ .join('\n');
43
+ return `@keyframes sw-dialog-sheet-in {
44
+ from { translate: 0 100%; }
45
+ }
46
+
47
+ @media (prefers-reduced-motion: reduce) {
48
+ ${still}
49
+ }`;
50
+ }
51
+ /*
52
+ * The title row when the dialog has a close button: the title takes the
53
+ * row and the button ends it. The button's negative margins keep the row
54
+ * as tall as one title line and put the glyph's end on the padding edge,
55
+ * at the sm control size and at the 44 px touch target alike.
56
+ */
57
+ function closeButtonRules() {
58
+ const square = (size) => `min-width: ${size};
59
+ margin-block: calc((${title.lineHeight}px - ${size}) / 2);
60
+ margin-inline-end: calc((var(--sw-space-4) - ${size}) / 2);`;
61
+ return `.sw-dialog-header {
62
+ align-items: flex-start;
63
+ display: flex;
64
+ gap: var(--sw-space-2);
65
+ }
66
+
67
+ .sw-dialog-header > :first-child {
68
+ flex: 1 1 auto;
69
+ min-width: 0;
70
+ }
71
+
72
+ .sw-button.sw-dialog-close {
73
+ flex: none;
74
+ padding-inline: 0;
75
+ ${square('var(--sw-control-sm-min-height)')}
76
+ }
77
+
78
+ @media ${coarsePointerQuery} {
79
+ .sw-button.sw-dialog-close {
80
+ min-height: ${touchTarget};
81
+ ${square(touchTarget)}
82
+ }
83
+ }
84
+
85
+ /* Private control chrome: two strokes crossed in a space-4 square, in the
86
+ button's color; borders, so forced colors keep them. */
87
+ .sw-dialog-close-glyph {
88
+ flex: none;
89
+ height: var(--sw-space-4);
90
+ position: relative;
91
+ width: var(--sw-space-4);
92
+ }
93
+
94
+ .sw-dialog-close-glyph::before,
95
+ .sw-dialog-close-glyph::after {
96
+ border-top: 2px solid currentColor;
97
+ border-radius: 1px;
98
+ box-sizing: border-box;
99
+ content: '';
100
+ left: 0;
101
+ margin-top: -1px;
102
+ position: absolute;
103
+ top: 50%;
104
+ width: 100%;
105
+ }
106
+
107
+ .sw-dialog-close-glyph::before { transform: rotate(45deg); }
108
+ .sw-dialog-close-glyph::after { transform: rotate(-45deg); }`;
109
+ }
1
110
  export function cssDialogClasses() {
2
111
  return `.sw-dialog {
3
112
  background: var(--sw-glass-fill);
@@ -9,7 +118,7 @@ export function cssDialogClasses() {
9
118
  box-sizing: border-box;
10
119
  color: var(--sw-color-text);
11
120
  margin: auto;
12
- max-height: min(100vh - var(--sw-space-8), 100dvh - var(--sw-space-8));
121
+ max-height: ${maxHeight};
13
122
  max-width: min(var(--sw-dialog-max), calc(100vw - var(--sw-space-8)));
14
123
  overflow: auto;
15
124
  width: calc(100% - var(--sw-space-8));
@@ -19,6 +128,10 @@ export function cssDialogClasses() {
19
128
  max-width: min(var(--sw-dialog-max-lg), calc(100vw - var(--sw-space-8)));
20
129
  }
21
130
 
131
+ ${sheetRules()}
132
+
133
+ ${sheetMotionRules()}
134
+
22
135
  .sw-dialog::backdrop {
23
136
  background: color-mix(in srgb, var(--sw-color-text) 28%, transparent);
24
137
  }
@@ -26,8 +139,17 @@ export function cssDialogClasses() {
26
139
  .sw-dialog:focus-visible {
27
140
  outline: var(--sw-focus-ring-width) solid var(--sw-color-accent);
28
141
  outline-offset: var(--sw-focus-ring-offset);
29
- }`;
142
+ }
143
+
144
+ ${closeButtonRules()}`;
30
145
  }
31
146
  export function dialogClassCatalog() {
32
- return ['sw-dialog', 'sw-dialog-lg'];
147
+ return [
148
+ 'sw-dialog',
149
+ 'sw-dialog-lg',
150
+ ...breakpoints.map((breakpoint) => dialogSheetClass(breakpoint)),
151
+ 'sw-dialog-header',
152
+ 'sw-dialog-close',
153
+ 'sw-dialog-close-glyph',
154
+ ];
33
155
  }
@@ -1,3 +1,4 @@
1
+ export declare const stuckCanvasShare = 0.9;
1
2
  /** The tab strip: a scrollable row of tabs with an accent underline under the current one. */
2
3
  export declare function cssTabsClasses(): string;
3
4
  export declare function tabsClassCatalog(): string[];
@@ -1,5 +1,37 @@
1
1
  import { typographyVariants } from '@scalewing/tokens';
2
+ import { zIndex } from './stacking.js';
2
3
  const label = typographyVariants.label;
4
+ /*
5
+ * The stuck strip is a full-bleed band of the page canvas, not a glass card:
6
+ * nine parts canvas color to one part see-through, with the glass blur
7
+ * behind it, so a panel scrolling under it shows as a faint smear, never as
8
+ * words beside the labels. At nine parts the muted labels keep at least
9
+ * 4.1:1, and the accent label 3.6:1, in every palette even over a solid
10
+ * block of the text, accent or danger color; over the canvas and ordinary
11
+ * type, which the blur spreads, they keep their canvas contrast. The first
12
+ * tab's own inline padding (the md control's, spacing step 4) lines its
13
+ * label up with a space-4 page gutter, so the strip adds no padding of its
14
+ * own. It rides the AppHeader's layer.
15
+ */
16
+ export const stuckCanvasShare = 0.9;
17
+ function stickyRules() {
18
+ return `.sw-tabs-sticky {
19
+ background: color-mix(in srgb, var(--sw-color-background) ${stuckCanvasShare * 100}%, transparent);
20
+ backdrop-filter: blur(var(--sw-glass-blur)) saturate(var(--sw-glass-saturate));
21
+ -webkit-backdrop-filter: blur(var(--sw-glass-blur)) saturate(var(--sw-glass-saturate));
22
+ position: sticky;
23
+ top: env(safe-area-inset-top, 0px);
24
+ ${zIndex('topChrome')}
25
+ }
26
+
27
+ @media (prefers-reduced-transparency: reduce) {
28
+ .sw-tabs-sticky {
29
+ background: var(--sw-color-background);
30
+ backdrop-filter: none;
31
+ -webkit-backdrop-filter: none;
32
+ }
33
+ }`;
34
+ }
3
35
  /** The tab strip: a scrollable row of tabs with an accent underline under the current one. */
4
36
  export function cssTabsClasses() {
5
37
  return `.sw-tabs {
@@ -55,10 +87,19 @@ export function cssTabsClasses() {
55
87
  outline-offset: var(--sw-focus-ring-offset);
56
88
  }
57
89
 
90
+ ${stickyRules()}
91
+
58
92
  @media (forced-colors: active) {
59
93
  .sw-tab-selected { border-bottom-color: Highlight; color: Highlight; }
94
+ .sw-tabs-sticky { background: Canvas; backdrop-filter: none; -webkit-backdrop-filter: none; }
60
95
  }`;
61
96
  }
62
97
  export function tabsClassCatalog() {
63
- return ['sw-tabs', 'sw-tab', 'sw-tab-selected', 'sw-tab-panel'];
98
+ return [
99
+ 'sw-tabs',
100
+ 'sw-tabs-sticky',
101
+ 'sw-tab',
102
+ 'sw-tab-selected',
103
+ 'sw-tab-panel',
104
+ ];
64
105
  }
@@ -10,8 +10,9 @@
10
10
  * its popup can only paint as high as the surface does; lifting the surface
11
11
  * over the ActionBar keeps an open list tappable where the bar would cover
12
12
  * it, and over the next surface in the flow.
13
- * 4. `topChrome`: the sticky AppHeader, over a lifted surface that scrolls up
14
- * under it (the header is the page's way back, and a popup opens downward).
13
+ * 4. `topChrome`: the sticky AppHeader or sticky Tabs strip, over a lifted
14
+ * surface that scrolls up under it (the header is the page's way back, the
15
+ * strip its way between sections, and a popup opens downward).
15
16
  * 5. `popup`: a popup outside any lifted surface (Select list, ActionMenu
16
17
  * list, Tooltip, the DateField and CalendarButton calendar) over
17
18
  * everything in the page.
package/dist/styles.css CHANGED
@@ -1890,6 +1890,32 @@ body {
1890
1890
  max-width: min(var(--sw-dialog-max-lg), calc(100vw - var(--sw-space-8)));
1891
1891
  }
1892
1892
 
1893
+ @media not all and (min-width: 48rem) {
1894
+ .sw-dialog-sheet-below-md {
1895
+ border-radius: var(--sw-radius-lg) var(--sw-radius-lg) 0 0;
1896
+ border-width: 1px 0 0;
1897
+ margin: auto 0 0;
1898
+ max-height: min(100vh - var(--sw-space-8), 100dvh - var(--sw-space-8));
1899
+ max-width: none;
1900
+ padding-bottom: calc(var(--sw-space-5) + env(safe-area-inset-bottom, 0px));
1901
+ padding-left: max(var(--sw-space-5), env(safe-area-inset-left, 0px));
1902
+ padding-right: max(var(--sw-space-5), env(safe-area-inset-right, 0px));
1903
+ width: 100%;
1904
+ }
1905
+
1906
+ .sw-dialog-sheet-below-md[open] {
1907
+ animation: sw-dialog-sheet-in var(--sw-motion-default) var(--sw-motion-easing);
1908
+ }
1909
+ }
1910
+
1911
+ @keyframes sw-dialog-sheet-in {
1912
+ from { translate: 0 100%; }
1913
+ }
1914
+
1915
+ @media (prefers-reduced-motion: reduce) {
1916
+ .sw-dialog-sheet-below-md[open] { animation: none; }
1917
+ }
1918
+
1893
1919
  .sw-dialog::backdrop {
1894
1920
  background: color-mix(in srgb, var(--sw-color-text) 28%, transparent);
1895
1921
  }
@@ -1899,6 +1925,59 @@ body {
1899
1925
  outline-offset: var(--sw-focus-ring-offset);
1900
1926
  }
1901
1927
 
1928
+ .sw-dialog-header {
1929
+ align-items: flex-start;
1930
+ display: flex;
1931
+ gap: var(--sw-space-2);
1932
+ }
1933
+
1934
+ .sw-dialog-header > :first-child {
1935
+ flex: 1 1 auto;
1936
+ min-width: 0;
1937
+ }
1938
+
1939
+ .sw-button.sw-dialog-close {
1940
+ flex: none;
1941
+ padding-inline: 0;
1942
+ min-width: var(--sw-control-sm-min-height);
1943
+ margin-block: calc((28px - var(--sw-control-sm-min-height)) / 2);
1944
+ margin-inline-end: calc((var(--sw-space-4) - var(--sw-control-sm-min-height)) / 2);
1945
+ }
1946
+
1947
+ @media (pointer: coarse) {
1948
+ .sw-button.sw-dialog-close {
1949
+ min-height: var(--sw-control-md-min-height);
1950
+ min-width: var(--sw-control-md-min-height);
1951
+ margin-block: calc((28px - var(--sw-control-md-min-height)) / 2);
1952
+ margin-inline-end: calc((var(--sw-space-4) - var(--sw-control-md-min-height)) / 2);
1953
+ }
1954
+ }
1955
+
1956
+ /* Private control chrome: two strokes crossed in a space-4 square, in the
1957
+ button's color; borders, so forced colors keep them. */
1958
+ .sw-dialog-close-glyph {
1959
+ flex: none;
1960
+ height: var(--sw-space-4);
1961
+ position: relative;
1962
+ width: var(--sw-space-4);
1963
+ }
1964
+
1965
+ .sw-dialog-close-glyph::before,
1966
+ .sw-dialog-close-glyph::after {
1967
+ border-top: 2px solid currentColor;
1968
+ border-radius: 1px;
1969
+ box-sizing: border-box;
1970
+ content: '';
1971
+ left: 0;
1972
+ margin-top: -1px;
1973
+ position: absolute;
1974
+ top: 50%;
1975
+ width: 100%;
1976
+ }
1977
+
1978
+ .sw-dialog-close-glyph::before { transform: rotate(45deg); }
1979
+ .sw-dialog-close-glyph::after { transform: rotate(-45deg); }
1980
+
1902
1981
  .sw-accordion {
1903
1982
  background: var(--sw-glass-fill);
1904
1983
  border: 1px solid var(--sw-glass-border);
@@ -2272,14 +2351,21 @@ body {
2272
2351
  padding-right: max(var(--sw-space-4), env(safe-area-inset-right, 0px));
2273
2352
  }
2274
2353
 
2354
+ /* A Badge and a short line sit on one row a small gap apart; a line that
2355
+ does not fit beside the badge wraps under it. */
2275
2356
  .sw-action-bar-status {
2357
+ align-items: center;
2276
2358
  color: var(--sw-color-muted);
2359
+ column-gap: var(--sw-space-2);
2360
+ display: flex;
2277
2361
  flex: 1 1 auto;
2362
+ flex-wrap: wrap;
2278
2363
  font-family: var(--sw-font-sans);
2279
2364
  font-size: 13px;
2280
2365
  line-height: 18px;
2281
2366
  margin: 0;
2282
2367
  min-width: 0;
2368
+ row-gap: var(--sw-space-1);
2283
2369
  }
2284
2370
 
2285
2371
  /* With no status the live region stays in the page, so a first status is
@@ -2674,8 +2760,26 @@ body {
2674
2760
  outline-offset: var(--sw-focus-ring-offset);
2675
2761
  }
2676
2762
 
2763
+ .sw-tabs-sticky {
2764
+ background: color-mix(in srgb, var(--sw-color-background) 90%, transparent);
2765
+ backdrop-filter: blur(var(--sw-glass-blur)) saturate(var(--sw-glass-saturate));
2766
+ -webkit-backdrop-filter: blur(var(--sw-glass-blur)) saturate(var(--sw-glass-saturate));
2767
+ position: sticky;
2768
+ top: env(safe-area-inset-top, 0px);
2769
+ z-index: 4;
2770
+ }
2771
+
2772
+ @media (prefers-reduced-transparency: reduce) {
2773
+ .sw-tabs-sticky {
2774
+ background: var(--sw-color-background);
2775
+ backdrop-filter: none;
2776
+ -webkit-backdrop-filter: none;
2777
+ }
2778
+ }
2779
+
2677
2780
  @media (forced-colors: active) {
2678
2781
  .sw-tab-selected { border-bottom-color: Highlight; color: Highlight; }
2782
+ .sw-tabs-sticky { background: Canvas; backdrop-filter: none; -webkit-backdrop-filter: none; }
2679
2783
  }
2680
2784
 
2681
2785
  .sw-filter-chips {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scalewing/react",
3
- "version": "1.17.0",
3
+ "version": "1.19.0",
4
4
  "description": "Scalewing DOM components and package-owned CSS utilities.",
5
5
  "type": "module",
6
6
  "license": "MIT",