@scalewing/react 1.45.0 → 1.46.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 +9 -0
- package/README.md +46 -1
- package/dist/components/Button.d.ts +14 -1
- package/dist/components/Button.js +5 -2
- package/dist/components/FileField.d.ts +18 -2
- package/dist/components/FileField.js +26 -22
- package/dist/components/ListGroup.d.ts +8 -3
- package/dist/components/ListGroup.js +13 -5
- package/dist/components/Select.d.ts +8 -5
- package/dist/components/Select.js +7 -4
- package/dist/components/file-field/ChosenFile.d.ts +45 -0
- package/dist/components/file-field/ChosenFile.js +48 -0
- package/dist/components/file-field/file-field-checks.d.ts +5 -2
- package/dist/components/file-field/file-field-checks.js +7 -2
- package/dist/components/select/select-naming.d.ts +34 -0
- package/dist/components/select/select-naming.js +22 -0
- package/dist/css/css-button-width.d.ts +11 -0
- package/dist/css/css-button-width.js +36 -0
- package/dist/css/css-button.js +3 -0
- package/dist/css/css-file-field-compact.d.ts +2 -0
- package/dist/css/css-file-field-compact.js +106 -0
- package/dist/css/css-file-field.js +4 -0
- package/dist/css/css-list-tiles.d.ts +6 -0
- package/dist/css/css-list-tiles.js +50 -0
- package/dist/css/css-list.js +5 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2 -0
- package/dist/list-options.d.ts +12 -5
- package/dist/list-options.js +19 -5
- package/dist/styles.css +115 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# @scalewing/react
|
|
2
2
|
|
|
3
|
+
## 1.46.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- f93ec57: `Button` `width`: `'content'` (the default, as before) or `'full'`, which fills the container's inline size in a `Stack`, a `Grid` cell or a column, and in an `Inline` row takes the space its siblings leave (as a full `Select` does; in a wrapping row it grows from its words and wraps), such as an Edit beside a ••• `ActionMenu` that keeps its own square at the row's end. The words stay centred. An unknown width throws a `RangeError`. New class `sw-button-full`; `ButtonWidth` and `buttonWidths` are exported. No new tokens or dependencies. Request: `docs/requests/teisoro-button-full-width.md`.
|
|
8
|
+
- 58bcede: `FileField` `onRemove` and `removeLabel`: Remove beside a chosen file, a neutral button after the change button in the full field, named by its words and the label ("Remove Cover photo"); the consumer clears `value` and focus moves to the empty field. `FileField` `compact`: the field at a text field's height for a form row; chosen, the thumbnail (or mark) and the name cut to one line are the change button, named by the label and `changeLabel` and described by the name and detail (read, not drawn), with Remove as a quiet button at the row's end. Set `onRemove` and `removeLabel` together (a `RangeError` otherwise, and for blank words). New classes `sw-file-field-compact`, `sw-file-field-pick`, `sw-file-field-remove` and `sw-file-field-actions`; disabled, the compact row and its Remove fade alike. A field without them renders as before. No new tokens or dependencies. Request: `docs/requests/teisoro-file-field-compact.md`.
|
|
9
|
+
- 0441a50: `ListGroup` `variant="tiles"`: the same one list (its name, its items and their count) with its rows laid out as tiles, each row its own bordered panel on the surface and no hairline between them, in `tileColumns` equal columns (2, 3 by default, or 4) a space-3 apart from `md` up, and one a row below it whatever `tileColumns` says, such as a strip of new arrivals. A row's press fill and inset focus ring stay inside its tile. Tiles take no `maxHeight` or `footer`, and `tileColumns` needs `tiles` (a `RangeError`, as for a count other than 2, 3 or 4). New classes `sw-list-group-tiles` and `sw-list-group-tiles-2`, `-3`, `-4`; `listTileColumnCounts` and `ListGroupTileColumns` are exported. No new tokens or dependencies. Request: `docs/requests/teisoro-list-group-tiles.md`.
|
|
10
|
+
- ccc2f79: `Select` `aria-labelledby`: in place of `label`, the id of words the page already draws, such as a label above a choice that is a `SegmentedControl` on a wide screen and a `Select` on a phone. The select then draws no label of its own, so a screen reader meets the words once instead of the page's words followed by the select's hidden copy; the trigger and its open list are named by them. `required` still sets `aria-required` and draws no mark. `label` and `aria-labelledby` are exclusive (a type union, and a `RangeError` for neither, a blank id, both, or `requiredMark` with `aria-labelledby`). A select with `label` renders exactly as before. **Types:** `SelectProps` is now a union (`SelectNaming`), so `label` is optional on the type. Code that reads `props.label` from a `SelectProps` narrows it first, for example a wrapper typed `SelectProps & { label: string }`, or a check `if (props.label !== undefined)`. No new classes, tokens or dependencies. Request: `docs/requests/teisoro-select-labelledby.md`.
|
|
11
|
+
|
|
3
12
|
## 1.45.0
|
|
4
13
|
|
|
5
14
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -144,6 +144,23 @@ A disabled native text control (`<input>`, `<select>` or `<textarea>` on the can
|
|
|
144
144
|
|
|
145
145
|
`FileField` is one file chosen with the system picker or dropped on the field, with `Field`'s `label` (above it, or hidden with `labelVisuallyHidden`), `description` and `error` (a polite live region in place of the hint, the frame's edge in the danger color, `Mark` in forced colors). Empty, it is one wide zone on the subtle fill, a button named by the label and `chooseLabel` ("Field guide Choose a PDF"), with `dropLabel` ("or drop it here") muted after it where the pointer can drag (left out on a coarse pointer). Chosen (`value={{ name, detail? }}`), it shows the consumer's `icon` (hidden from assistive technology), the file's name over the muted `detail` and a neutral button named by the label and `changeLabel`, described by the name and detail. Either button opens the picker (the native input is hidden and out of the Tab order, its `accept` the list the picker offers); `onChange` receives the `File` chosen or dropped, the first of several, and when a choice or a drop swaps the zone and the chosen row while focus is in the field, focus moves to the button now there (focus the person moved elsewhere is never taken back). A file dragged over the field draws the accent edge and tint, its words in the text color. It never reads, checks or uploads the file, and a dropped file is not filtered by `accept`: check what you receive (type, size, its header) and say why in `error`, keeping the earlier `value` or none. `disabled` blocks the picker, refuses a dropped file and dashes the frame. `id` prefixes the ids of the field's parts. An empty `label`, `chooseLabel` or `changeLabel`, a blank `dropLabel`, or a `value` with no name or a blank detail throws a `RangeError`.
|
|
146
146
|
|
|
147
|
+
`FileField` `onRemove` with `removeLabel` (set both or neither) adds Remove beside a chosen file: a neutral button after the change button in the full field (the two together at the row's end), named by its words and the label ("Remove Cover photo"), so two fields' Removes are told apart. It calls `onRemove`, and the consumer clears `value`; focus then moves to the empty field's button. Nothing is removed while the field is empty, and `disabled` disables it too. A lone `onRemove` or `removeLabel`, or blank `removeLabel` words, throws a `RangeError`.
|
|
148
|
+
|
|
149
|
+
`FileField` `compact` draws the field at a text field's height (the md control, 44 px) for a form row beside other fields, such as a cover photo beside a title. Empty, the zone holds the choose words at its start. Chosen, the row is one button, the thumbnail (or the mark) and the file's name in the body weight cut to one line with an ellipsis, named by the label and `changeLabel` ("Cover photo Choose another") and described by the name and `detail` (read, not drawn); Remove, a quiet ghost button, ends the row. The label, description and error are as in the full field.
|
|
150
|
+
|
|
151
|
+
```tsx
|
|
152
|
+
<FileField
|
|
153
|
+
label="Cover photo"
|
|
154
|
+
compact
|
|
155
|
+
chooseLabel="Choose a photo"
|
|
156
|
+
changeLabel="Choose another"
|
|
157
|
+
removeLabel="Remove"
|
|
158
|
+
value={cover}
|
|
159
|
+
onChange={choose}
|
|
160
|
+
onRemove={() => setCover(null)}
|
|
161
|
+
/>
|
|
162
|
+
```
|
|
163
|
+
|
|
147
164
|
A chosen photo can show itself: `value.preview` is the address of a small image of it, such as an object URL the consumer made from the `File` (and revokes when another is chosen), drawn as a 3:2 thumbnail (a space-8 wide, a space-6 tall, cropped to fill, at the small radius) before the name in place of `icon`. It is decorative (`alt=""`): the name beside it says which file it is. A blank `preview` throws a `RangeError`.
|
|
148
165
|
|
|
149
166
|
```tsx
|
|
@@ -178,6 +195,18 @@ A chosen photo can show itself: `value.preview` is the address of a small image
|
|
|
178
195
|
|
|
179
196
|
`Select` is a labeled listbox menu: supply `label`, `options` (`value` and `label`), a controlled `value` and `onChange`; `size="xs"` and `labelVisuallyHidden` are for toolbar chrome, and `action` is a last listbox command that never becomes the value. The closed trigger matches `Field`: its text is centred in the control, it ends in the `Accordion` chevron, and it is as wide as its longest option (as a native select is), so it keeps its width when the value changes; past the available width the label ellipsizes. The option labels that size it are drawn by CSS from `data-label`, so the trigger's text content and accessible value are only the current label. Its label is drawn as `Field`'s (the same label words and mark inside a `<label>` that keeps the canvas type), so a `Select` beside a `Field` lines up at the label and the control.
|
|
180
197
|
|
|
198
|
+
`Select` `aria-labelledby`, in place of `label`, names the select by words the page already draws, such as a label above a choice that is a `SegmentedControl` on a wide screen and a `Select` on a phone. The select then draws no label of its own, hidden or shown, so a screen reader meets the words once (as the page's text, then as the combobox's name) instead of the page's words followed by the select's hidden copy of them; the open list is named by the same words. `required` still sets `aria-required` but draws no mark, since the page owns the words. `label` and `aria-labelledby` are exclusive: a blank `label` with no `aria-labelledby`, a blank `aria-labelledby`, or `aria-labelledby` with `label`, `labelVisuallyHidden` or `requiredMark` throws a `RangeError`. The id must name an element in the page while the select is shown: a missing one leaves the select unnamed, which neither the types nor the select can check.
|
|
199
|
+
|
|
200
|
+
```tsx
|
|
201
|
+
<Text as="span" id="den-habitat" variant="label">Den habitat</Text>
|
|
202
|
+
<Select
|
|
203
|
+
aria-labelledby="den-habitat"
|
|
204
|
+
value={habitat}
|
|
205
|
+
onChange={setHabitat}
|
|
206
|
+
options={habitats}
|
|
207
|
+
/>
|
|
208
|
+
```
|
|
209
|
+
|
|
181
210
|
`Select` `placeholder` shows in the closed trigger, in the muted color, while `value` matches no option (such as `''`); it is not an option, is never selected in the list and never becomes the value, and it counts toward the trigger's width. `required` marks the label with `Field`'s `aria-hidden` asterisk and sets `aria-required` on the trigger. `error` renders a message under the control (`sw-field-error`), links it by `aria-describedby`, sets `aria-invalid` and the danger border; like `Field`'s it is announced politely from a live region that is always there, never as an alert, and an empty string is no error. A blank `placeholder` throws a `RangeError`.
|
|
182
211
|
|
|
183
212
|
```tsx
|
|
@@ -269,6 +298,22 @@ A `Button`'s own children are laid out in a row one token gap apart (spacing ste
|
|
|
269
298
|
|
|
270
299
|
On the web, `variant="neutral"` is a quiet grey fill (the muted color at 16 %, the fill of an open menu row) with its words in the text color and no border: an action beside the primary that should not draw the eye, such as a sheet's Cancel. It is web only (`webButtonVariants`; the shared `buttonVariants` in `@scalewing/tokens` and React Native do not have it). `disabledAppearance="neutral"` (default `"faded"`) draws a disabled button on that grey with its words in the muted color at 48 % (about #AEAEAE on the grey) and full opacity, whatever its variant, instead of fading its own fill (a disabled accent reads as a paler accent); a `ghost` button, which has no fill, still fades. The button is still a disabled `button`; forced colors draw it as any disabled button. An unknown `disabledAppearance` throws a `RangeError`.
|
|
271
300
|
|
|
301
|
+
`Button` `width` is `'content'` (the default: as wide as its words) or `'full'`: the button fills its container's inline size in a `Stack`, a `Grid` cell or any column, and in an `Inline` row takes the space its siblings leave, as a full `Select` does, so an Edit beside a ••• `ActionMenu` fills the row and the menu keeps its own square at the end. The row rule holds for a `Button` that is a direct child of the `Inline`; wrapped in another box it fills that box instead. In a wrapping `Inline` (`wrap`) a full button grows from its words' width and moves to a line of its own when the row runs out, rather than shrinking past its words. Inside a `ButtonGroup` it is not needed: the group already stacks its buttons full width below `md`. The words stay centred. An unknown width throws a `RangeError`. The class is `sw-button-full`.
|
|
302
|
+
|
|
303
|
+
```tsx
|
|
304
|
+
<Inline gap={2}>
|
|
305
|
+
<Button variant="neutral" width="full" onPress={edit}>
|
|
306
|
+
Edit
|
|
307
|
+
</Button>
|
|
308
|
+
<ActionMenu
|
|
309
|
+
label="More for Heron log"
|
|
310
|
+
items={items}
|
|
311
|
+
trigger={<Dots />}
|
|
312
|
+
triggerAppearance="icon"
|
|
313
|
+
/>
|
|
314
|
+
</Inline>
|
|
315
|
+
```
|
|
316
|
+
|
|
272
317
|
A `Button` with `aria-disabled="true"` (not `disabled`) looks disabled, faded or, with `disabledAppearance="neutral"`, on the neutral grey, with `GrayText` in forced colors, yet stays a focusable, pressable `button`: its `onPress` still runs, so a submit pressed while off can take the person to what is missing. Its look alone does not say what is missing: a press while off should show the message of the first thing to fix. Use `disabled` when a press should do nothing.
|
|
273
318
|
|
|
274
319
|
A `Button` that toggles something passes `aria-pressed`. The pressed button gets a 2 px accent ring outside its fill, past a 2 px gap in `--sw-color-background`, so the ring meets only the page and keeps the accent's contrast on the canvas (4.5:1 or more in every palette, at least the 3:1 a state indicator needs) whatever its `variant` or fill. A focused pressed button moves its focus outline out past the ring. In forced colors, which drop shadows, the pressed button keeps the forced button colors, so its label and any glyph or badge inside it stay readable, and draws the same ring and its own border in the system highlight (`Highlight`); nothing opts out of forced colors. An unpressed button is drawn at full strength, so its label keeps its contrast. To make the choice stand out further, give the pressed button `primary` and the others `secondary`.
|
|
@@ -840,7 +885,7 @@ const { headings, omissions } = inspectMarkdown(page.body, {
|
|
|
840
885
|
</Card>
|
|
841
886
|
```
|
|
842
887
|
|
|
843
|
-
`ListGroup` is rows in one list with a hairline between each pair, the web twin of react-native's: a `ul` (its list role kept) named by `aria-label` or `aria-labelledby`. `variant="panel"` (the default) is one bordered panel; `plain` draws no panel and a hairline above the first row too, for rows inside a `Card`. Each `ListRow` has a `title` (weight 600, or the body weight with `titleAppearance="plain"`; `tone="danger"` for a row that ends something), an optional muted `detail` line, an optional `value` at its end (weight 600, tabular figures; `valueTone` `success`, `danger`, `muted`, `accent` or `warning`, the warning colour's ink moved halfway to the text colour so it keeps 4.5:1 on the surface in every palette; `valueBadge` draws the value as a `Badge` in that tone instead, such as a status, still read in the row's name, never with `valueTone`; `valueDetail` a muted line under it) and an optional `leading` mark such as a status dot, hidden from assistive technology, so the words say what it means. With `onPress` the whole row is a button, and with `href` a link (`onPress` then takes a plain press for a client router); either adds a chevron, a quiet fill under the pointer and 44 px on a coarse pointer, and the row is named by its title and value and described by its detail lines (`aria-label` replaces the name). `selected` makes a pressable row a choice: a check while true, `aria-pressed` on a button or `aria-current` on a link (on a row with neither `onPress` nor `href` it throws, since nothing could read the state). `accessory="none"` hides the chevron of a row that acts in place; `disabled` blocks presses. A row with neither `onPress` nor `href` only states something. `maxHeight={{ rows }}` bounds the list to that many rows of a title and one detail line, the rest scrolling inside its own named region, which shades an edge with more rows past it; a bounded list must be named. `footer` stays pinned under the rows, outside the scroll, such as a `ListRow` with the count and the total (a row there is not a list item). An empty title or value, an unknown variant, tone or badge tone, a `valueDetail`, `valueTone` or `valueBadge` without a value, a value both toned and a badge, or a bad row count throws a `RangeError`.
|
|
888
|
+
`ListGroup` is rows in one list with a hairline between each pair, the web twin of react-native's: a `ul` (its list role kept) named by `aria-label` or `aria-labelledby`. `variant="panel"` (the default) is one bordered panel; `plain` draws no panel and a hairline above the first row too, for rows inside a `Card`. `variant="tiles"` keeps one list (its name, its list items, a screen reader's count of them) and lays its rows out as tiles: each row its own bordered panel on the surface with no hairline between them, in `tileColumns` equal columns (`2`, `3` the default, or `4`; `listTileColumnCounts`, `ListGroupTileColumns`) a spacing step 3 apart from `md` up, one a row below it, such as a strip of new arrivals under its heading. A row's press fill and inset focus ring stay inside its tile; `truncate` keeps a long title to one line in a narrow tile. Tiles take no `maxHeight` or `footer`, and `tileColumns` needs `tiles`; either throws a `RangeError`, as does a count other than 2, 3 or 4. Each `ListRow` has a `title` (weight 600, or the body weight with `titleAppearance="plain"`; `tone="danger"` for a row that ends something), an optional muted `detail` line, an optional `value` at its end (weight 600, tabular figures; `valueTone` `success`, `danger`, `muted`, `accent` or `warning`, the warning colour's ink moved halfway to the text colour so it keeps 4.5:1 on the surface in every palette; `valueBadge` draws the value as a `Badge` in that tone instead, such as a status, still read in the row's name, never with `valueTone`; `valueDetail` a muted line under it) and an optional `leading` mark such as a status dot, hidden from assistive technology, so the words say what it means. With `onPress` the whole row is a button, and with `href` a link (`onPress` then takes a plain press for a client router); either adds a chevron, a quiet fill under the pointer and 44 px on a coarse pointer, and the row is named by its title and value and described by its detail lines (`aria-label` replaces the name). `selected` makes a pressable row a choice: a check while true, `aria-pressed` on a button or `aria-current` on a link (on a row with neither `onPress` nor `href` it throws, since nothing could read the state). `accessory="none"` hides the chevron of a row that acts in place; `disabled` blocks presses. A row with neither `onPress` nor `href` only states something. `maxHeight={{ rows }}` bounds the list to that many rows of a title and one detail line, the rest scrolling inside its own named region, which shades an edge with more rows past it; a bounded list must be named. `footer` stays pinned under the rows, outside the scroll, such as a `ListRow` with the count and the total (a row there is not a list item). An empty title or value, an unknown variant, tone or badge tone, a `valueDetail`, `valueTone` or `valueBadge` without a value, a value both toned and a badge, or a bad row count throws a `RangeError`.
|
|
844
889
|
|
|
845
890
|
A row can also carry three trailing parts. `unread` marks it new for the person, such as a document they have not opened: an accent dot after the title's last word (drawn as a border, so forced colors keep it in the text color), hidden from assistive technology, and `unreadLabel` (required while `unread`, such as "New") is read after the title, in a pressable row's name ("Heron calls New"). `tag` is a short status before the row's end, such as a `Badge` ("Only in English"), centred on the row and never wrapped; a pressable row is described by it after its detail lines. `action` is a control after the row and outside its press area, such as an `ActionMenu` of the row's own commands named by the consumer ("More for Heron calls"): it is the row's sibling in the list item, centred on the row, after it in the Tab order, and a press on it never opens the row; it keeps the row's end inset (a space-4 in a panel or outside a ListGroup, none in a plain list), and where `:has` is supported the hover fill spans the row and its control. An empty slot (`undefined`, `null`, `false` or `''`, as conditional JSX gives) draws nothing, for `leading` too. Pair `action` with `accessory="none"` when the row's own chevron would sit beside it. An unread row without `unreadLabel`, or a blank one, throws a `RangeError`. An `aria-label` replaces the whole name, unread words included.
|
|
846
891
|
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { type ButtonSize } from '@scalewing/tokens';
|
|
2
2
|
import { type ButtonDisabledAppearance, type WebButtonVariant } from '../css/css-button.js';
|
|
3
|
+
import { type ButtonWidth } from '../css/css-button-width.js';
|
|
3
4
|
import { type ButtonHTMLAttributes, type ReactNode } from 'react';
|
|
4
5
|
/** The web variants: the shared tiers plus `neutral`. */
|
|
5
6
|
export type ButtonVariant = WebButtonVariant;
|
|
6
|
-
export type { ButtonDisabledAppearance, ButtonSize };
|
|
7
|
+
export type { ButtonDisabledAppearance, ButtonSize, ButtonWidth };
|
|
7
8
|
export type ButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'onClick' | 'type'> & {
|
|
8
9
|
children: ReactNode;
|
|
9
10
|
onPress: () => void;
|
|
@@ -21,6 +22,12 @@ export type ButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'onClick
|
|
|
21
22
|
* whatever its variant (a ghost button still fades).
|
|
22
23
|
*/
|
|
23
24
|
disabledAppearance?: ButtonDisabledAppearance;
|
|
25
|
+
/**
|
|
26
|
+
* `content` (default) is as wide as the words; `full` fills the
|
|
27
|
+
* container, and in an Inline row the space its siblings leave, such as
|
|
28
|
+
* an Edit beside a ••• menu.
|
|
29
|
+
*/
|
|
30
|
+
width?: ButtonWidth;
|
|
24
31
|
};
|
|
25
32
|
export declare const Button: import("react").ForwardRefExoticComponent<Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onClick" | "type"> & {
|
|
26
33
|
children: ReactNode;
|
|
@@ -39,4 +46,10 @@ export declare const Button: import("react").ForwardRefExoticComponent<Omit<Butt
|
|
|
39
46
|
* whatever its variant (a ghost button still fades).
|
|
40
47
|
*/
|
|
41
48
|
disabledAppearance?: ButtonDisabledAppearance;
|
|
49
|
+
/**
|
|
50
|
+
* `content` (default) is as wide as the words; `full` fills the
|
|
51
|
+
* container, and in an Inline row the space its siblings leave, such as
|
|
52
|
+
* an Edit beside a ••• menu.
|
|
53
|
+
*/
|
|
54
|
+
width?: ButtonWidth;
|
|
42
55
|
} & import("react").RefAttributes<HTMLButtonElement>>;
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { buttonClassNames, buttonDisabledAppearances, } from '../css/css-button.js';
|
|
3
|
+
import { buttonWidthClass, buttonWidths, } from '../css/css-button-width.js';
|
|
3
4
|
import { forwardRef } from 'react';
|
|
4
5
|
import { cx } from '../class-names.js';
|
|
5
6
|
import { tabStop } from '../tab-stop.js';
|
|
6
|
-
export const Button = forwardRef(function Button({ children, className, disabled = false, disabledAppearance = 'faded', onPress, size = 'md', tabIndex, type = 'button', variant = 'primary', ...rest }, ref) {
|
|
7
|
+
export const Button = forwardRef(function Button({ children, className, disabled = false, disabledAppearance = 'faded', onPress, size = 'md', tabIndex, type = 'button', variant = 'primary', width = 'content', ...rest }, ref) {
|
|
7
8
|
if (!buttonDisabledAppearances.includes(disabledAppearance))
|
|
8
9
|
throw new RangeError(`Unknown Button disabledAppearance: ${String(disabledAppearance)}`);
|
|
9
|
-
|
|
10
|
+
if (!buttonWidths.includes(width))
|
|
11
|
+
throw new RangeError(`Unknown Button width: ${String(width)}`);
|
|
12
|
+
return (_jsx("button", { ref: ref, className: cx(...buttonClassNames({ size, variant, disabledAppearance }), buttonWidthClass(width), className), disabled: disabled, tabIndex: tabIndex ?? tabStop, type: type, ...rest, onClick: onPress, children: children }));
|
|
10
13
|
});
|
|
@@ -42,6 +42,21 @@ export type FileFieldProps = {
|
|
|
42
42
|
*/
|
|
43
43
|
icon?: ReactNode;
|
|
44
44
|
disabled?: boolean;
|
|
45
|
+
/**
|
|
46
|
+
* The field at a text field's height, for a form row beside other
|
|
47
|
+
* fields: empty, one button with `chooseLabel`; chosen, the file's mark
|
|
48
|
+
* and its name cut to one line, pressed to choose another (named by
|
|
49
|
+
* `label` and `changeLabel`), the detail read but not drawn. Default
|
|
50
|
+
* false: the wide zone, and the chosen row with its change button.
|
|
51
|
+
*/
|
|
52
|
+
compact?: boolean;
|
|
53
|
+
/**
|
|
54
|
+
* Called by Remove, drawn beside a chosen file when set with
|
|
55
|
+
* `removeLabel`: the consumer clears `value`. Set both or neither.
|
|
56
|
+
*/
|
|
57
|
+
onRemove?: () => void;
|
|
58
|
+
/** Remove's words, such as "Remove"; named with `label` ("Remove Cover photo"). */
|
|
59
|
+
removeLabel?: string;
|
|
45
60
|
/** Prefixes the ids the field gives its parts (its button is `${id}-button`). */
|
|
46
61
|
id?: string;
|
|
47
62
|
};
|
|
@@ -49,7 +64,8 @@ export type FileFieldProps = {
|
|
|
49
64
|
* One file chosen by a press or dropped on the field: empty, a wide zone
|
|
50
65
|
* that opens the system picker; chosen, the file's name and detail (after
|
|
51
66
|
* a thumbnail for a photo with a `preview`) with a button to choose
|
|
52
|
-
* another
|
|
67
|
+
* another, and Remove with `onRemove`. `compact` draws both at a text
|
|
68
|
+
* field's height. It hands the `File` to the consumer and shows what the
|
|
53
69
|
* consumer passes back as `value`; it never reads or uploads it.
|
|
54
70
|
*/
|
|
55
|
-
export declare function FileField({ label, labelVisuallyHidden, description, error, accept, value, onChange, chooseLabel, changeLabel, dropLabel, icon, disabled, id, }: FileFieldProps): import("react").JSX.Element;
|
|
71
|
+
export declare function FileField({ label, labelVisuallyHidden, description, error, accept, value, onChange, chooseLabel, changeLabel, dropLabel, icon, disabled, compact, onRemove, removeLabel, id, }: FileFieldProps): import("react").JSX.Element;
|
|
@@ -1,39 +1,33 @@
|
|
|
1
1
|
'use client';
|
|
2
|
-
import { jsx as _jsx, jsxs as _jsxs
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { useId, useRef } from 'react';
|
|
4
4
|
import { cx } from '../class-names.js';
|
|
5
5
|
import { tabStop } from '../tab-stop.js';
|
|
6
|
-
import { Button } from './Button.js';
|
|
7
6
|
import { FieldErrorRegion } from './FieldErrorRegion.js';
|
|
8
7
|
import { FieldLabelText } from './FieldLabelText.js';
|
|
8
|
+
import { ChosenFileCompact, ChosenFileRow } from './file-field/ChosenFile.js';
|
|
9
9
|
import { assertFileField } from './file-field/file-field-checks.js';
|
|
10
10
|
import { useFileDrop } from './file-field/use-file-drop.js';
|
|
11
11
|
import { useSwapFocus } from './file-field/use-swap-focus.js';
|
|
12
12
|
import { Stack } from './Stack.js';
|
|
13
|
-
/** Whether the consumer passed a mark to draw (not empty JSX). */
|
|
14
|
-
function hasMark(icon) {
|
|
15
|
-
return (icon !== undefined &&
|
|
16
|
-
icon !== null &&
|
|
17
|
-
icon !== '' &&
|
|
18
|
-
typeof icon !== 'boolean');
|
|
19
|
-
}
|
|
20
|
-
/** Before a chosen file's name: its photo's thumbnail, else the consumer's mark. */
|
|
21
|
-
function ChosenMark({ icon, preview }) {
|
|
22
|
-
if (preview !== undefined)
|
|
23
|
-
return _jsx("img", { alt: "", className: "sw-file-field-preview", src: preview });
|
|
24
|
-
if (!hasMark(icon))
|
|
25
|
-
return null;
|
|
26
|
-
return (_jsx("span", { "aria-hidden": "true", className: "sw-file-field-icon", children: icon }));
|
|
27
|
-
}
|
|
28
13
|
/**
|
|
29
14
|
* One file chosen by a press or dropped on the field: empty, a wide zone
|
|
30
15
|
* that opens the system picker; chosen, the file's name and detail (after
|
|
31
16
|
* a thumbnail for a photo with a `preview`) with a button to choose
|
|
32
|
-
* another
|
|
17
|
+
* another, and Remove with `onRemove`. `compact` draws both at a text
|
|
18
|
+
* field's height. It hands the `File` to the consumer and shows what the
|
|
33
19
|
* consumer passes back as `value`; it never reads or uploads it.
|
|
34
20
|
*/
|
|
35
|
-
export function FileField({ label, labelVisuallyHidden = false, description, error, accept, value = null, onChange, chooseLabel, changeLabel, dropLabel, icon, disabled = false, id, }) {
|
|
36
|
-
assertFileField({
|
|
21
|
+
export function FileField({ label, labelVisuallyHidden = false, description, error, accept, value = null, onChange, chooseLabel, changeLabel, dropLabel, icon, disabled = false, compact = false, onRemove, removeLabel, id, }) {
|
|
22
|
+
assertFileField({
|
|
23
|
+
label,
|
|
24
|
+
chooseLabel,
|
|
25
|
+
changeLabel,
|
|
26
|
+
dropLabel,
|
|
27
|
+
value,
|
|
28
|
+
onRemove,
|
|
29
|
+
removeLabel,
|
|
30
|
+
});
|
|
37
31
|
const ownId = useId();
|
|
38
32
|
const baseId = id ?? ownId;
|
|
39
33
|
const inputRef = useRef(null);
|
|
@@ -55,11 +49,17 @@ export function FileField({ label, labelVisuallyHidden = false, description, err
|
|
|
55
49
|
detail: `${baseId}-detail`,
|
|
56
50
|
hint: `${baseId}-hint`,
|
|
57
51
|
error: `${baseId}-error`,
|
|
52
|
+
remove: `${baseId}-remove`,
|
|
58
53
|
};
|
|
54
|
+
const compactChosen = compact && chosen;
|
|
59
55
|
// The error is in the button's description and the polite region; the
|
|
60
56
|
// button role takes no aria-invalid.
|
|
57
|
+
// Compact and chosen, the button holds the file's name: it is named by
|
|
58
|
+
// the label and its hidden change words, the name only describing it.
|
|
61
59
|
const naming = {
|
|
62
|
-
'aria-labelledby':
|
|
60
|
+
'aria-labelledby': compactChosen
|
|
61
|
+
? `${ids.label} ${ids.button}-words`
|
|
62
|
+
: `${ids.label} ${ids.button}`,
|
|
63
63
|
'aria-describedby': [
|
|
64
64
|
chosen && ids.name,
|
|
65
65
|
chosen && value.detail && ids.detail,
|
|
@@ -71,5 +71,9 @@ export function FileField({ label, labelVisuallyHidden = false, description, err
|
|
|
71
71
|
id: ids.button,
|
|
72
72
|
};
|
|
73
73
|
const open = () => inputRef.current?.click();
|
|
74
|
-
|
|
74
|
+
const remove = onRemove !== undefined && removeLabel !== undefined
|
|
75
|
+
? { label: removeLabel, onRemove }
|
|
76
|
+
: null;
|
|
77
|
+
const ChosenFile = compact ? ChosenFileCompact : ChosenFileRow;
|
|
78
|
+
return (_jsxs(Stack, { className: cx('sw-field', 'sw-file-field', error && 'sw-field-invalid'), gap: labelVisuallyHidden ? 0 : 1, children: [_jsx("span", { id: ids.label, children: _jsx(FieldLabelText, { label: label, visuallyHidden: labelVisuallyHidden }) }), _jsxs("div", { className: cx('sw-file-field-frame', chosen ? 'sw-file-field-chosen' : 'sw-file-field-empty', compact && 'sw-file-field-compact', drop.dragging && 'sw-file-field-dragging', disabled && 'sw-file-field-disabled'), ...drop.handlers, ...focusHandlers, children: [_jsx("input", { accept: accept, "aria-label": label, disabled: disabled, hidden: true, onChange: onInput, ref: inputRef, tabIndex: -1, type: "file" }), chosen ? (_jsx(ChosenFile, { buttonRef: buttonRef, changeLabel: changeLabel, disabled: disabled, icon: icon, ids: ids, naming: naming, onChange: open, remove: remove, value: value })) : (_jsxs("button", { ...naming, className: "sw-file-field-zone", disabled: disabled, onClick: open, ref: buttonRef, tabIndex: tabStop, type: "button", children: [_jsx("span", { className: "sw-file-field-choose", children: chooseLabel }), dropLabel ? ' ' : null, dropLabel ? (_jsx("span", { className: "sw-file-field-drop", children: dropLabel })) : null] }))] }), description && !error ? (_jsx("span", { className: "sw-field-description", id: ids.hint, children: description })) : null, _jsx(FieldErrorRegion, { className: "sw-field-error", id: ids.error, message: error })] }));
|
|
75
79
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type ReactNode } from 'react';
|
|
2
|
+
import { type ListGroupTileColumns } from '../css/css-list-tiles.js';
|
|
2
3
|
import { type ListGroupMaxHeight, type ListGroupVariant } from '../list-options.js';
|
|
3
4
|
export type ListGroupProps = {
|
|
4
5
|
/** `ListRow` elements; the group draws a hairline between each pair. */
|
|
@@ -8,9 +9,13 @@ export type ListGroupProps = {
|
|
|
8
9
|
'aria-labelledby'?: string;
|
|
9
10
|
/**
|
|
10
11
|
* `panel` (the default) is one bordered panel; `plain` draws no panel
|
|
11
|
-
* and a hairline above the first row too, for rows inside a Card
|
|
12
|
+
* and a hairline above the first row too, for rows inside a Card;
|
|
13
|
+
* `tiles` sets each row on a panel of its own, in `tileColumns` equal
|
|
14
|
+
* columns from `md` up and one a row below it, still one list.
|
|
12
15
|
*/
|
|
13
16
|
variant?: ListGroupVariant;
|
|
17
|
+
/** How many tiles share a row from `md` up (`tiles` only): 2, 3 (default) or 4. */
|
|
18
|
+
tileColumns?: ListGroupTileColumns;
|
|
14
19
|
/**
|
|
15
20
|
* Bounds the list to a row count of its own region, `{ rows }` rows of a
|
|
16
21
|
* title and one detail line, where the rest scroll; the region is a named
|
|
@@ -27,7 +32,7 @@ export type ListGroupProps = {
|
|
|
27
32
|
};
|
|
28
33
|
/**
|
|
29
34
|
* Rows in one list (`ul`, its list role kept) with hairlines between them:
|
|
30
|
-
* a panel of its own, or plain rows inside a surface
|
|
31
|
-
* outer box.
|
|
35
|
+
* a panel of its own, or plain rows inside a surface; or the same list's
|
|
36
|
+
* rows as tiles, each a panel. The ref reaches the outer box.
|
|
32
37
|
*/
|
|
33
38
|
export declare const ListGroup: import("react").ForwardRefExoticComponent<ListGroupProps & import("react").RefAttributes<HTMLDivElement>>;
|
|
@@ -2,18 +2,26 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { forwardRef } from 'react';
|
|
4
4
|
import { cx } from '../class-names.js';
|
|
5
|
+
import { listTileColumnsClass, } from '../css/css-list-tiles.js';
|
|
5
6
|
import { assertListGroup, listMaxHeightStyle, } from '../list-options.js';
|
|
6
7
|
import { ListRowContainer } from './list/list-row-container.js';
|
|
7
8
|
import { ScrollRegion } from './ScrollRegion.js';
|
|
8
9
|
/**
|
|
9
10
|
* Rows in one list (`ul`, its list role kept) with hairlines between them:
|
|
10
|
-
* a panel of its own, or plain rows inside a surface
|
|
11
|
-
* outer box.
|
|
11
|
+
* a panel of its own, or plain rows inside a surface; or the same list's
|
|
12
|
+
* rows as tiles, each a panel. The ref reaches the outer box.
|
|
12
13
|
*/
|
|
13
|
-
export const ListGroup = forwardRef(function ListGroup({ children, 'aria-label': label, 'aria-labelledby': labelledBy, variant = 'panel', maxHeight, footer, className, id, }, ref) {
|
|
14
|
-
assertListGroup({
|
|
14
|
+
export const ListGroup = forwardRef(function ListGroup({ children, 'aria-label': label, 'aria-labelledby': labelledBy, variant = 'panel', tileColumns, maxHeight, footer, className, id, }, ref) {
|
|
15
|
+
assertListGroup({
|
|
16
|
+
label,
|
|
17
|
+
labelledBy,
|
|
18
|
+
variant,
|
|
19
|
+
maxHeight,
|
|
20
|
+
tileColumns,
|
|
21
|
+
hasFooter: footer !== undefined,
|
|
22
|
+
});
|
|
15
23
|
const list = (_jsx("ul", { "aria-label": label, "aria-labelledby": labelledBy, className: "sw-list",
|
|
16
24
|
// A list styled without bullets loses its role in Safari.
|
|
17
25
|
role: "list", children: _jsx(ListRowContainer.Provider, { value: "li", children: children }) }));
|
|
18
|
-
return (_jsxs("div", { className: cx('sw-list-group', `sw-list-group-${variant}`, className), id: id, ref: ref, children: [maxHeight === undefined ? (list) : (_jsx(ScrollRegion, { "aria-label": label, "aria-labelledby": labelledBy, className: "sw-list-group-scroll", style: listMaxHeightStyle(maxHeight), children: list })), footer === undefined ? null : (_jsx("div", { className: "sw-list-group-footer", children: _jsx(ListRowContainer.Provider, { value: "div", children: footer }) }))] }));
|
|
26
|
+
return (_jsxs("div", { className: cx('sw-list-group', `sw-list-group-${variant}`, variant === 'tiles' && listTileColumnsClass(tileColumns ?? 3), className), id: id, ref: ref, children: [maxHeight === undefined ? (list) : (_jsx(ScrollRegion, { "aria-label": label, "aria-labelledby": labelledBy, className: "sw-list-group-scroll", style: listMaxHeightStyle(maxHeight), children: list })), footer === undefined ? null : (_jsx("div", { className: "sw-list-group-footer", children: _jsx(ListRowContainer.Provider, { value: "div", children: footer }) }))] }));
|
|
19
27
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type SelectOption } from '../select-list.js';
|
|
2
2
|
import { type FieldSize } from './Field.js';
|
|
3
3
|
import { type RequiredMark } from './FieldLabelText.js';
|
|
4
|
+
import { type SelectNaming } from './select/select-naming.js';
|
|
4
5
|
export type { SelectOption };
|
|
5
6
|
/**
|
|
6
7
|
* `content` (the default) sizes the field to its longest option, as a native
|
|
@@ -12,10 +13,9 @@ export type SelectAction = {
|
|
|
12
13
|
label: string;
|
|
13
14
|
onPress: () => void;
|
|
14
15
|
};
|
|
15
|
-
export type
|
|
16
|
+
export type { SelectNaming };
|
|
17
|
+
export type SelectProps = SelectNaming & {
|
|
16
18
|
action?: SelectAction;
|
|
17
|
-
label: string;
|
|
18
|
-
labelVisuallyHidden?: boolean;
|
|
19
19
|
onChange: (value: string) => void;
|
|
20
20
|
options: readonly SelectOption[];
|
|
21
21
|
size?: FieldSize;
|
|
@@ -25,7 +25,10 @@ export type SelectProps = {
|
|
|
25
25
|
* (such as ''). It is not an option and never becomes the value.
|
|
26
26
|
*/
|
|
27
27
|
placeholder?: string;
|
|
28
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* Marks the label as Field does and sets `aria-required` (named by
|
|
30
|
+
* `aria-labelledby`, only `aria-required`: the page draws the words).
|
|
31
|
+
*/
|
|
29
32
|
required?: boolean;
|
|
30
33
|
/**
|
|
31
34
|
* `hidden` leaves out the label's " *" while the control stays required
|
|
@@ -43,4 +46,4 @@ export type SelectProps = {
|
|
|
43
46
|
*/
|
|
44
47
|
width?: SelectWidth;
|
|
45
48
|
};
|
|
46
|
-
export declare function Select({ action, label, labelVisuallyHidden, onChange, options, size, value, placeholder, required, requiredMark, error, width, }: SelectProps): import("react").JSX.Element;
|
|
49
|
+
export declare function Select({ action, label, labelVisuallyHidden, 'aria-labelledby': labelledBy, onChange, options, size, value, placeholder, required, requiredMark, error, width, }: SelectProps): import("react").JSX.Element;
|
|
@@ -5,16 +5,19 @@ import { cx } from '../class-names.js';
|
|
|
5
5
|
import { selectedSelectIndex } from '../select-list.js';
|
|
6
6
|
import { FieldErrorRegion } from './FieldErrorRegion.js';
|
|
7
7
|
import { assertRequiredMark, FieldLabelText, } from './FieldLabelText.js';
|
|
8
|
+
import { assertSelectNaming, } from './select/select-naming.js';
|
|
8
9
|
import { SelectListbox } from './select/SelectListbox.js';
|
|
9
10
|
import { SelectValue } from './select/SelectValue.js';
|
|
10
11
|
import { useSelectList } from './select/use-select-list.js';
|
|
11
12
|
import { Stack } from './Stack.js';
|
|
12
13
|
import { tabStop } from '../tab-stop.js';
|
|
13
|
-
export function Select({ action, label, labelVisuallyHidden
|
|
14
|
-
|
|
14
|
+
export function Select({ action, label, labelVisuallyHidden, 'aria-labelledby': labelledBy, onChange, options, size = 'md', value, placeholder, required = false, requiredMark, error, width = 'content', }) {
|
|
15
|
+
assertSelectNaming({ label, labelVisuallyHidden, labelledBy, requiredMark });
|
|
16
|
+
assertRequiredMark(requiredMark ?? 'visible');
|
|
15
17
|
if (placeholder !== undefined && !placeholder.trim())
|
|
16
18
|
throw new RangeError('placeholder must not be empty');
|
|
17
|
-
const
|
|
19
|
+
const ownLabelId = useId();
|
|
20
|
+
const labelId = labelledBy ?? ownLabelId;
|
|
18
21
|
const triggerId = useId();
|
|
19
22
|
const listId = useId();
|
|
20
23
|
const optionIdPrefix = useId();
|
|
@@ -24,7 +27,7 @@ export function Select({ action, label, labelVisuallyHidden = false, onChange, o
|
|
|
24
27
|
const showsPlaceholder = placeholder !== undefined &&
|
|
25
28
|
!options.some((option) => option.value === value);
|
|
26
29
|
const { commit, highlight, onTriggerKeyDown, open, openList, setHighlight, setOpen, } = useSelectList({ action, onChange, options, rootRef, value });
|
|
27
|
-
return (_jsx("div", { ref: rootRef, className: cx('sw-select', size === 'xs' && 'sw-select-xs', width === 'full' && 'sw-select-full', error && 'sw-select-invalid'), children: _jsxs(Stack, { gap: labelVisuallyHidden ? 0 : 1, children: [_jsx("label", { htmlFor: triggerId, id: labelId, children: _jsx(FieldLabelText, { label: label, required: required, requiredMark: requiredMark, size: size, visuallyHidden: labelVisuallyHidden }) }), _jsxs("div", { className: "sw-select-control", children: [_jsx("button", { "aria-activedescendant": open ? `${optionIdPrefix}-${highlight}` : undefined, "aria-controls": open ? listId : undefined, "aria-describedby": error ? errorId : undefined, "aria-expanded": open, "aria-haspopup": "listbox", "aria-invalid": error ? true : undefined, "aria-labelledby": labelId, "aria-required": required || undefined, className: "sw-select-trigger", id: triggerId, onBlur: (event) => {
|
|
30
|
+
return (_jsx("div", { ref: rootRef, className: cx('sw-select', size === 'xs' && 'sw-select-xs', width === 'full' && 'sw-select-full', error && 'sw-select-invalid'), children: _jsxs(Stack, { gap: label === undefined || labelVisuallyHidden ? 0 : 1, children: [label === undefined ? null : (_jsx("label", { htmlFor: triggerId, id: labelId, children: _jsx(FieldLabelText, { label: label, required: required, requiredMark: requiredMark ?? 'visible', size: size, visuallyHidden: labelVisuallyHidden ?? false }) })), _jsxs("div", { className: "sw-select-control", children: [_jsx("button", { "aria-activedescendant": open ? `${optionIdPrefix}-${highlight}` : undefined, "aria-controls": open ? listId : undefined, "aria-describedby": error ? errorId : undefined, "aria-expanded": open, "aria-haspopup": "listbox", "aria-invalid": error ? true : undefined, "aria-labelledby": labelId, "aria-required": required || undefined, className: "sw-select-trigger", id: triggerId, onBlur: (event) => {
|
|
28
31
|
const next = event.relatedTarget;
|
|
29
32
|
if (next instanceof Node && rootRef.current?.contains(next)) {
|
|
30
33
|
return;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { type ReactNode, type Ref } from 'react';
|
|
2
|
+
import type { FileFieldValue } from '../FileField.js';
|
|
3
|
+
/** The ids a chosen file's parts take, from the field's. */
|
|
4
|
+
export type ChosenFileIds = {
|
|
5
|
+
label: string;
|
|
6
|
+
button: string;
|
|
7
|
+
name: string;
|
|
8
|
+
detail: string;
|
|
9
|
+
remove: string;
|
|
10
|
+
};
|
|
11
|
+
/** The change button's naming and description, as the field wires them. */
|
|
12
|
+
export type ChosenFileNaming = {
|
|
13
|
+
'aria-labelledby': string;
|
|
14
|
+
'aria-describedby': string | undefined;
|
|
15
|
+
id: string;
|
|
16
|
+
};
|
|
17
|
+
/** Remove beside a chosen file: its words and what it does. */
|
|
18
|
+
export type ChosenFileRemove = {
|
|
19
|
+
label: string;
|
|
20
|
+
onRemove: () => void;
|
|
21
|
+
};
|
|
22
|
+
type ChosenFileProps = {
|
|
23
|
+
value: FileFieldValue;
|
|
24
|
+
icon: ReactNode;
|
|
25
|
+
ids: ChosenFileIds;
|
|
26
|
+
naming: ChosenFileNaming;
|
|
27
|
+
changeLabel: string;
|
|
28
|
+
remove: ChosenFileRemove | null;
|
|
29
|
+
disabled: boolean;
|
|
30
|
+
onChange: () => void;
|
|
31
|
+
buttonRef: Ref<HTMLButtonElement>;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* The chosen file in the full field: its mark, its name over its detail,
|
|
35
|
+
* then the change button (and Remove after it) at the row's end.
|
|
36
|
+
*/
|
|
37
|
+
export declare function ChosenFileRow({ value, icon, ids, naming, changeLabel, remove, disabled, onChange, buttonRef, }: ChosenFileProps): import("react").JSX.Element;
|
|
38
|
+
/**
|
|
39
|
+
* The chosen file in a compact field: one row at a text field's height.
|
|
40
|
+
* The mark and the name (cut to one line) are the change button, named by
|
|
41
|
+
* the field's label and the change words, described by the name and the
|
|
42
|
+
* detail (which is not drawn); Remove, quiet, ends the row.
|
|
43
|
+
*/
|
|
44
|
+
export declare function ChosenFileCompact({ value, icon, ids, naming, changeLabel, remove, disabled, onChange, buttonRef, }: ChosenFileProps): import("react").JSX.Element;
|
|
45
|
+
export {};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import { tabStop } from '../../tab-stop.js';
|
|
4
|
+
import { Button } from '../Button.js';
|
|
5
|
+
/** Whether the consumer passed a mark to draw (not empty JSX). */
|
|
6
|
+
function hasMark(icon) {
|
|
7
|
+
return (icon !== undefined &&
|
|
8
|
+
icon !== null &&
|
|
9
|
+
icon !== '' &&
|
|
10
|
+
typeof icon !== 'boolean');
|
|
11
|
+
}
|
|
12
|
+
/** Before a chosen file's name: its photo's thumbnail, else the consumer's mark. */
|
|
13
|
+
function ChosenMark({ icon, preview }) {
|
|
14
|
+
if (preview !== undefined)
|
|
15
|
+
return _jsx("img", { alt: "", className: "sw-file-field-preview", src: preview });
|
|
16
|
+
if (!hasMark(icon))
|
|
17
|
+
return null;
|
|
18
|
+
return (_jsx("span", { "aria-hidden": "true", className: "sw-file-field-icon", children: icon }));
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Remove: a quiet button named by its words and the field's label
|
|
22
|
+
* ("Remove Cover photo"), so two fields' Removes are told apart. Disabled,
|
|
23
|
+
* the neutral one greys as the change button does; the ghost one, which
|
|
24
|
+
* has no fill, fades as the compact row's pick button does.
|
|
25
|
+
*/
|
|
26
|
+
function RemoveButton({ ids, remove, disabled, variant, }) {
|
|
27
|
+
return (_jsx(Button
|
|
28
|
+
// Named by its own words, then the label: the self-reference is
|
|
29
|
+
// deliberate and needs the id below on the button itself.
|
|
30
|
+
, { "aria-labelledby": `${ids.remove} ${ids.label}`, className: "sw-file-field-remove", disabled: disabled, disabledAppearance: variant === 'neutral' ? 'neutral' : 'faded', id: ids.remove, onPress: remove.onRemove, size: variant === 'ghost' ? 'sm' : 'md', variant: variant, children: remove.label }));
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The chosen file in the full field: its mark, its name over its detail,
|
|
34
|
+
* then the change button (and Remove after it) at the row's end.
|
|
35
|
+
*/
|
|
36
|
+
export function ChosenFileRow({ value, icon, ids, naming, changeLabel, remove, disabled, onChange, buttonRef, }) {
|
|
37
|
+
const change = (_jsx(Button, { ...naming, disabled: disabled, disabledAppearance: "neutral", onPress: onChange, ref: buttonRef, variant: "neutral", children: changeLabel }));
|
|
38
|
+
return (_jsxs(_Fragment, { children: [_jsx(ChosenMark, { icon: icon, preview: value.preview }), _jsxs("span", { className: "sw-file-field-file", children: [_jsx("span", { className: "sw-file-field-name", id: ids.name, children: value.name }), value.detail ? (_jsx("span", { className: "sw-file-field-detail", id: ids.detail, children: value.detail })) : null] }), remove === null ? (change) : (_jsxs("span", { className: "sw-file-field-actions", children: [change, _jsx(RemoveButton, { disabled: disabled, ids: ids, remove: remove, variant: "neutral" })] }))] }));
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The chosen file in a compact field: one row at a text field's height.
|
|
42
|
+
* The mark and the name (cut to one line) are the change button, named by
|
|
43
|
+
* the field's label and the change words, described by the name and the
|
|
44
|
+
* detail (which is not drawn); Remove, quiet, ends the row.
|
|
45
|
+
*/
|
|
46
|
+
export function ChosenFileCompact({ value, icon, ids, naming, changeLabel, remove, disabled, onChange, buttonRef, }) {
|
|
47
|
+
return (_jsxs(_Fragment, { children: [_jsxs("button", { ...naming, className: "sw-file-field-pick", disabled: disabled, onClick: onChange, ref: buttonRef, tabIndex: tabStop, type: "button", children: [_jsx(ChosenMark, { icon: icon, preview: value.preview }), _jsx("span", { className: "sw-file-field-name", id: ids.name, children: value.name }), value.detail ? (_jsx("span", { className: "sw-sr-only", id: ids.detail, children: value.detail })) : null, _jsx("span", { className: "sw-sr-only", id: `${ids.button}-words`, children: changeLabel })] }), remove === null ? null : (_jsx(RemoveButton, { disabled: disabled, ids: ids, remove: remove, variant: "ghost" }))] }));
|
|
48
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { FileFieldValue } from '../FileField.js';
|
|
2
2
|
/**
|
|
3
3
|
* Fails closed on a FileField it cannot name or show: an empty label,
|
|
4
|
-
* choose or change words, a blank drop hint,
|
|
5
|
-
*
|
|
4
|
+
* choose or change words, a blank drop hint, `onRemove` without its
|
|
5
|
+
* words (or words without it), blank Remove words, or a chosen file with
|
|
6
|
+
* no name, a blank detail or a blank preview address.
|
|
6
7
|
*/
|
|
7
8
|
export declare function assertFileField(props: {
|
|
8
9
|
label: string;
|
|
@@ -10,4 +11,6 @@ export declare function assertFileField(props: {
|
|
|
10
11
|
changeLabel: string;
|
|
11
12
|
dropLabel: string | undefined;
|
|
12
13
|
value: FileFieldValue | null;
|
|
14
|
+
onRemove: (() => void) | undefined;
|
|
15
|
+
removeLabel: string | undefined;
|
|
13
16
|
}): void;
|
|
@@ -3,8 +3,9 @@ function isBlank(text) {
|
|
|
3
3
|
}
|
|
4
4
|
/**
|
|
5
5
|
* Fails closed on a FileField it cannot name or show: an empty label,
|
|
6
|
-
* choose or change words, a blank drop hint,
|
|
7
|
-
*
|
|
6
|
+
* choose or change words, a blank drop hint, `onRemove` without its
|
|
7
|
+
* words (or words without it), blank Remove words, or a chosen file with
|
|
8
|
+
* no name, a blank detail or a blank preview address.
|
|
8
9
|
*/
|
|
9
10
|
export function assertFileField(props) {
|
|
10
11
|
for (const key of ['label', 'chooseLabel', 'changeLabel'])
|
|
@@ -12,6 +13,10 @@ export function assertFileField(props) {
|
|
|
12
13
|
throw new RangeError(`FileField ${key} must be non-empty text`);
|
|
13
14
|
if (props.dropLabel !== undefined && isBlank(props.dropLabel))
|
|
14
15
|
throw new RangeError('FileField dropLabel must be non-empty text');
|
|
16
|
+
if ((props.onRemove === undefined) !== (props.removeLabel === undefined))
|
|
17
|
+
throw new RangeError('FileField onRemove and removeLabel go together');
|
|
18
|
+
if (props.removeLabel !== undefined && isBlank(props.removeLabel))
|
|
19
|
+
throw new RangeError('FileField removeLabel must be non-empty text');
|
|
15
20
|
if (props.value === null)
|
|
16
21
|
return;
|
|
17
22
|
if (isBlank(props.value.name))
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a Select is named: by its own `label` (drawn, or visually hidden),
|
|
3
|
+
* or by words the page already draws, through `aria-labelledby`.
|
|
4
|
+
*/
|
|
5
|
+
export type SelectNaming = {
|
|
6
|
+
/** The select's own label, drawn above it as Field's. */
|
|
7
|
+
label: string;
|
|
8
|
+
/** Keeps the label for assistive technology only, for toolbar chrome. */
|
|
9
|
+
labelVisuallyHidden?: boolean;
|
|
10
|
+
'aria-labelledby'?: undefined;
|
|
11
|
+
} | {
|
|
12
|
+
/**
|
|
13
|
+
* The id of words the page draws for this choice, such as a heading
|
|
14
|
+
* or a label beside it: the select draws no label of its own, so
|
|
15
|
+
* the name is drawn, and read, once.
|
|
16
|
+
*/
|
|
17
|
+
'aria-labelledby': string;
|
|
18
|
+
label?: undefined;
|
|
19
|
+
labelVisuallyHidden?: undefined;
|
|
20
|
+
/** No mark: the page draws the words, and any mark, itself. */
|
|
21
|
+
requiredMark?: undefined;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Fails closed on a Select with no name, or with both: a blank `label`
|
|
25
|
+
* without `aria-labelledby`, a blank `aria-labelledby`, the two together
|
|
26
|
+
* (one would silently hide the other), or `requiredMark` with
|
|
27
|
+
* `aria-labelledby` (there is no label to mark).
|
|
28
|
+
*/
|
|
29
|
+
export declare function assertSelectNaming(naming: {
|
|
30
|
+
label: unknown;
|
|
31
|
+
labelVisuallyHidden: unknown;
|
|
32
|
+
labelledBy: unknown;
|
|
33
|
+
requiredMark: unknown;
|
|
34
|
+
}): void;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
function isBlank(text) {
|
|
2
|
+
return typeof text !== 'string' || text.trim() === '';
|
|
3
|
+
}
|
|
4
|
+
/**
|
|
5
|
+
* Fails closed on a Select with no name, or with both: a blank `label`
|
|
6
|
+
* without `aria-labelledby`, a blank `aria-labelledby`, the two together
|
|
7
|
+
* (one would silently hide the other), or `requiredMark` with
|
|
8
|
+
* `aria-labelledby` (there is no label to mark).
|
|
9
|
+
*/
|
|
10
|
+
export function assertSelectNaming(naming) {
|
|
11
|
+
if (naming.labelledBy === undefined) {
|
|
12
|
+
if (isBlank(naming.label))
|
|
13
|
+
throw new RangeError('Select needs a label or aria-labelledby');
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
if (isBlank(naming.labelledBy))
|
|
17
|
+
throw new RangeError('Select aria-labelledby must be a non-empty id');
|
|
18
|
+
if (naming.label !== undefined || naming.labelVisuallyHidden !== undefined)
|
|
19
|
+
throw new RangeError('Select takes label (and labelVisuallyHidden) or aria-labelledby, not both');
|
|
20
|
+
if (naming.requiredMark !== undefined)
|
|
21
|
+
throw new RangeError('Select requiredMark needs label: with aria-labelledby the page draws the mark');
|
|
22
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How wide a Button is: `content` (the default) is as wide as its words;
|
|
3
|
+
* `full` fills the container's inline size, and in an Inline row takes
|
|
4
|
+
* the space its siblings leave, such as an Edit beside a ••• menu.
|
|
5
|
+
*/
|
|
6
|
+
export declare const buttonWidths: readonly ["content", "full"];
|
|
7
|
+
export type ButtonWidth = (typeof buttonWidths)[number];
|
|
8
|
+
/** The class a width adds; `content` adds none (`cx` drops `undefined`). */
|
|
9
|
+
export declare function buttonWidthClass(width: ButtonWidth): string | undefined;
|
|
10
|
+
export declare function cssButtonWidthClasses(): string;
|
|
11
|
+
export declare function buttonWidthClassCatalog(): string[];
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How wide a Button is: `content` (the default) is as wide as its words;
|
|
3
|
+
* `full` fills the container's inline size, and in an Inline row takes
|
|
4
|
+
* the space its siblings leave, such as an Edit beside a ••• menu.
|
|
5
|
+
*/
|
|
6
|
+
export const buttonWidths = ['content', 'full'];
|
|
7
|
+
const full = 'sw-button-full';
|
|
8
|
+
/** The class a width adds; `content` adds none (`cx` drops `undefined`). */
|
|
9
|
+
export function buttonWidthClass(width) {
|
|
10
|
+
return width === 'full' ? full : undefined;
|
|
11
|
+
}
|
|
12
|
+
/*
|
|
13
|
+
* A full button fills a Stack, a Grid cell or any column. As a direct
|
|
14
|
+
* child of an Inline row it shares the row with its siblings, which keep
|
|
15
|
+
* their own width (a ••• or a second button), as a full Select does; in a
|
|
16
|
+
* wrapping row it grows from its words' width instead, so it moves to a
|
|
17
|
+
* line of its own rather than shrinking past them. Its words stay centred.
|
|
18
|
+
*/
|
|
19
|
+
export function cssButtonWidthClasses() {
|
|
20
|
+
return `.${full} {
|
|
21
|
+
width: 100%;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.sw-inline > .${full} {
|
|
25
|
+
flex: 1 1 0;
|
|
26
|
+
min-width: 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.sw-inline.sw-wrap > .${full} {
|
|
30
|
+
flex: 1 1 auto;
|
|
31
|
+
width: auto;
|
|
32
|
+
}`;
|
|
33
|
+
}
|
|
34
|
+
export function buttonWidthClassCatalog() {
|
|
35
|
+
return [full];
|
|
36
|
+
}
|
package/dist/css/css-button.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { buttonSizes, buttonVariants, controlScale, typographyVariants, } from '@scalewing/tokens';
|
|
2
2
|
import { breakpointQuery } from './breakpoints.js';
|
|
3
3
|
import { buttonGroupAttachedClassCatalog, cssButtonGroupAttachedClasses, } from './css-button-group-attached.js';
|
|
4
|
+
import { buttonWidthClassCatalog, cssButtonWidthClasses, } from './css-button-width.js';
|
|
4
5
|
import { quietFill } from './quiet-fill.js';
|
|
5
6
|
const label = typographyVariants.label;
|
|
6
7
|
/**
|
|
@@ -135,6 +136,7 @@ ${disabledNeutralRules()}
|
|
|
135
136
|
${pressedRules()}
|
|
136
137
|
${ariaDisabledForcedRules()}
|
|
137
138
|
${sizeRules()}
|
|
139
|
+
${cssButtonWidthClasses()}
|
|
138
140
|
${groupRules()}
|
|
139
141
|
|
|
140
142
|
${cssButtonGroupAttachedClasses()}`;
|
|
@@ -162,6 +164,7 @@ export function buttonClassCatalog() {
|
|
|
162
164
|
...webButtonVariants.map((variant) => `sw-button-${variant}`),
|
|
163
165
|
'sw-button-disabled-neutral',
|
|
164
166
|
...buttonSizes.map((size) => `sw-button-${size}`),
|
|
167
|
+
...buttonWidthClassCatalog(),
|
|
165
168
|
'sw-button-group',
|
|
166
169
|
...buttonGroupJustifies.map((name) => `sw-button-group-${name}`),
|
|
167
170
|
...buttonGroupAttachedClassCatalog(),
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { typographyVariants } from '@scalewing/tokens';
|
|
2
|
+
import { touchTarget } from './touch-target.js';
|
|
3
|
+
const body = typographyVariants.body;
|
|
4
|
+
/*
|
|
5
|
+
* Remove beside a chosen file in the full field: the change button and
|
|
6
|
+
* Remove together at the row's end, a space-2 apart.
|
|
7
|
+
*/
|
|
8
|
+
function actionsRules() {
|
|
9
|
+
return `.sw-file-field-actions {
|
|
10
|
+
display: flex;
|
|
11
|
+
flex: none;
|
|
12
|
+
flex-wrap: wrap;
|
|
13
|
+
gap: var(--sw-space-2);
|
|
14
|
+
margin-inline-start: auto;
|
|
15
|
+
}`;
|
|
16
|
+
}
|
|
17
|
+
/*
|
|
18
|
+
* A compact field is a text field's height (the md control, 44 px), on
|
|
19
|
+
* one line. Empty, its zone holds the choose words at its start. Chosen,
|
|
20
|
+
* the row is the change button (the thumbnail or mark a space-1 from the
|
|
21
|
+
* frame's start, then the name in the body weight cut to one line) and a
|
|
22
|
+
* quiet Remove at its end. The button's focus ring sits inside the frame.
|
|
23
|
+
*/
|
|
24
|
+
function compactRules() {
|
|
25
|
+
return `.sw-file-field-compact.sw-file-field-chosen {
|
|
26
|
+
flex-wrap: nowrap;
|
|
27
|
+
gap: var(--sw-space-1);
|
|
28
|
+
min-height: ${touchTarget};
|
|
29
|
+
padding-block: 0;
|
|
30
|
+
padding-inline: 0 var(--sw-space-1);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.sw-file-field-compact .sw-file-field-zone {
|
|
34
|
+
justify-content: flex-start;
|
|
35
|
+
padding: var(--sw-space-2) var(--sw-space-4);
|
|
36
|
+
text-align: start;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.sw-file-field-pick {
|
|
40
|
+
align-items: center;
|
|
41
|
+
appearance: none;
|
|
42
|
+
background: transparent;
|
|
43
|
+
border: 0;
|
|
44
|
+
border-radius: inherit;
|
|
45
|
+
box-sizing: border-box;
|
|
46
|
+
color: var(--sw-color-text);
|
|
47
|
+
cursor: pointer;
|
|
48
|
+
display: flex;
|
|
49
|
+
flex: 1 1 auto;
|
|
50
|
+
font-family: var(--sw-font-sans);
|
|
51
|
+
gap: var(--sw-space-3);
|
|
52
|
+
margin: 0;
|
|
53
|
+
min-height: ${touchTarget};
|
|
54
|
+
min-width: 0;
|
|
55
|
+
padding-block: var(--sw-space-1);
|
|
56
|
+
padding-inline: var(--sw-space-1) var(--sw-space-2);
|
|
57
|
+
text-align: start;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.sw-file-field-pick:focus { outline: none; }
|
|
61
|
+
|
|
62
|
+
.sw-file-field-pick:focus-visible {
|
|
63
|
+
outline: var(--sw-focus-ring-width) solid var(--sw-color-accent);
|
|
64
|
+
outline-offset: calc(-1 * var(--sw-focus-ring-width));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.sw-file-field-pick:disabled { cursor: not-allowed; }
|
|
68
|
+
|
|
69
|
+
.sw-file-field-pick .sw-file-field-name {
|
|
70
|
+
font-weight: ${body.fontWeight};
|
|
71
|
+
min-width: 0;
|
|
72
|
+
overflow: hidden;
|
|
73
|
+
overflow-wrap: normal;
|
|
74
|
+
text-overflow: ellipsis;
|
|
75
|
+
white-space: nowrap;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/* With no thumbnail or mark, the name starts where a text field's words do. */
|
|
79
|
+
.sw-file-field-pick > .sw-file-field-name:first-child { margin-inline-start: var(--sw-space-3); }
|
|
80
|
+
|
|
81
|
+
.sw-file-field-compact .sw-file-field-preview,
|
|
82
|
+
.sw-file-field-compact .sw-file-field-icon { margin-inline-start: var(--sw-space-1); }
|
|
83
|
+
|
|
84
|
+
.sw-file-field-remove { flex: none; }
|
|
85
|
+
|
|
86
|
+
@media (forced-colors: active) {
|
|
87
|
+
.sw-file-field-pick { color: ButtonText; }
|
|
88
|
+
.sw-file-field-pick:disabled { color: GrayText; }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/* Disabled, the row fades as its Remove does. */
|
|
92
|
+
.sw-file-field-pick:disabled { opacity: var(--sw-disabled-opacity); }`;
|
|
93
|
+
}
|
|
94
|
+
export function cssFileFieldCompactClasses() {
|
|
95
|
+
return `${actionsRules()}
|
|
96
|
+
|
|
97
|
+
${compactRules()}`;
|
|
98
|
+
}
|
|
99
|
+
export function fileFieldCompactClassCatalog() {
|
|
100
|
+
return [
|
|
101
|
+
'sw-file-field-actions',
|
|
102
|
+
'sw-file-field-compact',
|
|
103
|
+
'sw-file-field-pick',
|
|
104
|
+
'sw-file-field-remove',
|
|
105
|
+
];
|
|
106
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { typographyVariants } from '@scalewing/tokens';
|
|
2
2
|
import { disabledControlSurface } from './css-document.js';
|
|
3
|
+
import { cssFileFieldCompactClasses, fileFieldCompactClassCatalog, } from './css-file-field-compact.js';
|
|
3
4
|
import { coarsePointerQuery, touchTarget } from './touch-target.js';
|
|
4
5
|
const label = typographyVariants.label;
|
|
5
6
|
const caption = typographyVariants.caption;
|
|
@@ -172,6 +173,8 @@ ${zoneRules()}
|
|
|
172
173
|
|
|
173
174
|
${fileRules()}
|
|
174
175
|
|
|
176
|
+
${cssFileFieldCompactClasses()}
|
|
177
|
+
|
|
175
178
|
${forcedColorRules()}`;
|
|
176
179
|
}
|
|
177
180
|
export function fileFieldClassCatalog() {
|
|
@@ -190,5 +193,6 @@ export function fileFieldClassCatalog() {
|
|
|
190
193
|
'sw-file-field-file',
|
|
191
194
|
'sw-file-field-name',
|
|
192
195
|
'sw-file-field-detail',
|
|
196
|
+
...fileFieldCompactClassCatalog(),
|
|
193
197
|
];
|
|
194
198
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** How many tiles share a row from `md` up; one a row below it. */
|
|
2
|
+
export declare const listTileColumnCounts: readonly [2, 3, 4];
|
|
3
|
+
export type ListGroupTileColumns = (typeof listTileColumnCounts)[number];
|
|
4
|
+
export declare function listTileColumnsClass(columns: ListGroupTileColumns): string;
|
|
5
|
+
export declare function cssListTilesClasses(): string;
|
|
6
|
+
export declare function listTilesClassCatalog(): string[];
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { breakpointQuery } from './breakpoints.js';
|
|
2
|
+
/** How many tiles share a row from `md` up; one a row below it. */
|
|
3
|
+
export const listTileColumnCounts = [2, 3, 4];
|
|
4
|
+
export function listTileColumnsClass(columns) {
|
|
5
|
+
return `sw-list-group-tiles-${columns}`;
|
|
6
|
+
}
|
|
7
|
+
const tiles = '.sw-list-group-tiles';
|
|
8
|
+
/*
|
|
9
|
+
* A tiled ListGroup is still one list (`sw-list`), its rows laid out as
|
|
10
|
+
* tiles: equal columns a space-3 apart from `md` up, one column below it,
|
|
11
|
+
* each row its own bordered panel on the surface (the panel variant's
|
|
12
|
+
* look, per row), with no hairlines between them. A row's press fill and
|
|
13
|
+
* its inset focus ring stay inside its tile.
|
|
14
|
+
*
|
|
15
|
+
* Generated after the list's hairline rules (css-list.ts), whose
|
|
16
|
+
* `.sw-list > .sw-list-item + .sw-list-item` has the same specificity as
|
|
17
|
+
* the tile rule: being later is what lets a tile's own border replace the
|
|
18
|
+
* hairline. `list-group-tiles.test.tsx` holds that order.
|
|
19
|
+
*/
|
|
20
|
+
export function cssListTilesClasses() {
|
|
21
|
+
const columns = listTileColumnCounts.map((count) => `.${listTileColumnsClass(count)} > .sw-list { grid-template-columns: repeat(${count}, minmax(0, 1fr)); }`);
|
|
22
|
+
return `${tiles} > .sw-list {
|
|
23
|
+
display: grid;
|
|
24
|
+
gap: var(--sw-space-3);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
${columns.join('\n')}
|
|
28
|
+
|
|
29
|
+
${tiles} > .sw-list > .sw-list-item {
|
|
30
|
+
background: var(--sw-color-surface);
|
|
31
|
+
border: 1px solid var(--sw-color-border);
|
|
32
|
+
border-radius: var(--sw-radius-md);
|
|
33
|
+
min-width: 0;
|
|
34
|
+
overflow: clip;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
@media ${breakpointQuery('below', 'md')} {
|
|
38
|
+
${tiles} > .sw-list { grid-template-columns: minmax(0, 1fr); }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
@media (forced-colors: active) {
|
|
42
|
+
${tiles} > .sw-list > .sw-list-item { border-color: CanvasText; }
|
|
43
|
+
}`;
|
|
44
|
+
}
|
|
45
|
+
export function listTilesClassCatalog() {
|
|
46
|
+
return [
|
|
47
|
+
'sw-list-group-tiles',
|
|
48
|
+
...listTileColumnCounts.map((count) => listTileColumnsClass(count)),
|
|
49
|
+
];
|
|
50
|
+
}
|
package/dist/css/css-list.js
CHANGED
|
@@ -2,6 +2,7 @@ import { typographyVariants } from '@scalewing/tokens';
|
|
|
2
2
|
import { cssListRowClasses, listRowClassCatalog } from './css-list-row.js';
|
|
3
3
|
import { cssListRowTrailingClasses, listRowTrailingClassCatalog, } from './css-list-row-trailing.js';
|
|
4
4
|
import { cssListRowTruncateClasses, listRowTruncateClassCatalog, } from './css-list-row-truncate.js';
|
|
5
|
+
import { cssListTilesClasses, listTilesClassCatalog, } from './css-list-tiles.js';
|
|
5
6
|
import { blockEdgeShadeRules, scrollRegionRules } from './css-scroll-region.js';
|
|
6
7
|
const label = typographyVariants.label;
|
|
7
8
|
const caption = typographyVariants.caption;
|
|
@@ -75,7 +76,9 @@ ${cssListRowClasses()}
|
|
|
75
76
|
|
|
76
77
|
${cssListRowTrailingClasses()}
|
|
77
78
|
|
|
78
|
-
${cssListRowTruncateClasses()}
|
|
79
|
+
${cssListRowTruncateClasses()}
|
|
80
|
+
|
|
81
|
+
${cssListTilesClasses()}`;
|
|
79
82
|
}
|
|
80
83
|
export function listClassCatalog() {
|
|
81
84
|
return [
|
|
@@ -86,6 +89,7 @@ export function listClassCatalog() {
|
|
|
86
89
|
'sw-list-group-plain',
|
|
87
90
|
'sw-list-group-scroll',
|
|
88
91
|
'sw-list-group-footer',
|
|
92
|
+
...listTilesClassCatalog(),
|
|
89
93
|
...listRowClassCatalog(),
|
|
90
94
|
...listRowTrailingClassCatalog(),
|
|
91
95
|
...listRowTruncateClassCatalog(),
|
package/dist/index.d.ts
CHANGED
|
@@ -13,6 +13,7 @@ export { ListGroup, type ListGroupProps } from './components/ListGroup.js';
|
|
|
13
13
|
export { inspectMarkdown, markdownOmissionKinds, MarkdownView, type MarkdownHeading, type MarkdownInspection, type MarkdownImage, type MarkdownImageResolver, type MarkdownOmission, type MarkdownOmissionCounts, type MarkdownOmissionKind, type MarkdownOptions, type MarkdownViewProps, } from './components/MarkdownView.js';
|
|
14
14
|
export { ListRow, type ListRowProps } from './components/ListRow.js';
|
|
15
15
|
export { listGroupVariants, listRowTitleAppearances, listRowTones, listRowValueTones, type ListGroupMaxHeight, type ListGroupVariant, type ListRowTitleAppearance, type ListRowTone, type ListRowValueTone, } from './list-options.js';
|
|
16
|
+
export { listTileColumnCounts, type ListGroupTileColumns, } from './css/css-list-tiles.js';
|
|
16
17
|
export { Checkbox, type CheckboxProps } from './components/Checkbox.js';
|
|
17
18
|
export { Spinner, type SpinnerProps, type SpinnerSize, } from './components/Spinner.js';
|
|
18
19
|
export { GlyphTile, glyphTileSizes, type GlyphTileHue, type GlyphTileProps, type GlyphTileSize, } from './components/GlyphTile.js';
|
|
@@ -29,7 +30,8 @@ export { ColumnChart, type ColumnChartCallout, type ColumnChartLine, type Column
|
|
|
29
30
|
export { LineChart, type LineChartCurve, type LineChartMarker, type LineChartPeriod, type LineChartPoint, type LineChartProps, lineChartCurves, } from './components/LineChart.js';
|
|
30
31
|
export { DataBar, type DataBarProps, type DataBarTone, dataBarTones, } from './components/DataBar.js';
|
|
31
32
|
export { Pagination, type PaginationLabels, type PaginationProps, } from './components/Pagination.js';
|
|
32
|
-
export { Button, type ButtonDisabledAppearance, type ButtonProps, type ButtonSize, type ButtonVariant, } from './components/Button.js';
|
|
33
|
+
export { Button, type ButtonDisabledAppearance, type ButtonProps, type ButtonSize, type ButtonVariant, type ButtonWidth, } from './components/Button.js';
|
|
34
|
+
export { buttonWidths } from './css/css-button-width.js';
|
|
33
35
|
export { buttonDisabledAppearances, webButtonVariants, } from './css/css-button.js';
|
|
34
36
|
export { Box, type BoxBorder, type BoxElement, type BoxLink, type BoxProps, } from './components/Box.js';
|
|
35
37
|
export { boxLinkAppearances } from './css/css-box-link.js';
|
package/dist/index.js
CHANGED
|
@@ -12,6 +12,7 @@ export { ListGroup } from './components/ListGroup.js';
|
|
|
12
12
|
export { inspectMarkdown, markdownOmissionKinds, MarkdownView, } from './components/MarkdownView.js';
|
|
13
13
|
export { ListRow } from './components/ListRow.js';
|
|
14
14
|
export { listGroupVariants, listRowTitleAppearances, listRowTones, listRowValueTones, } from './list-options.js';
|
|
15
|
+
export { listTileColumnCounts, } from './css/css-list-tiles.js';
|
|
15
16
|
export { Checkbox } from './components/Checkbox.js';
|
|
16
17
|
export { Spinner, } from './components/Spinner.js';
|
|
17
18
|
export { GlyphTile, glyphTileSizes, } from './components/GlyphTile.js';
|
|
@@ -29,6 +30,7 @@ export { LineChart, lineChartCurves, } from './components/LineChart.js';
|
|
|
29
30
|
export { DataBar, dataBarTones, } from './components/DataBar.js';
|
|
30
31
|
export { Pagination, } from './components/Pagination.js';
|
|
31
32
|
export { Button, } from './components/Button.js';
|
|
33
|
+
export { buttonWidths } from './css/css-button-width.js';
|
|
32
34
|
export { buttonDisabledAppearances, webButtonVariants, } from './css/css-button.js';
|
|
33
35
|
export { Box, } from './components/Box.js';
|
|
34
36
|
export { boxLinkAppearances } from './css/css-box-link.js';
|
package/dist/list-options.d.ts
CHANGED
|
@@ -4,9 +4,11 @@ import { listRowValueTones, type ListRowValueTone } from './css/css-list-row.js'
|
|
|
4
4
|
/**
|
|
5
5
|
* `panel` (the default) is one bordered panel on the surface; `plain`
|
|
6
6
|
* draws no panel and a hairline above the first row too, for rows inside
|
|
7
|
-
* a surface that already has one, such as a Card
|
|
7
|
+
* a surface that already has one, such as a Card; `tiles` lays the rows
|
|
8
|
+
* out as tiles, each its own panel, in equal columns (one a row below
|
|
9
|
+
* `md`).
|
|
8
10
|
*/
|
|
9
|
-
export declare const listGroupVariants: readonly ["panel", "plain"];
|
|
11
|
+
export declare const listGroupVariants: readonly ["panel", "plain", "tiles"];
|
|
10
12
|
export type ListGroupVariant = (typeof listGroupVariants)[number];
|
|
11
13
|
/**
|
|
12
14
|
* A list's bounded height, counted in rows: as tall as `rows` rows of a
|
|
@@ -30,13 +32,18 @@ export type ListGroupInput = {
|
|
|
30
32
|
labelledBy: string | undefined;
|
|
31
33
|
variant: unknown;
|
|
32
34
|
maxHeight: ListGroupMaxHeight | undefined;
|
|
35
|
+
tileColumns: unknown;
|
|
36
|
+
hasFooter: boolean;
|
|
33
37
|
};
|
|
34
38
|
/**
|
|
35
39
|
* Fails closed on a ListGroup it cannot draw: an unknown variant, a
|
|
36
|
-
* `maxHeight` that is not a whole row count from 1,
|
|
37
|
-
* no name (its scroll region is a keyboard stop and must say what it
|
|
40
|
+
* `maxHeight` that is not a whole row count from 1, a bounded list with
|
|
41
|
+
* no name (its scroll region is a keyboard stop and must say what it
|
|
42
|
+
* holds), `tileColumns` other than 2, 3 or 4 or on a list that is not
|
|
43
|
+
* tiles, or tiles with `maxHeight` or a `footer` (tiles neither scroll as
|
|
44
|
+
* rows nor add up to a total).
|
|
38
45
|
*/
|
|
39
|
-
export declare function assertListGroup({ label, labelledBy, variant, maxHeight, }: ListGroupInput): void;
|
|
46
|
+
export declare function assertListGroup({ label, labelledBy, variant, maxHeight, tileColumns, hasFooter, }: ListGroupInput): void;
|
|
40
47
|
/** The row count the generated `sw-list-group-scroll` height reads. */
|
|
41
48
|
export declare function listMaxHeightStyle(maxHeight: ListGroupMaxHeight): CSSProperties;
|
|
42
49
|
export type ListRowInput = {
|
package/dist/list-options.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { badgeTones } from './css/css-data.js';
|
|
2
2
|
import { listRowValueTones, } from './css/css-list-row.js';
|
|
3
|
+
import { listTileColumnCounts } from './css/css-list-tiles.js';
|
|
3
4
|
import { isOneOf } from './is-one-of.js';
|
|
4
5
|
/**
|
|
5
6
|
* `panel` (the default) is one bordered panel on the surface; `plain`
|
|
6
7
|
* draws no panel and a hairline above the first row too, for rows inside
|
|
7
|
-
* a surface that already has one, such as a Card
|
|
8
|
+
* a surface that already has one, such as a Card; `tiles` lays the rows
|
|
9
|
+
* out as tiles, each its own panel, in equal columns (one a row below
|
|
10
|
+
* `md`).
|
|
8
11
|
*/
|
|
9
|
-
export const listGroupVariants = ['panel', 'plain'];
|
|
12
|
+
export const listGroupVariants = ['panel', 'plain', 'tiles'];
|
|
10
13
|
/** `danger` draws the title in the danger colour, for a row that ends something. */
|
|
11
14
|
export const listRowTones = ['default', 'danger'];
|
|
12
15
|
/** `strong` (the default) sets the title at the label weight; `plain` at the body's. */
|
|
@@ -17,12 +20,23 @@ function isBlank(text) {
|
|
|
17
20
|
}
|
|
18
21
|
/**
|
|
19
22
|
* Fails closed on a ListGroup it cannot draw: an unknown variant, a
|
|
20
|
-
* `maxHeight` that is not a whole row count from 1,
|
|
21
|
-
* no name (its scroll region is a keyboard stop and must say what it
|
|
23
|
+
* `maxHeight` that is not a whole row count from 1, a bounded list with
|
|
24
|
+
* no name (its scroll region is a keyboard stop and must say what it
|
|
25
|
+
* holds), `tileColumns` other than 2, 3 or 4 or on a list that is not
|
|
26
|
+
* tiles, or tiles with `maxHeight` or a `footer` (tiles neither scroll as
|
|
27
|
+
* rows nor add up to a total).
|
|
22
28
|
*/
|
|
23
|
-
export function assertListGroup({ label, labelledBy, variant, maxHeight, }) {
|
|
29
|
+
export function assertListGroup({ label, labelledBy, variant, maxHeight, tileColumns, hasFooter, }) {
|
|
24
30
|
if (!isOneOf(listGroupVariants, variant))
|
|
25
31
|
throw new RangeError(`Unknown ListGroup variant: ${String(variant)}`);
|
|
32
|
+
if (tileColumns !== undefined) {
|
|
33
|
+
if (variant !== 'tiles')
|
|
34
|
+
throw new RangeError('ListGroup tileColumns needs variant="tiles"');
|
|
35
|
+
if (!listTileColumnCounts.includes(tileColumns))
|
|
36
|
+
throw new RangeError(`ListGroup tileColumns must be 2, 3 or 4: ${String(tileColumns)}`);
|
|
37
|
+
}
|
|
38
|
+
if (variant === 'tiles' && (maxHeight !== undefined || hasFooter))
|
|
39
|
+
throw new RangeError('A ListGroup of tiles takes no maxHeight or footer');
|
|
26
40
|
if (maxHeight === undefined)
|
|
27
41
|
return;
|
|
28
42
|
const rows = typeof maxHeight === 'object' && maxHeight !== null
|
package/dist/styles.css
CHANGED
|
@@ -967,6 +967,19 @@ body {
|
|
|
967
967
|
.sw-button-xs { min-height: 28px; padding-inline: 10px; }
|
|
968
968
|
.sw-button-sm { min-height: 32px; padding-inline: 12px; }
|
|
969
969
|
.sw-button-md { min-height: 44px; padding-inline: 16px; }
|
|
970
|
+
.sw-button-full {
|
|
971
|
+
width: 100%;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
.sw-inline > .sw-button-full {
|
|
975
|
+
flex: 1 1 0;
|
|
976
|
+
min-width: 0;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
.sw-inline.sw-wrap > .sw-button-full {
|
|
980
|
+
flex: 1 1 auto;
|
|
981
|
+
width: auto;
|
|
982
|
+
}
|
|
970
983
|
.sw-button-group { align-items: center; display: flex; flex-wrap: wrap; gap: var(--sw-space-2); min-width: 0; }
|
|
971
984
|
.sw-button-group-start { justify-content: flex-start; }
|
|
972
985
|
.sw-button-group-end { justify-content: flex-end; }
|
|
@@ -3018,6 +3031,31 @@ body {
|
|
|
3018
3031
|
white-space: nowrap;
|
|
3019
3032
|
}
|
|
3020
3033
|
|
|
3034
|
+
.sw-list-group-tiles > .sw-list {
|
|
3035
|
+
display: grid;
|
|
3036
|
+
gap: var(--sw-space-3);
|
|
3037
|
+
}
|
|
3038
|
+
|
|
3039
|
+
.sw-list-group-tiles-2 > .sw-list { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
3040
|
+
.sw-list-group-tiles-3 > .sw-list { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
|
3041
|
+
.sw-list-group-tiles-4 > .sw-list { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
|
3042
|
+
|
|
3043
|
+
.sw-list-group-tiles > .sw-list > .sw-list-item {
|
|
3044
|
+
background: var(--sw-color-surface);
|
|
3045
|
+
border: 1px solid var(--sw-color-border);
|
|
3046
|
+
border-radius: var(--sw-radius-md);
|
|
3047
|
+
min-width: 0;
|
|
3048
|
+
overflow: clip;
|
|
3049
|
+
}
|
|
3050
|
+
|
|
3051
|
+
@media not all and (min-width: 48rem) {
|
|
3052
|
+
.sw-list-group-tiles > .sw-list { grid-template-columns: minmax(0, 1fr); }
|
|
3053
|
+
}
|
|
3054
|
+
|
|
3055
|
+
@media (forced-colors: active) {
|
|
3056
|
+
.sw-list-group-tiles > .sw-list > .sw-list-item { border-color: CanvasText; }
|
|
3057
|
+
}
|
|
3058
|
+
|
|
3021
3059
|
.sw-file-field-frame {
|
|
3022
3060
|
border: 1px solid var(--sw-color-border);
|
|
3023
3061
|
border-radius: var(--sw-radius-md);
|
|
@@ -3147,6 +3185,83 @@ body {
|
|
|
3147
3185
|
margin-inline-start: auto;
|
|
3148
3186
|
}
|
|
3149
3187
|
|
|
3188
|
+
.sw-file-field-actions {
|
|
3189
|
+
display: flex;
|
|
3190
|
+
flex: none;
|
|
3191
|
+
flex-wrap: wrap;
|
|
3192
|
+
gap: var(--sw-space-2);
|
|
3193
|
+
margin-inline-start: auto;
|
|
3194
|
+
}
|
|
3195
|
+
|
|
3196
|
+
.sw-file-field-compact.sw-file-field-chosen {
|
|
3197
|
+
flex-wrap: nowrap;
|
|
3198
|
+
gap: var(--sw-space-1);
|
|
3199
|
+
min-height: var(--sw-control-md-min-height);
|
|
3200
|
+
padding-block: 0;
|
|
3201
|
+
padding-inline: 0 var(--sw-space-1);
|
|
3202
|
+
}
|
|
3203
|
+
|
|
3204
|
+
.sw-file-field-compact .sw-file-field-zone {
|
|
3205
|
+
justify-content: flex-start;
|
|
3206
|
+
padding: var(--sw-space-2) var(--sw-space-4);
|
|
3207
|
+
text-align: start;
|
|
3208
|
+
}
|
|
3209
|
+
|
|
3210
|
+
.sw-file-field-pick {
|
|
3211
|
+
align-items: center;
|
|
3212
|
+
appearance: none;
|
|
3213
|
+
background: transparent;
|
|
3214
|
+
border: 0;
|
|
3215
|
+
border-radius: inherit;
|
|
3216
|
+
box-sizing: border-box;
|
|
3217
|
+
color: var(--sw-color-text);
|
|
3218
|
+
cursor: pointer;
|
|
3219
|
+
display: flex;
|
|
3220
|
+
flex: 1 1 auto;
|
|
3221
|
+
font-family: var(--sw-font-sans);
|
|
3222
|
+
gap: var(--sw-space-3);
|
|
3223
|
+
margin: 0;
|
|
3224
|
+
min-height: var(--sw-control-md-min-height);
|
|
3225
|
+
min-width: 0;
|
|
3226
|
+
padding-block: var(--sw-space-1);
|
|
3227
|
+
padding-inline: var(--sw-space-1) var(--sw-space-2);
|
|
3228
|
+
text-align: start;
|
|
3229
|
+
}
|
|
3230
|
+
|
|
3231
|
+
.sw-file-field-pick:focus { outline: none; }
|
|
3232
|
+
|
|
3233
|
+
.sw-file-field-pick:focus-visible {
|
|
3234
|
+
outline: var(--sw-focus-ring-width) solid var(--sw-color-accent);
|
|
3235
|
+
outline-offset: calc(-1 * var(--sw-focus-ring-width));
|
|
3236
|
+
}
|
|
3237
|
+
|
|
3238
|
+
.sw-file-field-pick:disabled { cursor: not-allowed; }
|
|
3239
|
+
|
|
3240
|
+
.sw-file-field-pick .sw-file-field-name {
|
|
3241
|
+
font-weight: 400;
|
|
3242
|
+
min-width: 0;
|
|
3243
|
+
overflow: hidden;
|
|
3244
|
+
overflow-wrap: normal;
|
|
3245
|
+
text-overflow: ellipsis;
|
|
3246
|
+
white-space: nowrap;
|
|
3247
|
+
}
|
|
3248
|
+
|
|
3249
|
+
/* With no thumbnail or mark, the name starts where a text field's words do. */
|
|
3250
|
+
.sw-file-field-pick > .sw-file-field-name:first-child { margin-inline-start: var(--sw-space-3); }
|
|
3251
|
+
|
|
3252
|
+
.sw-file-field-compact .sw-file-field-preview,
|
|
3253
|
+
.sw-file-field-compact .sw-file-field-icon { margin-inline-start: var(--sw-space-1); }
|
|
3254
|
+
|
|
3255
|
+
.sw-file-field-remove { flex: none; }
|
|
3256
|
+
|
|
3257
|
+
@media (forced-colors: active) {
|
|
3258
|
+
.sw-file-field-pick { color: ButtonText; }
|
|
3259
|
+
.sw-file-field-pick:disabled { color: GrayText; }
|
|
3260
|
+
}
|
|
3261
|
+
|
|
3262
|
+
/* Disabled, the row fades as its Remove does. */
|
|
3263
|
+
.sw-file-field-pick:disabled { opacity: var(--sw-disabled-opacity); }
|
|
3264
|
+
|
|
3150
3265
|
@media (forced-colors: active) {
|
|
3151
3266
|
.sw-file-field-frame { border-color: CanvasText; }
|
|
3152
3267
|
.sw-field-invalid .sw-file-field-frame { border-color: Mark; }
|