shelving 1.284.0 → 1.285.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/schema/PasswordSchema.d.ts +1 -3
- package/schema/PasswordSchema.js +1 -5
- package/ui/README.md +1 -1
- package/ui/button/Button.d.ts +2 -5
- package/ui/button/Button.js +1 -0
- package/ui/button/Button.md +35 -19
- package/ui/button/Button.module.css +27 -26
- package/ui/button/Button.tsx +2 -5
- package/ui/button/SubmitButton.d.ts +2 -3
- package/ui/button/SubmitButton.js +3 -3
- package/ui/button/SubmitButton.tsx +2 -4
- package/ui/input/SchemaInput.d.ts +2 -1
- package/ui/input/SchemaInput.js +3 -2
- package/ui/input/SchemaInput.test.tsx +73 -7
- package/ui/input/SchemaInput.tsx +11 -2
- package/ui/input/TextInput.d.ts +2 -2
- package/ui/input/TextInput.js +3 -3
- package/ui/input/TextInput.tsx +4 -4
- package/ui/style/TINT_CLASS.md +2 -2
- package/ui/util/ClassProps.md +1 -1
package/package.json
CHANGED
|
@@ -3,15 +3,13 @@ import { StringSchema, type StringSchemaOptions } from "./StringSchema.js";
|
|
|
3
3
|
* Schema that defines a valid password string.
|
|
4
4
|
*
|
|
5
5
|
* - Defaults the `<input />` hint to `"password"`, but a caller can override it (e.g. `"text"` for a show-password toggle).
|
|
6
|
-
* -
|
|
6
|
+
* - Formats the value unchanged (like `StringSchema`) so a password `<input />` can hold and mask it — hiding the value is the input's job, not the schema's.
|
|
7
7
|
*
|
|
8
8
|
* @example new PasswordSchema({}).validate("hunter2"); // Returns "hunter2"
|
|
9
9
|
* @see https://shelving.cc/schema/PasswordSchema
|
|
10
10
|
*/
|
|
11
11
|
export declare class PasswordSchema extends StringSchema {
|
|
12
12
|
constructor({ one, title, min, input, ...options }?: StringSchemaOptions);
|
|
13
|
-
/** Always returns `""` — passwords are never shown. */
|
|
14
|
-
format(): string;
|
|
15
13
|
}
|
|
16
14
|
/**
|
|
17
15
|
* Sugar instance of `PasswordSchema` for a password string. Equivalent to `new PasswordSchema({})`.
|
package/schema/PasswordSchema.js
CHANGED
|
@@ -3,7 +3,7 @@ import { StringSchema } from "./StringSchema.js";
|
|
|
3
3
|
* Schema that defines a valid password string.
|
|
4
4
|
*
|
|
5
5
|
* - Defaults the `<input />` hint to `"password"`, but a caller can override it (e.g. `"text"` for a show-password toggle).
|
|
6
|
-
* -
|
|
6
|
+
* - Formats the value unchanged (like `StringSchema`) so a password `<input />` can hold and mask it — hiding the value is the input's job, not the schema's.
|
|
7
7
|
*
|
|
8
8
|
* @example new PasswordSchema({}).validate("hunter2"); // Returns "hunter2"
|
|
9
9
|
* @see https://shelving.cc/schema/PasswordSchema
|
|
@@ -12,10 +12,6 @@ export class PasswordSchema extends StringSchema {
|
|
|
12
12
|
constructor({ one = "password", title = "Password", min = 6, input = "password", ...options } = {}) {
|
|
13
13
|
super({ one, title, min, input, ...options });
|
|
14
14
|
}
|
|
15
|
-
/** Always returns `""` — passwords are never shown. */
|
|
16
|
-
format() {
|
|
17
|
-
return ""; // Never format a password for display.
|
|
18
|
-
}
|
|
19
15
|
}
|
|
20
16
|
/**
|
|
21
17
|
* Sugar instance of `PasswordSchema` for a password string. Equivalent to `new PasswordSchema({})`.
|
package/ui/README.md
CHANGED
|
@@ -10,7 +10,7 @@ The `ui` module exists so an app never hand-rolls the same form field, card, or
|
|
|
10
10
|
|
|
11
11
|
A few conventions run through every component:
|
|
12
12
|
|
|
13
|
-
- **Styling props are for one-off overrides.** Visual options are props on the component — enumerated props for the scales (`color="red"`, `size="large"`, `space="none"`, `width="narrow"`) and boolean props for on/off variants (`<Button
|
|
13
|
+
- **Styling props are for one-off overrides.** Visual options are props on the component — enumerated props for the scales (`color="red"`, `size="large"`, `space="none"`, `width="narrow"`) and boolean props for on/off variants (`<Button plain>`, `<Title center>`, `<Flex wrap>`). Each maps to a class in a CSS Module. Reach for them when a component needs to look different in *one place* — the way the docs site tints its accents purple — not as the way to dress a whole app. You never pass `style`.
|
|
14
14
|
- **`className` is the escape hatch.** Most components accept an optional `className`, merged *after* the classes the component computes for itself, so an app class reliably wins over the library's `@layer components` rules. It takes any `Classes` value — a string, an array, or a dictionary of `true` flags — and lands on the component's own root element. Use it for a composed one-off treatment that tokens and per-property hooks can't express (an animation, a bespoke gradient); tokens and hooks stay the primary theming surface. Formatting-only components (`<When>`, `<Prose>`, `<Scroll>`), the `*Layout` components, and `<Dialog>` deliberately don't take one — write a new layout, or style the content inside. See `ClassProps`.
|
|
15
15
|
- **Composition.** Higher-level components — a `*Page`, a `*Card` — take their identity from library components like `<Card>`, `<Section>`, `<Button>`, and `<Tag>` rather than shipping their own styling.
|
|
16
16
|
- **Sentence case.** Titles, headings, and button labels capitalise only the first word.
|
package/ui/button/Button.d.ts
CHANGED
|
@@ -10,12 +10,8 @@ import { type ClickableProps } from "./Clickable.js";
|
|
|
10
10
|
* @see https://shelving.cc/ui/ButtonVariants
|
|
11
11
|
*/
|
|
12
12
|
export interface ButtonVariants extends FlexVariants, StatusVariants, TypographyVariants {
|
|
13
|
-
/**
|
|
14
|
-
strong?: boolean | undefined;
|
|
15
|
-
/** Add plain styling (background only appears on hover or focus). */
|
|
13
|
+
/** Add plain styling (no background or border until hover or focus). */
|
|
16
14
|
plain?: boolean | undefined;
|
|
17
|
-
/** Add outline styling (has no background until hover or focus). */
|
|
18
|
-
outline?: boolean | undefined;
|
|
19
15
|
/** Make the button appear smaller. */
|
|
20
16
|
small?: boolean | undefined;
|
|
21
17
|
/** Fill the available width instead of sizing to content (buttons are content-width by default). */
|
|
@@ -39,6 +35,7 @@ export interface ButtonProps extends ButtonVariants, ClickableProps, ClassProps
|
|
|
39
35
|
/**
|
|
40
36
|
* Render either a `<button>` or an `<a href="">` styled as a button, based on whether an `onClick` or `href` prop is provided.
|
|
41
37
|
* - Content-width by default (never grows); it won't shrink below its label. Pass `full` to fill the available width.
|
|
38
|
+
* - Filled by default — use `color=` / `status=` for emphasis, or `plain` to de-emphasise.
|
|
42
39
|
* - Accepts all `ButtonVariants` styling props plus the `ClickableProps` (`onClick`, `href`, `disabled`, etc.).
|
|
43
40
|
*
|
|
44
41
|
* @kind component
|
package/ui/button/Button.js
CHANGED
|
@@ -18,6 +18,7 @@ export function getButtonClass(variants) {
|
|
|
18
18
|
/**
|
|
19
19
|
* Render either a `<button>` or an `<a href="">` styled as a button, based on whether an `onClick` or `href` prop is provided.
|
|
20
20
|
* - Content-width by default (never grows); it won't shrink below its label. Pass `full` to fill the available width.
|
|
21
|
+
* - Filled by default — use `color=` / `status=` for emphasis, or `plain` to de-emphasise.
|
|
21
22
|
* - Accepts all `ButtonVariants` styling props plus the `ClickableProps` (`onClick`, `href`, `disabled`, etc.).
|
|
22
23
|
*
|
|
23
24
|
* @kind component
|
package/ui/button/Button.md
CHANGED
|
@@ -5,8 +5,9 @@ A clickable styled as a solid button. Renders an `<a href="">` when given `href`
|
|
|
5
5
|
**Things to know:**
|
|
6
6
|
|
|
7
7
|
- Content-width by default: it sizes to its label and never grows. Pass `full` to fill the available width (it then shrinks to share a row, down to the content floor).
|
|
8
|
-
-
|
|
9
|
-
- `
|
|
8
|
+
- Every button is filled. Emphasis comes from colour: `color=` / `status=` move the tint anchor, so `color="primary"` marks the main action and a colourless button stays a neutral grey.
|
|
9
|
+
- `plain` de-emphasises — no fill or border until hover/focus, for chrome-level actions like breadcrumbs and a dialog's close button. Set `--button-plain-border` to give plain buttons a resting edge when they need to hold their shape.
|
|
10
|
+
- `small` tightens the padding.
|
|
10
11
|
- `getButtonClass(variants)` returns the same `className` the component composes — use it to style a non-`<button>` element as a button when `Button` itself doesn't fit.
|
|
11
12
|
- `className` attaches an app class to one button, merged after the computed classes so an app stylesheet wins — see `ClassProps`.
|
|
12
13
|
|
|
@@ -17,7 +18,7 @@ A clickable styled as a solid button. Renders an `<a href="">` when given `href`
|
|
|
17
18
|
```tsx
|
|
18
19
|
import { Button } from "shelving/ui";
|
|
19
20
|
|
|
20
|
-
<Button onClick={save} color="primary"
|
|
21
|
+
<Button onClick={save} color="primary">Save</Button>
|
|
21
22
|
<Button href="/about">About</Button>
|
|
22
23
|
<Button onClick={remove} status="error">Delete</Button>
|
|
23
24
|
```
|
|
@@ -30,7 +31,7 @@ import { Row } from "shelving/ui";
|
|
|
30
31
|
|
|
31
32
|
<Row gap="small" right>
|
|
32
33
|
<Button plain onClick={cancel}>Cancel</Button>
|
|
33
|
-
<Button
|
|
34
|
+
<Button color="primary" onClick={submit}>Continue</Button>
|
|
34
35
|
</Row>
|
|
35
36
|
```
|
|
36
37
|
|
|
@@ -40,7 +41,7 @@ import { Row } from "shelving/ui";
|
|
|
40
41
|
import { Button } from "shelving/ui";
|
|
41
42
|
|
|
42
43
|
// `.spongy-press` lives in the app's own stylesheet — use it for what the theme hooks below can't express.
|
|
43
|
-
<Button className="spongy-press"
|
|
44
|
+
<Button className="spongy-press" onClick={claim}>Claim</Button>
|
|
44
45
|
```
|
|
45
46
|
|
|
46
47
|
### Reusing the button class
|
|
@@ -60,16 +61,20 @@ import { getButtonClass } from "shelving/ui";
|
|
|
60
61
|
|
|
61
62
|
`--button-padding` and `--button-small-padding` set the `padding` shorthand, so a single value pads both axes equally and a two-value override pads block and inline separately (e.g. `var(--space-small) var(--space-normal)`).
|
|
62
63
|
|
|
63
|
-
`--button-shadow`, `--button-hover-transform` and the `--button-active-*` pressed-state hooks are static and apply to every button, with one exception: `plain`
|
|
64
|
+
`--button-shadow`, `--button-hover-transform` and the `--button-active-*` pressed-state hooks are static and apply to every button, with one exception: `plain` never paints a box shadow in any state — it has no fill until hover, so a raised edge under it reads broken. The hover and pressed transforms still apply to it, so all buttons move together. `--button-transition` already covers animating the press and release.
|
|
65
|
+
|
|
66
|
+
`plain` carries its own hooks for where it differs: `--button-plain-text` recolours the label, `--button-plain-hover-background` / `--button-plain-hover-border` paint the hover and focus state, the `--button-plain-active-*` pair paints the pressed state (falling back to the plain hover hooks), and `--button-plain-border` sets the resting border — transparent by default, so a theme where plain buttons should keep a visible edge (an "outline" button) sets it once. The hover border falls back through `--button-hover-border`, so a theme that borders every hovered button also borders hovered plain ones.
|
|
67
|
+
|
|
68
|
+
Backgrounds paint to the button's true edge: `background-origin` is set to `border-box`, so a gradient or image background in any state reaches through the transparent border instead of stopping 2px short at the padding box.
|
|
64
69
|
|
|
65
70
|
| Variable | Styles | Default |
|
|
66
71
|
|---|---|---|
|
|
67
|
-
| `--button-background` | Surface fill | `var(--tint-
|
|
68
|
-
| `--button-hover-background` | Surface fill on hover / focus | `var(--tint-
|
|
69
|
-
| `--button-hover-border` | Border on hover / focus | `var(--button-stroke) solid
|
|
72
|
+
| `--button-background` | Surface fill | `var(--tint-50)` |
|
|
73
|
+
| `--button-hover-background` | Surface fill on hover / focus | `var(--tint-55)` |
|
|
74
|
+
| `--button-hover-border` | Border on hover / focus | `var(--button-stroke) solid transparent` |
|
|
70
75
|
| `--button-hover-transform` | Transform on hover / focus | `none` |
|
|
71
|
-
| `--button-text` | Label colour | `var(--tint-
|
|
72
|
-
| `--button-border` | Border shorthand | `var(--button-stroke) solid
|
|
76
|
+
| `--button-text` | Label colour | `var(--tint-100)` |
|
|
77
|
+
| `--button-border` | Border shorthand | `var(--button-stroke) solid transparent` |
|
|
73
78
|
| `--button-stroke` | Border / outline thickness | `var(--stroke-normal)` (2px) |
|
|
74
79
|
| `--button-radius` | Corner radius | `var(--radius-xsmall)` (8px) |
|
|
75
80
|
| `--button-padding` | Inner padding | `var(--space-small)` (12px) |
|
|
@@ -81,7 +86,7 @@ import { getButtonClass } from "shelving/ui";
|
|
|
81
86
|
| `--button-weight` | Font weight | `var(--weight-strong)` (700) |
|
|
82
87
|
| `--button-size` | Font size | `var(--size-normal)` |
|
|
83
88
|
| `--button-leading` | Line height | `var(--leading)` |
|
|
84
|
-
| `--button-shadow` | Box shadow (never on `plain`
|
|
89
|
+
| `--button-shadow` | Box shadow (never on `plain`) | `none` |
|
|
85
90
|
| `--button-active-background` | Surface fill while pressed | `var(--button-hover-background)` |
|
|
86
91
|
| `--button-active-border` | Border while pressed | `var(--button-hover-border)` |
|
|
87
92
|
| `--button-active-shadow` | Box shadow while pressed | `var(--button-shadow)` |
|
|
@@ -89,11 +94,14 @@ import { getButtonClass } from "shelving/ui";
|
|
|
89
94
|
| `--button-transition` | Transition | `all var(--duration-fast)` (150ms) |
|
|
90
95
|
| `--button-focus-border` | Focus outline | `var(--stroke-focus) solid var(--color-focus)` |
|
|
91
96
|
| `--button-disabled-opacity` | Opacity when disabled | `0.5` |
|
|
92
|
-
| `--button-
|
|
93
|
-
| `--button-
|
|
94
|
-
| `--button-
|
|
97
|
+
| `--button-plain-text` | Label colour when `plain` | `var(--tint-50)` |
|
|
98
|
+
| `--button-plain-border` | Resting border when `plain` | `var(--button-stroke) solid transparent` |
|
|
99
|
+
| `--button-plain-hover-background` | Fill on hover / focus when `plain` | `var(--tint-95)` |
|
|
100
|
+
| `--button-plain-hover-border` | Border on hover / focus when `plain` | `var(--button-hover-border)` (transparent) |
|
|
101
|
+
| `--button-plain-active-background` | Fill while pressed when `plain` | `var(--button-plain-hover-background)` |
|
|
102
|
+
| `--button-plain-active-border` | Border while pressed when `plain` | `var(--button-plain-hover-border)` |
|
|
95
103
|
|
|
96
|
-
**Global tokens it reads:** the tint ladder `--tint-50` / `--tint-
|
|
104
|
+
**Global tokens it reads:** the tint ladder `--tint-50` / `--tint-55` / `--tint-95` / `--tint-100`, plus `--space-small`, `--space-xxsmall`, `--radius-xsmall`, `--stroke-normal`, `--stroke-focus`, `--color-focus`, `--font-body`, `--weight-strong`, `--size-normal`, `--leading`, and `--duration-fast`.
|
|
97
105
|
|
|
98
106
|
```css
|
|
99
107
|
/* Theme: pill-shaped buttons, with roomier inline padding. */
|
|
@@ -104,11 +112,19 @@ import { getButtonClass } from "shelving/ui";
|
|
|
104
112
|
```
|
|
105
113
|
|
|
106
114
|
```css
|
|
107
|
-
/* Theme: buttons
|
|
115
|
+
/* Theme: plain buttons keep an edge, so a quiet button holds its shape next to a filled one. */
|
|
116
|
+
:root {
|
|
117
|
+
--button-plain-border: var(--stroke-normal) solid var(--tint-80);
|
|
118
|
+
--button-plain-hover-border: var(--stroke-normal) solid var(--tint-80);
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
```css
|
|
123
|
+
/* Theme: buttons are raised and press down flat — `plain` presses down too but never casts a shadow. */
|
|
108
124
|
:root {
|
|
109
|
-
--button-shadow: 0 0.25rem 0 var(--tint-
|
|
125
|
+
--button-shadow: 0 0.25rem 0 var(--tint-30);
|
|
110
126
|
--button-active-transform: translateY(0.2rem);
|
|
111
|
-
--button-active-shadow: 0 0.05rem 0 var(--tint-
|
|
127
|
+
--button-active-shadow: 0 0.05rem 0 var(--tint-30);
|
|
112
128
|
--button-transition: all var(--duration-fast) cubic-bezier(0.34, 1.56, 0.64, 1);
|
|
113
129
|
}
|
|
114
130
|
```
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
min-inline-size: fit-content;
|
|
17
17
|
max-inline-size: 100%;
|
|
18
18
|
border-radius: var(--button-radius, var(--radius-xsmall));
|
|
19
|
-
border: var(--button-border, var(--button-stroke, var(--stroke-normal)) solid
|
|
19
|
+
border: var(--button-border, var(--button-stroke, var(--stroke-normal)) solid transparent);
|
|
20
20
|
padding: var(--button-padding, var(--space-small));
|
|
21
21
|
margin-inline: 0;
|
|
22
22
|
margin-block: var(--button-space, var(--space-small));
|
|
@@ -29,8 +29,8 @@
|
|
|
29
29
|
align-items: center;
|
|
30
30
|
|
|
31
31
|
/* Style. */
|
|
32
|
-
background: var(--button-background, var(--tint-
|
|
33
|
-
color: var(--button-text, var(--tint-
|
|
32
|
+
background: var(--button-background, var(--tint-50));
|
|
33
|
+
color: var(--button-text, var(--tint-100));
|
|
34
34
|
box-shadow: var(--button-shadow, none);
|
|
35
35
|
transition: var(--button-transition, all var(--duration-fast));
|
|
36
36
|
cursor: pointer;
|
|
@@ -54,15 +54,15 @@
|
|
|
54
54
|
&:enabled:hover,
|
|
55
55
|
&:any-link:hover,
|
|
56
56
|
&:focus:not(:focus-visible) {
|
|
57
|
-
border: var(--button-hover-border, var(--button-stroke, var(--stroke-normal)) solid
|
|
58
|
-
background: var(--button-hover-background, var(--tint-
|
|
57
|
+
border: var(--button-hover-border, var(--button-stroke, var(--stroke-normal)) solid transparent);
|
|
58
|
+
background: var(--button-hover-background, var(--tint-55));
|
|
59
59
|
transform: var(--button-hover-transform, none);
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
&:enabled:active,
|
|
63
63
|
&:any-link:active {
|
|
64
|
-
border: var(--button-active-border, var(--button-hover-border, var(--button-stroke, var(--stroke-normal)) solid
|
|
65
|
-
background: var(--button-active-background, var(--button-hover-background, var(--tint-
|
|
64
|
+
border: var(--button-active-border, var(--button-hover-border, var(--button-stroke, var(--stroke-normal)) solid transparent));
|
|
65
|
+
background: var(--button-active-background, var(--button-hover-background, var(--tint-55)));
|
|
66
66
|
transform: var(--button-active-transform, var(--button-hover-transform, none));
|
|
67
67
|
box-shadow: var(--button-active-shadow, var(--button-shadow, none));
|
|
68
68
|
}
|
|
@@ -78,14 +78,24 @@
|
|
|
78
78
|
inline-size: 100%;
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
-
&.
|
|
82
|
-
|
|
83
|
-
color: var(--button-strong-text, var(--tint-100));
|
|
81
|
+
&.plain {
|
|
82
|
+
color: var(--button-plain-text, var(--tint-50));
|
|
84
83
|
|
|
84
|
+
/* Plain includes plain `:focus` so a keyboard-focused button paints its fill and stays legible. */
|
|
85
85
|
&:enabled:hover,
|
|
86
86
|
&:any-link:hover,
|
|
87
|
-
&:focus
|
|
88
|
-
|
|
87
|
+
&:focus {
|
|
88
|
+
border: var(--button-plain-hover-border, var(--button-hover-border, var(--button-stroke, var(--stroke-normal)) solid transparent));
|
|
89
|
+
background: var(--button-plain-hover-background, var(--tint-95));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
&:enabled:active,
|
|
93
|
+
&:any-link:active {
|
|
94
|
+
border: var(
|
|
95
|
+
--button-plain-active-border,
|
|
96
|
+
var(--button-plain-hover-border, var(--button-hover-border, var(--button-stroke, var(--stroke-normal)) solid transparent))
|
|
97
|
+
);
|
|
98
|
+
background: var(--button-plain-active-background, var(--button-plain-hover-background, var(--tint-95)));
|
|
89
99
|
}
|
|
90
100
|
}
|
|
91
101
|
}
|
|
@@ -93,6 +103,9 @@
|
|
|
93
103
|
|
|
94
104
|
@layer overrides {
|
|
95
105
|
.button {
|
|
106
|
+
/* The border is transparent by default, so background images/gradients must size to the border box to reach the button's edge. Lives in the overrides layer because every state's `background` shorthand resets the origin to `padding-box`. */
|
|
107
|
+
background-origin: border-box;
|
|
108
|
+
|
|
96
109
|
&:first-child {
|
|
97
110
|
margin-block-start: 0;
|
|
98
111
|
}
|
|
@@ -107,23 +120,11 @@
|
|
|
107
120
|
}
|
|
108
121
|
|
|
109
122
|
/* Variants */
|
|
110
|
-
&.strong {
|
|
111
|
-
border-color: transparent;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
123
|
&.plain {
|
|
115
124
|
box-shadow: none;
|
|
116
125
|
|
|
117
|
-
&:not(:enabled:hover, :any-link:hover, :focus) {
|
|
118
|
-
border
|
|
119
|
-
background: transparent;
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
&.outline {
|
|
124
|
-
box-shadow: none;
|
|
125
|
-
|
|
126
|
-
&:not(:enabled:hover, :any-link:hover, :focus) {
|
|
126
|
+
&:not(:enabled:hover, :any-link:hover, :active, :focus) {
|
|
127
|
+
border: var(--button-plain-border, var(--button-stroke, var(--stroke-normal)) solid transparent);
|
|
127
128
|
background: transparent;
|
|
128
129
|
}
|
|
129
130
|
}
|
package/ui/button/Button.tsx
CHANGED
|
@@ -13,12 +13,8 @@ import { Clickable, type ClickableProps } from "./Clickable.js";
|
|
|
13
13
|
* @see https://shelving.cc/ui/ButtonVariants
|
|
14
14
|
*/
|
|
15
15
|
export interface ButtonVariants extends FlexVariants, StatusVariants, TypographyVariants {
|
|
16
|
-
/**
|
|
17
|
-
strong?: boolean | undefined;
|
|
18
|
-
/** Add plain styling (background only appears on hover or focus). */
|
|
16
|
+
/** Add plain styling (no background or border until hover or focus). */
|
|
19
17
|
plain?: boolean | undefined;
|
|
20
|
-
/** Add outline styling (has no background until hover or focus). */
|
|
21
|
-
outline?: boolean | undefined;
|
|
22
18
|
/** Make the button appear smaller. */
|
|
23
19
|
small?: boolean | undefined;
|
|
24
20
|
/** Fill the available width instead of sizing to content (buttons are content-width by default). */
|
|
@@ -51,6 +47,7 @@ export interface ButtonProps extends ButtonVariants, ClickableProps, ClassProps
|
|
|
51
47
|
/**
|
|
52
48
|
* Render either a `<button>` or an `<a href="">` styled as a button, based on whether an `onClick` or `href` prop is provided.
|
|
53
49
|
* - Content-width by default (never grows); it won't shrink below its label. Pass `full` to fill the available width.
|
|
50
|
+
* - Filled by default — use `color=` / `status=` for emphasis, or `plain` to de-emphasise.
|
|
54
51
|
* - Accepts all `ButtonVariants` styling props plus the `ClickableProps` (`onClick`, `href`, `disabled`, etc.).
|
|
55
52
|
*
|
|
56
53
|
* @kind component
|
|
@@ -5,7 +5,6 @@ import { type ButtonVariants } from "./Button.js";
|
|
|
5
5
|
* Component props for `<SubmitButton>`, a form submit button.
|
|
6
6
|
*
|
|
7
7
|
* @property children - The content of the button. Defaults to `"Save"` with a right-pointing arrow icon.
|
|
8
|
-
* @property strong - Whether the button should have strong styling. Defaults to `true`
|
|
9
8
|
* @property color - The color variant of the button. Defaults to `"primary"`
|
|
10
9
|
*
|
|
11
10
|
* @see https://shelving.cc/ui/SubmitButtonProps
|
|
@@ -14,10 +13,10 @@ export interface SubmitButtonProps extends ButtonVariants, OptionalChildProps, C
|
|
|
14
13
|
}
|
|
15
14
|
/**
|
|
16
15
|
* Submit button for a form that disables itself and shows a spinner while the form is busy.
|
|
17
|
-
* - Defaults to
|
|
16
|
+
* - Defaults to full-width, primary styling and a "Save" label.
|
|
18
17
|
*
|
|
19
18
|
* @returns A `<button type="submit">` element bound to the current form.
|
|
20
19
|
* @example <SubmitButton>Save changes</SubmitButton>
|
|
21
20
|
* @see https://shelving.cc/ui/SubmitButton
|
|
22
21
|
*/
|
|
23
|
-
export declare function SubmitButton({ children,
|
|
22
|
+
export declare function SubmitButton({ children, color, full, className, ...props }: SubmitButtonProps): ReactElement;
|
|
@@ -8,14 +8,14 @@ import { getButtonClass } from "./Button.js";
|
|
|
8
8
|
const _SUBMIT_CHILDREN = (_jsxs(_Fragment, { children: ["Save", _jsx(ArrowRightIcon, {})] }));
|
|
9
9
|
/**
|
|
10
10
|
* Submit button for a form that disables itself and shows a spinner while the form is busy.
|
|
11
|
-
* - Defaults to
|
|
11
|
+
* - Defaults to full-width, primary styling and a "Save" label.
|
|
12
12
|
*
|
|
13
13
|
* @returns A `<button type="submit">` element bound to the current form.
|
|
14
14
|
* @example <SubmitButton>Save changes</SubmitButton>
|
|
15
15
|
* @see https://shelving.cc/ui/SubmitButton
|
|
16
16
|
*/
|
|
17
|
-
export function SubmitButton({ children = _SUBMIT_CHILDREN,
|
|
17
|
+
export function SubmitButton({ children = _SUBMIT_CHILDREN, color = "primary", full = true, className, ...props }) {
|
|
18
18
|
const form = requireForm();
|
|
19
19
|
const busy = useStore(form.busy).value;
|
|
20
|
-
return (_jsx("button", { type: "submit", disabled: busy, className: getClass(getButtonClass({
|
|
20
|
+
return (_jsx("button", { type: "submit", disabled: busy, className: getClass(getButtonClass({ color, full, ...props }), className), children: busy ? LOADING : children }));
|
|
21
21
|
}
|
|
@@ -11,7 +11,6 @@ import { type ButtonVariants, getButtonClass } from "./Button.js";
|
|
|
11
11
|
* Component props for `<SubmitButton>`, a form submit button.
|
|
12
12
|
*
|
|
13
13
|
* @property children - The content of the button. Defaults to `"Save"` with a right-pointing arrow icon.
|
|
14
|
-
* @property strong - Whether the button should have strong styling. Defaults to `true`
|
|
15
14
|
* @property color - The color variant of the button. Defaults to `"primary"`
|
|
16
15
|
*
|
|
17
16
|
* @see https://shelving.cc/ui/SubmitButtonProps
|
|
@@ -27,7 +26,7 @@ const _SUBMIT_CHILDREN = (
|
|
|
27
26
|
|
|
28
27
|
/**
|
|
29
28
|
* Submit button for a form that disables itself and shows a spinner while the form is busy.
|
|
30
|
-
* - Defaults to
|
|
29
|
+
* - Defaults to full-width, primary styling and a "Save" label.
|
|
31
30
|
*
|
|
32
31
|
* @returns A `<button type="submit">` element bound to the current form.
|
|
33
32
|
* @example <SubmitButton>Save changes</SubmitButton>
|
|
@@ -35,7 +34,6 @@ const _SUBMIT_CHILDREN = (
|
|
|
35
34
|
*/
|
|
36
35
|
export function SubmitButton({
|
|
37
36
|
children = _SUBMIT_CHILDREN,
|
|
38
|
-
strong = true,
|
|
39
37
|
color = "primary",
|
|
40
38
|
full = true,
|
|
41
39
|
className,
|
|
@@ -44,7 +42,7 @@ export function SubmitButton({
|
|
|
44
42
|
const form = requireForm();
|
|
45
43
|
const busy = useStore(form.busy).value;
|
|
46
44
|
return (
|
|
47
|
-
<button type="submit" disabled={busy} className={getClass(getButtonClass({
|
|
45
|
+
<button type="submit" disabled={busy} className={getClass(getButtonClass({ color, full, ...props }), className)}>
|
|
48
46
|
{busy ? LOADING : children}
|
|
49
47
|
</button>
|
|
50
48
|
);
|
|
@@ -115,13 +115,14 @@ export interface StringSchemaInputProps extends SchemaInputProps<StringSchema, u
|
|
|
115
115
|
}
|
|
116
116
|
/**
|
|
117
117
|
* Show a `TextInput` for a `StringSchema`, sanitising and formatting values with the schema.
|
|
118
|
+
* - Publishes the sanitized value through `onValue`; the schema's `format()` only shapes what the input displays (on first render and on blur).
|
|
118
119
|
*
|
|
119
120
|
* @returns A `TextInput` element bound to the schema.
|
|
120
121
|
* @kind component
|
|
121
122
|
* @example <StringSchemaInput name="email" schema={EMAIL} />
|
|
122
123
|
* @see https://shelving.cc/ui/StringSchemaInput
|
|
123
124
|
*/
|
|
124
|
-
export declare function StringSchemaInput({ schema, value, ...props }: StringSchemaInputProps): ReactElement;
|
|
125
|
+
export declare function StringSchemaInput({ schema, value, onValue, ...props }: StringSchemaInputProps): ReactElement;
|
|
125
126
|
/**
|
|
126
127
|
* Props for `ArraySchemaInput`, the `ArraySchema` input variant.
|
|
127
128
|
*
|
package/ui/input/SchemaInput.js
CHANGED
|
@@ -123,14 +123,15 @@ export function BooleanSchemaInput({ schema, value, ...props }) {
|
|
|
123
123
|
}
|
|
124
124
|
/**
|
|
125
125
|
* Show a `TextInput` for a `StringSchema`, sanitising and formatting values with the schema.
|
|
126
|
+
* - Publishes the sanitized value through `onValue`; the schema's `format()` only shapes what the input displays (on first render and on blur).
|
|
126
127
|
*
|
|
127
128
|
* @returns A `TextInput` element bound to the schema.
|
|
128
129
|
* @kind component
|
|
129
130
|
* @example <StringSchemaInput name="email" schema={EMAIL} />
|
|
130
131
|
* @see https://shelving.cc/ui/StringSchemaInput
|
|
131
132
|
*/
|
|
132
|
-
export function StringSchemaInput({ schema, value, ...props }) {
|
|
133
|
-
return _jsx(TextInput, { ...schema, value: getString(value), formatter: str => schema.format(schema.sanitize(str)), ...props });
|
|
133
|
+
export function StringSchemaInput({ schema, value, onValue, ...props }) {
|
|
134
|
+
return (_jsx(TextInput, { ...schema, value: getString(value), onValue: str => onValue(str === undefined ? undefined : schema.sanitize(str)), formatter: str => schema.format(schema.sanitize(str)), ...props }));
|
|
134
135
|
}
|
|
135
136
|
/**
|
|
136
137
|
* Show an `ArrayInput` for an `ArraySchema`.
|
|
@@ -1,9 +1,34 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
|
-
import { StringSchema } from "shelving/schema";
|
|
4
|
-
import { StringSchemaInput } from "shelving/ui";
|
|
3
|
+
import { PASSWORD, StringSchema, URL_SCHEMA } from "shelving/schema";
|
|
4
|
+
import { StringSchemaInput, TextInput, type TextInputProps } from "shelving/ui";
|
|
5
5
|
import { PASSTHROUGH } from "shelving/util/function";
|
|
6
6
|
|
|
7
|
+
/** A `StringSchema` with a non-identity `format()` (wraps in brackets) so display and published values differ. */
|
|
8
|
+
class BracketSchema extends StringSchema {
|
|
9
|
+
override format(str: string): string {
|
|
10
|
+
return str ? `[${str}]` : str;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Render a `StringSchemaInput` down to its `<input>` element's props, so the event handlers can be called directly.
|
|
16
|
+
* - Neither component uses hooks, so calling them as plain functions is safe without a DOM.
|
|
17
|
+
*/
|
|
18
|
+
function _getInputProps(schema: StringSchema, onValue: (value: string | undefined) => void, value?: string): _InputProps {
|
|
19
|
+
const props: { schema: StringSchema; name: string; onValue: typeof onValue; value?: string } = { schema, name: "field", onValue };
|
|
20
|
+
if (value !== undefined) props.value = value;
|
|
21
|
+
return TextInput(StringSchemaInput(props).props as TextInputProps).props as _InputProps;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The `<input>` props the tests read and call. */
|
|
25
|
+
interface _InputProps {
|
|
26
|
+
readonly type?: string;
|
|
27
|
+
readonly defaultValue?: string;
|
|
28
|
+
onInput(e: { currentTarget: { value: string } }): void;
|
|
29
|
+
onBlur(e: { currentTarget: { value: string } }): void;
|
|
30
|
+
}
|
|
31
|
+
|
|
7
32
|
describe("StringSchemaInput", () => {
|
|
8
33
|
test("formats the initial value to its clean sanitized value", () => {
|
|
9
34
|
// The `formatter` runs `schema.sanitize()` then `schema.format()`, so runs of whitespace collapse and the value trims.
|
|
@@ -23,14 +48,55 @@ describe("StringSchemaInput", () => {
|
|
|
23
48
|
|
|
24
49
|
test("applies a subclass `format()` after sanitizing", () => {
|
|
25
50
|
// A subclass `format()` is non-identity (here wrapping in brackets), so the clean value is sanitized then formatted.
|
|
26
|
-
class BracketSchema extends StringSchema {
|
|
27
|
-
override format(str: string): string {
|
|
28
|
-
return str ? `[${str}]` : str;
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
51
|
const schema = new BracketSchema({});
|
|
32
52
|
const html = renderToStaticMarkup(<StringSchemaInput name="tag" schema={schema} value=" abc " onValue={PASSTHROUGH} />);
|
|
33
53
|
|
|
34
54
|
expect(html).toContain('value="[abc]"');
|
|
35
55
|
});
|
|
56
|
+
|
|
57
|
+
test("publishes the sanitized value, not the formatted one", () => {
|
|
58
|
+
// `format()` is for display only — the store must receive a value that re-validates.
|
|
59
|
+
const values: (string | undefined)[] = [];
|
|
60
|
+
const input = _getInputProps(new BracketSchema({}), v => void values.push(v));
|
|
61
|
+
input.onInput({ currentTarget: { value: " abc " } });
|
|
62
|
+
|
|
63
|
+
expect(values).toEqual(["abc"]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("reformats the displayed value on blur without publishing it", () => {
|
|
67
|
+
const values: (string | undefined)[] = [];
|
|
68
|
+
const input = _getInputProps(new BracketSchema({}), v => void values.push(v));
|
|
69
|
+
const currentTarget = { value: " abc " };
|
|
70
|
+
input.onBlur({ currentTarget });
|
|
71
|
+
|
|
72
|
+
expect(currentTarget.value).toBe("[abc]");
|
|
73
|
+
expect(values).toEqual([]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("publishes a password unchanged and keeps it displayed on blur", () => {
|
|
77
|
+
// A `PasswordSchema` field must submit the typed password; masking is the `type="password"` input's job.
|
|
78
|
+
const values: (string | undefined)[] = [];
|
|
79
|
+
const input = _getInputProps(PASSWORD, v => void values.push(v), "hunter2");
|
|
80
|
+
input.onInput({ currentTarget: { value: "hunter22" } });
|
|
81
|
+
const currentTarget = { value: "hunter22" };
|
|
82
|
+
input.onBlur({ currentTarget });
|
|
83
|
+
|
|
84
|
+
expect(input.type).toBe("password");
|
|
85
|
+
expect(input.defaultValue).toBe("hunter2");
|
|
86
|
+
expect(values).toEqual(["hunter22"]);
|
|
87
|
+
expect(currentTarget.value).toBe("hunter22");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("publishes a URL that still validates while displaying its friendly form", () => {
|
|
91
|
+
// `URLSchema.format()` strips the scheme for display; publishing that form would fail re-validation.
|
|
92
|
+
const values: (string | undefined)[] = [];
|
|
93
|
+
const input = _getInputProps(URL_SCHEMA, v => void values.push(v));
|
|
94
|
+
input.onInput({ currentTarget: { value: " https://example.com/path " } });
|
|
95
|
+
const currentTarget = { value: "https://example.com/path" };
|
|
96
|
+
input.onBlur({ currentTarget });
|
|
97
|
+
|
|
98
|
+
expect(values).toEqual(["https://example.com/path"]);
|
|
99
|
+
expect(URL_SCHEMA.validate(values[0])).toBe("https://example.com/path");
|
|
100
|
+
expect(currentTarget.value).toBe("example.com/path");
|
|
101
|
+
});
|
|
36
102
|
});
|
package/ui/input/SchemaInput.tsx
CHANGED
|
@@ -192,14 +192,23 @@ export interface StringSchemaInputProps extends SchemaInputProps<StringSchema, u
|
|
|
192
192
|
|
|
193
193
|
/**
|
|
194
194
|
* Show a `TextInput` for a `StringSchema`, sanitising and formatting values with the schema.
|
|
195
|
+
* - Publishes the sanitized value through `onValue`; the schema's `format()` only shapes what the input displays (on first render and on blur).
|
|
195
196
|
*
|
|
196
197
|
* @returns A `TextInput` element bound to the schema.
|
|
197
198
|
* @kind component
|
|
198
199
|
* @example <StringSchemaInput name="email" schema={EMAIL} />
|
|
199
200
|
* @see https://shelving.cc/ui/StringSchemaInput
|
|
200
201
|
*/
|
|
201
|
-
export function StringSchemaInput({ schema, value, ...props }: StringSchemaInputProps): ReactElement {
|
|
202
|
-
return
|
|
202
|
+
export function StringSchemaInput({ schema, value, onValue, ...props }: StringSchemaInputProps): ReactElement {
|
|
203
|
+
return (
|
|
204
|
+
<TextInput
|
|
205
|
+
{...schema}
|
|
206
|
+
value={getString(value)}
|
|
207
|
+
onValue={str => onValue(str === undefined ? undefined : schema.sanitize(str))}
|
|
208
|
+
formatter={str => schema.format(schema.sanitize(str))}
|
|
209
|
+
{...props}
|
|
210
|
+
/>
|
|
211
|
+
);
|
|
203
212
|
}
|
|
204
213
|
|
|
205
214
|
/**
|
package/ui/input/TextInput.d.ts
CHANGED
|
@@ -13,12 +13,12 @@ export interface TextInputProps extends ValueInputProps<string>, InputVariants {
|
|
|
13
13
|
input?: StringInputType;
|
|
14
14
|
min?: number | undefined;
|
|
15
15
|
max?: number | undefined;
|
|
16
|
-
/** Optional formatter — when provided the value is reformatted on blur and when initially displayed. */
|
|
16
|
+
/** Optional formatter — when provided the value is reformatted on blur and when initially displayed (the value published through `onValue` is never formatted). */
|
|
17
17
|
formatter?: TextFormatter | undefined;
|
|
18
18
|
}
|
|
19
19
|
/**
|
|
20
20
|
* Text input bound to a `string` value, rendered as an `<input>` or a `<textarea>` when `rows > 1`.
|
|
21
|
-
* - Applies an optional `formatter` on initial display and on blur
|
|
21
|
+
* - Applies an optional `formatter` on initial display and on blur — the raw typed value is published through `onValue`.
|
|
22
22
|
* - Multiline mode auto-grows the textarea to fit its content.
|
|
23
23
|
*
|
|
24
24
|
* @returns A text `<input>` or `<textarea>` element.
|
package/ui/input/TextInput.js
CHANGED
|
@@ -5,7 +5,7 @@ import { getClass, getModuleClass } from "../util/css.js";
|
|
|
5
5
|
import INPUT_CSS from "./Input.module.css";
|
|
6
6
|
/**
|
|
7
7
|
* Text input bound to a `string` value, rendered as an `<input>` or a `<textarea>` when `rows > 1`.
|
|
8
|
-
* - Applies an optional `formatter` on initial display and on blur
|
|
8
|
+
* - Applies an optional `formatter` on initial display and on blur — the raw typed value is published through `onValue`.
|
|
9
9
|
* - Multiline mode auto-grows the textarea to fit its content.
|
|
10
10
|
*
|
|
11
11
|
* @returns A text `<input>` or `<textarea>` element.
|
|
@@ -19,13 +19,13 @@ required = false, disabled = false, message = "", value, onValue, input = "text"
|
|
|
19
19
|
};
|
|
20
20
|
if (rows > 1) {
|
|
21
21
|
const onChange = (e) => {
|
|
22
|
-
onValue?.(
|
|
22
|
+
onValue?.(e.currentTarget.value);
|
|
23
23
|
_resize(e.currentTarget, rows);
|
|
24
24
|
};
|
|
25
25
|
return (_jsx("textarea", { ref: el => void (el && _resize(el, rows)), name: name, defaultValue: value !== undefined ? formatter(value) : "", minLength: Number.isFinite(min) ? min : 0, maxLength: Number.isFinite(max) ? max : undefined, rows: rows, required: required && min > 0, disabled: disabled, placeholder: placeholder || " ", className: getClass(getInputClass(variants), getModuleClass(INPUT_CSS, "text"), getModuleClass(INPUT_CSS, "multiline"), className), onInput: onChange, onChange: onChange, onBlur: onBlur, title: message, "aria-invalid": !!message }));
|
|
26
26
|
}
|
|
27
27
|
const onChange = (e) => {
|
|
28
|
-
onValue?.(
|
|
28
|
+
onValue?.(e.currentTarget.value);
|
|
29
29
|
};
|
|
30
30
|
return (_jsx("input", { name: name, type: input, defaultValue: value !== undefined ? formatter(value) : "", minLength: Number.isFinite(min) ? min : 0, maxLength: Number.isFinite(max) ? max : undefined, required: required && min > 0, disabled: disabled, placeholder: placeholder || " ", className: getClass(getInputClass(variants), getModuleClass(INPUT_CSS, "text"), className), onInput: onChange, onChange: onChange, onBlur: onBlur, title: message, "aria-invalid": !!message }));
|
|
31
31
|
}
|
package/ui/input/TextInput.tsx
CHANGED
|
@@ -18,13 +18,13 @@ export interface TextInputProps extends ValueInputProps<string>, InputVariants {
|
|
|
18
18
|
input?: StringInputType;
|
|
19
19
|
min?: number | undefined;
|
|
20
20
|
max?: number | undefined;
|
|
21
|
-
/** Optional formatter — when provided the value is reformatted on blur and when initially displayed. */
|
|
21
|
+
/** Optional formatter — when provided the value is reformatted on blur and when initially displayed (the value published through `onValue` is never formatted). */
|
|
22
22
|
formatter?: TextFormatter | undefined;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* Text input bound to a `string` value, rendered as an `<input>` or a `<textarea>` when `rows > 1`.
|
|
27
|
-
* - Applies an optional `formatter` on initial display and on blur
|
|
27
|
+
* - Applies an optional `formatter` on initial display and on blur — the raw typed value is published through `onValue`.
|
|
28
28
|
* - Multiline mode auto-grows the textarea to fit its content.
|
|
29
29
|
*
|
|
30
30
|
* @returns A text `<input>` or `<textarea>` element.
|
|
@@ -54,7 +54,7 @@ export function TextInput({
|
|
|
54
54
|
|
|
55
55
|
if (rows > 1) {
|
|
56
56
|
const onChange = (e: SyntheticEvent<HTMLTextAreaElement>) => {
|
|
57
|
-
onValue?.(
|
|
57
|
+
onValue?.(e.currentTarget.value);
|
|
58
58
|
_resize(e.currentTarget, rows);
|
|
59
59
|
};
|
|
60
60
|
|
|
@@ -80,7 +80,7 @@ export function TextInput({
|
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
const onChange = (e: SyntheticEvent<HTMLInputElement>) => {
|
|
83
|
-
onValue?.(
|
|
83
|
+
onValue?.(e.currentTarget.value);
|
|
84
84
|
};
|
|
85
85
|
|
|
86
86
|
return (
|
package/ui/style/TINT_CLASS.md
CHANGED
|
@@ -29,9 +29,9 @@ Components paint from the ladder by convention:
|
|
|
29
29
|
| Step | Used for |
|
|
30
30
|
|---|---|
|
|
31
31
|
| `--tint-00` | Body text, headings — maximum contrast |
|
|
32
|
-
| `--tint-50` | The hue itself — accents, labels, `Tag` backgrounds,
|
|
32
|
+
| `--tint-50` | The hue itself — accents, labels, `Tag` backgrounds, `<Button>` backgrounds |
|
|
33
33
|
| `--tint-80` | Borders |
|
|
34
|
-
| `--tint-90` | Surfaces — `<Card>`, `Preformatted
|
|
34
|
+
| `--tint-90` | Surfaces — `<Card>`, `Preformatted` backgrounds |
|
|
35
35
|
| `--tint-95` | Hover state of those surfaces |
|
|
36
36
|
| `--tint-100` | The page background; text on `--tint-50` backgrounds |
|
|
37
37
|
|
package/ui/util/ClassProps.md
CHANGED
|
@@ -19,7 +19,7 @@ The shared `className` prop most components accept — an app class merged *afte
|
|
|
19
19
|
import { Button } from "shelving/ui";
|
|
20
20
|
|
|
21
21
|
// `.spongy-press` lives in the app's own global stylesheet.
|
|
22
|
-
<Button className="spongy-press"
|
|
22
|
+
<Button className="spongy-press" onClick={claim}>
|
|
23
23
|
Claim
|
|
24
24
|
</Button>;
|
|
25
25
|
```
|