@atelic-action/ui 0.8.0 → 0.9.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/README.md +16 -1
- package/package.json +8 -3
- package/src/components/StarRating.tsx +153 -0
- package/src/components/index.ts +1 -0
- package/src/styles/components.css +2 -0
- package/src/styles/star-rating.css +67 -0
package/README.md
CHANGED
|
@@ -41,7 +41,7 @@ The package sheets sit inside `@layer atelic-ui`, so any rule a site writes outs
|
|
|
41
41
|
| Import | Exports |
|
|
42
42
|
|---|---|
|
|
43
43
|
| `@atelic-action/ui/chrome` | `SiteHeader`, `SiteMenu`, `Footer`, `CreditBar`, `StickyCTABar`, `BrandLockup`, `SkipLink`, and their prop types |
|
|
44
|
-
| `@atelic-action/ui/components` | `NotFound` and the page components (`Checklist`, `DataTable`, `StatRow`, `Callout`, `Collapsible`, `StackedBar`, `ColumnChart`, `ThresholdScale`), with their prop types (see [The Not Found Page](#the-not-found-page) and [Page Components](#page-components)) |
|
|
44
|
+
| `@atelic-action/ui/components` | `NotFound` and the page components (`Checklist`, `DataTable`, `StatRow`, `Callout`, `Collapsible`, `StackedBar`, `ColumnChart`, `ThresholdScale`, `StarRating`), with their prop types (see [The Not Found Page](#the-not-found-page) and [Page Components](#page-components)) |
|
|
45
45
|
| `@atelic-action/ui/email` | The email components, the theme provider, the plain text helpers, and their prop types (see [Email](#email)) |
|
|
46
46
|
| `@atelic-action/ui/email/render` | `renderEmail` and `renderFailureEmail`, the only entry that imports `react-dom/server` |
|
|
47
47
|
| `@atelic-action/ui/tokens` | `Palette`, `atelicPalette`, `Fonts`, `atelicFonts`, `toThemeCSS`, `themeTokenMap` |
|
|
@@ -50,6 +50,7 @@ The package sheets sit inside `@layer atelic-ui`, so any rule a site writes outs
|
|
|
50
50
|
| `@atelic-action/ui/styles/base.css` | Resets, the `.mkt` canvas, typography, and layout helpers |
|
|
51
51
|
| `@atelic-action/ui/styles/chrome.css` | Styles for everything under `chrome` |
|
|
52
52
|
| `@atelic-action/ui/styles/components.css` | Layout defaults for everything under `components` |
|
|
53
|
+
| `@atelic-action/ui/styles/star-rating.css` | `StarRating`'s styles alone, for an app that wears none of the site chrome; `components.css` already imports it |
|
|
53
54
|
|
|
54
55
|
Every component renders from props alone. None reads a config file or a router, so a site maps its own config onto the props in its shell:
|
|
55
56
|
|
|
@@ -261,9 +262,23 @@ The blocks a page composes, each styled in `styles/components.css` inside the `a
|
|
|
261
262
|
| `StackedBar` | One bar split into the parts of a whole, with a key | A part of zero is keyed and not drawn |
|
|
262
263
|
| `ColumnChart` | A bar a period, the periods in groups that carry a label and a total | Months in quarters is the case it was cut for: a quarter reads against the one before it and the same one a year earlier |
|
|
263
264
|
| `ThresholdScale` | Zones on a scale and a pin for each reading | For a number judged against lines, like a load time against good and poor |
|
|
265
|
+
| `StarRating` | A row of stars showing a rating in half star steps, and taking one when given `onChange` | Each star is two hit targets; picking the current rating clears it. Editable, it is one slider to a keyboard and a screen reader; read only, a picture named by the rating. `fillColor` and `outlineColor` take any CSS color, `size` and `max` set the star and the scale |
|
|
266
|
+
|
|
267
|
+
**StarRating outside a site.** It is the one component that does not sit under `.mkt` or read a theme token: its styles are in `styles/star-rating.css`, and its two colors are the `--star-fill` and `--star-outline` custom properties, falling back to the text color and a faint tint of it. An app that installs the package for this component alone imports that one sheet, not `components.css`.
|
|
264
268
|
|
|
265
269
|
**Tones.** `good`, `warn`, `bad`, `info`, and `muted` are the one palette the package carries, because good, a warning, and broken read green, amber, and red whatever the brand is. A component sets `data-tone`, and `--tone`, `--tone-fill`, and `--tone-bg` follow for whatever sits inside. A site's theme may restate `--tone-good` and the rest on `.mkt`; `info` and `muted` come from the theme's own primary and neutral tokens.
|
|
266
270
|
|
|
271
|
+
## The Gallery
|
|
272
|
+
|
|
273
|
+
`gallery/` is one static page, The Components, that shows every page component on its own with made up data for a fictional bakery, wearing the practice's own palette and type. It lives at [ui.atelic.me](https://ui.atelic.me), noindexed and disallowed in its `robots.txt`, since it is for the people building with the package and never search inventory. It imports the components and stylesheets from `src/` by relative path rather than from npm, so it always shows the code in the repo, and it reads the package name and version from `package.json` at build time. It sits outside `src/`, so it is never published.
|
|
274
|
+
|
|
275
|
+
```bash
|
|
276
|
+
bun run gallery:dev # a dev server on the repo's own source
|
|
277
|
+
bun run gallery:build # the static page, written to gallery/dist
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
`vercel.json` builds it the same way and serves `gallery/dist`. Typecheck and lint cover it, and CI builds it, so a component change that breaks the gallery fails CI. **A new page component gets a gallery section in the same change**, with each of its meaningfully different forms shown.
|
|
281
|
+
|
|
267
282
|
## Releasing
|
|
268
283
|
|
|
269
284
|
1. Bump `version` in `package.json` and merge it to `main`: patch for a fix, minor for a new component or prop, major for a breaking prop or class rename.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@atelic-action/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Shared UI for the Atelic templates: site chrome, base styles, and the component library they install",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "UNLICENSED",
|
|
@@ -28,13 +28,16 @@
|
|
|
28
28
|
"./styles/base.css": "./src/styles/base.css",
|
|
29
29
|
"./styles/chrome.css": "./src/styles/chrome.css",
|
|
30
30
|
"./styles/components.css": "./src/styles/components.css",
|
|
31
|
+
"./styles/star-rating.css": "./src/styles/star-rating.css",
|
|
31
32
|
"./package.json": "./package.json"
|
|
32
33
|
},
|
|
33
34
|
"scripts": {
|
|
34
35
|
"lint": "biome check .",
|
|
35
36
|
"lint:fix": "biome check . --write",
|
|
36
|
-
"typecheck": "tsc --noEmit",
|
|
37
|
-
"test:run": "vitest run"
|
|
37
|
+
"typecheck": "tsc --noEmit && tsc --noEmit -p gallery",
|
|
38
|
+
"test:run": "vitest run",
|
|
39
|
+
"gallery:dev": "vite gallery",
|
|
40
|
+
"gallery:build": "vite build gallery"
|
|
38
41
|
},
|
|
39
42
|
"peerDependencies": {
|
|
40
43
|
"lucide-react": "^1.23.0",
|
|
@@ -47,11 +50,13 @@
|
|
|
47
50
|
"@testing-library/react": "^16.0.1",
|
|
48
51
|
"@types/react": "^19.0.0",
|
|
49
52
|
"@types/react-dom": "^19.0.0",
|
|
53
|
+
"@vitejs/plugin-react": "^6.1.1",
|
|
50
54
|
"jsdom": "^25.0.1",
|
|
51
55
|
"lucide-react": "^1.23.0",
|
|
52
56
|
"react": "^19.0.0",
|
|
53
57
|
"react-dom": "^19.0.0",
|
|
54
58
|
"typescript": "^5.6.3",
|
|
59
|
+
"vite": "^8.3.2",
|
|
55
60
|
"vitest": "^4.0.0"
|
|
56
61
|
},
|
|
57
62
|
"trustedDependencies": [
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { Star } from "lucide-react";
|
|
2
|
+
import { type CSSProperties, type KeyboardEvent, useState } from "react";
|
|
3
|
+
|
|
4
|
+
const STEP = 0.5;
|
|
5
|
+
|
|
6
|
+
export interface StarRatingProps {
|
|
7
|
+
/** The rating, in half star steps from 0.5 up to `max`; null is unrated. */
|
|
8
|
+
value: number | null;
|
|
9
|
+
/** Called with the new rating, or null to clear it. Leave it out for a read only row. */
|
|
10
|
+
onChange?: (value: number | null) => void;
|
|
11
|
+
/** How many stars the scale runs to. */
|
|
12
|
+
max?: number;
|
|
13
|
+
/** The side of one star, in pixels. */
|
|
14
|
+
size?: number;
|
|
15
|
+
/** The color of a filled star: any CSS color. Defaults to the text color around it. */
|
|
16
|
+
fillColor?: string;
|
|
17
|
+
/** The color of a star's outline: any CSS color. Defaults to a faint tint of the text color. */
|
|
18
|
+
outlineColor?: string;
|
|
19
|
+
/** What a screen reader calls the row. */
|
|
20
|
+
label?: string;
|
|
21
|
+
className?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** "4.5 stars", "1 star", "Not rated": the rating as a person would say it. */
|
|
25
|
+
export function describeStarRating(value: number | null): string {
|
|
26
|
+
if (value === null) return "Not rated";
|
|
27
|
+
return `${value} ${value === 1 ? "star" : "stars"}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A row of stars that shows a rating in half star steps and, given `onChange`,
|
|
32
|
+
* takes one. Each star is two hit targets, its left half and its right, so a
|
|
33
|
+
* pointer lands on 3.5 or 4 without a second control. Picking the rating
|
|
34
|
+
* already set clears it, which is the only way back to unrated and needs no
|
|
35
|
+
* button of its own.
|
|
36
|
+
*
|
|
37
|
+
* To a keyboard and a screen reader an editable row is one slider: the arrows
|
|
38
|
+
* move half a star, Home clears, End is the top of the scale. The halves stay
|
|
39
|
+
* out of the tab order, so the row is one stop rather than ten. Without
|
|
40
|
+
* `onChange` it is a picture with the rating as its name.
|
|
41
|
+
*
|
|
42
|
+
* The two colors are the `--star-fill` and `--star-outline` custom properties.
|
|
43
|
+
* `fillColor` and `outlineColor` set them on this row; a site may also set
|
|
44
|
+
* them in its own CSS for every row at once.
|
|
45
|
+
*/
|
|
46
|
+
export function StarRating({
|
|
47
|
+
value,
|
|
48
|
+
onChange,
|
|
49
|
+
max = 5,
|
|
50
|
+
size = 24,
|
|
51
|
+
fillColor,
|
|
52
|
+
outlineColor,
|
|
53
|
+
label = "Rating",
|
|
54
|
+
className,
|
|
55
|
+
}: StarRatingProps) {
|
|
56
|
+
const [hover, setHover] = useState<number | null>(null);
|
|
57
|
+
const shown = hover ?? value ?? 0;
|
|
58
|
+
|
|
59
|
+
const style = {
|
|
60
|
+
"--star-size": `${size}px`,
|
|
61
|
+
...(fillColor ? { "--star-fill": fillColor } : {}),
|
|
62
|
+
...(outlineColor ? { "--star-outline": outlineColor } : {}),
|
|
63
|
+
} as CSSProperties;
|
|
64
|
+
const classes = ["star-rating", className].filter(Boolean).join(" ");
|
|
65
|
+
|
|
66
|
+
const stars = Array.from({ length: max }, (_, i) => i + 1).map((star) => {
|
|
67
|
+
const fill = shown >= star ? "100%" : shown >= star - STEP ? "50%" : "0%";
|
|
68
|
+
return (
|
|
69
|
+
<span key={star} className="star-rating-star">
|
|
70
|
+
<Star aria-hidden="true" size={size} className="star-rating-outline" />
|
|
71
|
+
<span aria-hidden="true" className="star-rating-fill" style={{ width: fill }}>
|
|
72
|
+
<Star size={size} />
|
|
73
|
+
</span>
|
|
74
|
+
{onChange &&
|
|
75
|
+
[star - STEP, star].map((rating, half) => (
|
|
76
|
+
<button
|
|
77
|
+
key={rating}
|
|
78
|
+
type="button"
|
|
79
|
+
tabIndex={-1}
|
|
80
|
+
aria-label={describeStarRating(rating)}
|
|
81
|
+
className={`star-rating-hit ${half === 0 ? "is-left" : "is-right"}`}
|
|
82
|
+
onMouseEnter={() => setHover(rating)}
|
|
83
|
+
onClick={() => {
|
|
84
|
+
// Drop the preview with the pick: the pointer is still on this
|
|
85
|
+
// half, and a preview left up would hide a clear behind the
|
|
86
|
+
// very stars it just emptied.
|
|
87
|
+
setHover(null);
|
|
88
|
+
onChange(rating === value ? null : rating);
|
|
89
|
+
}}
|
|
90
|
+
/>
|
|
91
|
+
))}
|
|
92
|
+
</span>
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
if (!onChange) {
|
|
97
|
+
return (
|
|
98
|
+
<span
|
|
99
|
+
role="img"
|
|
100
|
+
aria-label={`${label}: ${describeStarRating(value)}`}
|
|
101
|
+
className={classes}
|
|
102
|
+
style={style}
|
|
103
|
+
>
|
|
104
|
+
{stars}
|
|
105
|
+
</span>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const set = (next: number | null) => {
|
|
110
|
+
if (next !== value) onChange(next);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
114
|
+
const current = value ?? 0;
|
|
115
|
+
switch (event.key) {
|
|
116
|
+
case "ArrowRight":
|
|
117
|
+
case "ArrowUp":
|
|
118
|
+
set(Math.min(max, current + STEP));
|
|
119
|
+
break;
|
|
120
|
+
case "ArrowLeft":
|
|
121
|
+
case "ArrowDown":
|
|
122
|
+
set(current - STEP > 0 ? current - STEP : null);
|
|
123
|
+
break;
|
|
124
|
+
case "Home":
|
|
125
|
+
set(null);
|
|
126
|
+
break;
|
|
127
|
+
case "End":
|
|
128
|
+
set(max);
|
|
129
|
+
break;
|
|
130
|
+
default:
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
event.preventDefault();
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<span
|
|
138
|
+
role="slider"
|
|
139
|
+
tabIndex={0}
|
|
140
|
+
aria-label={label}
|
|
141
|
+
aria-valuemin={0}
|
|
142
|
+
aria-valuemax={max}
|
|
143
|
+
aria-valuenow={value ?? 0}
|
|
144
|
+
aria-valuetext={describeStarRating(value)}
|
|
145
|
+
className={`${classes} is-editable`}
|
|
146
|
+
style={style}
|
|
147
|
+
onKeyDown={onKeyDown}
|
|
148
|
+
onMouseLeave={() => setHover(null)}
|
|
149
|
+
>
|
|
150
|
+
{stars}
|
|
151
|
+
</span>
|
|
152
|
+
);
|
|
153
|
+
}
|
package/src/components/index.ts
CHANGED
|
@@ -18,6 +18,7 @@ export {
|
|
|
18
18
|
} from "./DataTable";
|
|
19
19
|
export { NotFound, type NotFoundProps } from "./NotFound";
|
|
20
20
|
export { StackedBar, type StackedBarProps, type StackedBarSegment } from "./StackedBar";
|
|
21
|
+
export { describeStarRating, StarRating, type StarRatingProps } from "./StarRating";
|
|
21
22
|
export {
|
|
22
23
|
type StatDelta,
|
|
23
24
|
type StatDirection,
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/* StarRating: a row of stars in half star steps. On its own sheet, and not
|
|
2
|
+
under .mkt, so an app that wears none of the site chrome can import this
|
|
3
|
+
file alone; components.css imports it for every site.
|
|
4
|
+
|
|
5
|
+
The two colors are custom properties with fallbacks off the text color, so
|
|
6
|
+
the row needs no theme token and takes a color from a prop, from a site's
|
|
7
|
+
own rule, or from whatever it sits in. */
|
|
8
|
+
@layer atelic-ui {
|
|
9
|
+
.star-rating {
|
|
10
|
+
display: inline-flex;
|
|
11
|
+
align-items: center;
|
|
12
|
+
vertical-align: middle;
|
|
13
|
+
line-height: 0;
|
|
14
|
+
border-radius: 4px;
|
|
15
|
+
}
|
|
16
|
+
.star-rating.is-editable:focus-visible {
|
|
17
|
+
outline: 2px solid var(--star-focus, currentColor);
|
|
18
|
+
outline-offset: 2px;
|
|
19
|
+
}
|
|
20
|
+
.star-rating-star {
|
|
21
|
+
position: relative;
|
|
22
|
+
display: inline-block;
|
|
23
|
+
width: var(--star-size, 24px);
|
|
24
|
+
height: var(--star-size, 24px);
|
|
25
|
+
}
|
|
26
|
+
/* Pinned to the star's size: a base reset that caps an svg at its
|
|
27
|
+
container's width would shrink the filled star inside its clip to a
|
|
28
|
+
small whole star, where it has to stay full size and be cut in half. */
|
|
29
|
+
.star-rating .star-rating-star svg {
|
|
30
|
+
display: block;
|
|
31
|
+
width: var(--star-size, 24px);
|
|
32
|
+
height: var(--star-size, 24px);
|
|
33
|
+
max-width: none;
|
|
34
|
+
}
|
|
35
|
+
.star-rating-outline {
|
|
36
|
+
color: var(--star-outline, color-mix(in srgb, currentColor 40%, transparent));
|
|
37
|
+
}
|
|
38
|
+
/* The filled star sits over the outline and is clipped to none, half, or
|
|
39
|
+
all of its width. */
|
|
40
|
+
.star-rating-fill {
|
|
41
|
+
position: absolute;
|
|
42
|
+
inset-block: 0;
|
|
43
|
+
left: 0;
|
|
44
|
+
overflow: hidden;
|
|
45
|
+
pointer-events: none;
|
|
46
|
+
color: var(--star-fill, currentColor);
|
|
47
|
+
}
|
|
48
|
+
.star-rating-fill svg {
|
|
49
|
+
fill: currentColor;
|
|
50
|
+
}
|
|
51
|
+
.star-rating-hit {
|
|
52
|
+
position: absolute;
|
|
53
|
+
inset-block: 0;
|
|
54
|
+
width: 50%;
|
|
55
|
+
margin: 0;
|
|
56
|
+
padding: 0;
|
|
57
|
+
border: 0;
|
|
58
|
+
background: none;
|
|
59
|
+
cursor: pointer;
|
|
60
|
+
}
|
|
61
|
+
.star-rating-hit.is-left {
|
|
62
|
+
left: 0;
|
|
63
|
+
}
|
|
64
|
+
.star-rating-hit.is-right {
|
|
65
|
+
right: 0;
|
|
66
|
+
}
|
|
67
|
+
}
|