Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a5d0a7912 | ||
|
|
3e95514053 | ||
|
|
fe124f0d52 | ||
|
|
dd58865323 | ||
|
|
d80012a821 | ||
|
|
5758f198e4 | ||
|
|
048d5dd71a | ||
|
|
c899f07b1f | ||
|
|
998658b42c | ||
|
|
a453a84231 | ||
|
|
732aa92a8c | ||
|
|
02f1dd6491 | ||
|
|
496f2e5375 | ||
|
|
7cc96121e4 | ||
|
|
bba9033ac5 |
+64
-13
@@ -68,6 +68,14 @@ var codePattern = regexp.MustCompile(`^[a-z][a-z0-9_]{0,63}$`)
|
||||
// ErrNotFound is returned by Get for an unknown job id.
|
||||
var ErrNotFound = errors.New("jobs: job not found")
|
||||
|
||||
// ErrUnknownKind is returned by Runner.Enqueue for a kind with no
|
||||
// registered handler.
|
||||
var ErrUnknownKind = errors.New("jobs: unknown job kind")
|
||||
|
||||
// ErrInvalidPayload is returned by Runner.Enqueue for a payload that does
|
||||
// not decode into the kind's payload type.
|
||||
var ErrInvalidPayload = errors.New("jobs: payload does not decode")
|
||||
|
||||
// Failure is a handler error reporting a failure code the household may
|
||||
// see. Code must be one the job's kind declared at registration; the runner
|
||||
// stores it as the job's error. Any other handler error, including a Failure
|
||||
@@ -106,6 +114,13 @@ type Job struct {
|
||||
// never quotes an error.
|
||||
type runFunc func(ctx context.Context, payload string, publish func(json.RawMessage) error) (result json.RawMessage, code, reason string)
|
||||
|
||||
// handler is a registered kind: run runs a job, and check reports whether
|
||||
// a payload decodes into the kind's payload type.
|
||||
type handler struct {
|
||||
run runFunc
|
||||
check func(payload []byte) bool
|
||||
}
|
||||
|
||||
// Runner owns the job handlers and the worker pool.
|
||||
type Runner struct {
|
||||
st *store.Store
|
||||
@@ -118,7 +133,7 @@ type Runner struct {
|
||||
claim func(ctx context.Context, now sql.NullString) (db.Job, error)
|
||||
|
||||
mu sync.Mutex
|
||||
handlers map[string]runFunc
|
||||
handlers map[string]handler
|
||||
started bool
|
||||
|
||||
wg sync.WaitGroup
|
||||
@@ -141,7 +156,7 @@ func NewRunner(st *store.Store, logger *slog.Logger, workers int) (*Runner, erro
|
||||
workers: workers,
|
||||
wake: make(chan struct{}, workers),
|
||||
claim: db.New(st.Writer).ClaimNextJob,
|
||||
handlers: make(map[string]runFunc),
|
||||
handlers: make(map[string]handler),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -179,11 +194,9 @@ func Register[P any, R Public](r *Runner, kind string, h Handler[P, R], failureC
|
||||
if _, ok := r.handlers[kind]; ok {
|
||||
return nil, fmt.Errorf("jobs: register %q: kind already registered", kind)
|
||||
}
|
||||
r.handlers[kind] = func(ctx context.Context, payload string, publish func(json.RawMessage) error) (json.RawMessage, string, string) {
|
||||
var p P
|
||||
dec := json.NewDecoder(bytes.NewReader([]byte(payload)))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&p); err != nil {
|
||||
run := func(ctx context.Context, payload string, publish func(json.RawMessage) error) (json.RawMessage, string, string) {
|
||||
p, err := decodePayload[P]([]byte(payload))
|
||||
if err != nil {
|
||||
return nil, CodeInternal, "payload does not decode"
|
||||
}
|
||||
result, err := h(ctx, p, func(partial R) error {
|
||||
@@ -209,9 +222,23 @@ func Register[P any, R Public](r *Runner, kind string, h Handler[P, R], failureC
|
||||
}
|
||||
return data, "", ""
|
||||
}
|
||||
check := func(payload []byte) bool {
|
||||
_, err := decodePayload[P](payload)
|
||||
return err == nil
|
||||
}
|
||||
r.handlers[kind] = handler{run: run, check: check}
|
||||
return &Kind[P]{r: r, name: kind}, nil
|
||||
}
|
||||
|
||||
// decodePayload decodes payload into P, refusing unknown fields.
|
||||
func decodePayload[P any](payload []byte) (P, error) {
|
||||
var p P
|
||||
dec := json.NewDecoder(bytes.NewReader(payload))
|
||||
dec.DisallowUnknownFields()
|
||||
err := dec.Decode(&p)
|
||||
return p, err
|
||||
}
|
||||
|
||||
// publicJSON returns v's PublicJSON, refusing anything that is not one JSON
|
||||
// value. Its error never quotes PublicJSON's error.
|
||||
func publicJSON(v Public) (json.RawMessage, error) {
|
||||
@@ -229,19 +256,43 @@ func (k *Kind[P]) Enqueue(ctx context.Context, payload P) (string, error) {
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("jobs: enqueue %s: encode payload: %w", k.name, err)
|
||||
}
|
||||
return k.r.enqueue(ctx, k.name, data)
|
||||
}
|
||||
|
||||
// Enqueue stores a queued job of kind with its JSON payload and returns its
|
||||
// id, as Kind.Enqueue does for a kind known at compile time. It returns
|
||||
// ErrUnknownKind for a kind with no registered handler and
|
||||
// ErrInvalidPayload for a payload that does not decode into the kind's
|
||||
// payload type; neither stores a job.
|
||||
func (r *Runner) Enqueue(ctx context.Context, kind string, payload json.RawMessage) (string, error) {
|
||||
r.mu.Lock()
|
||||
h, ok := r.handlers[kind]
|
||||
r.mu.Unlock()
|
||||
if !ok {
|
||||
return "", ErrUnknownKind
|
||||
}
|
||||
if !h.check(payload) {
|
||||
return "", ErrInvalidPayload
|
||||
}
|
||||
return r.enqueue(ctx, kind, payload)
|
||||
}
|
||||
|
||||
// enqueue stores a queued job of kind with its encoded payload and wakes a
|
||||
// worker.
|
||||
func (r *Runner) enqueue(ctx context.Context, kind string, payload []byte) (string, error) {
|
||||
id := store.NewID()
|
||||
now := store.Now().String()
|
||||
if err := db.New(k.r.st.Writer).CreateJob(ctx, db.CreateJobParams{
|
||||
if err := db.New(r.st.Writer).CreateJob(ctx, db.CreateJobParams{
|
||||
ID: id,
|
||||
Kind: k.name,
|
||||
Payload: string(data),
|
||||
Kind: kind,
|
||||
Payload: string(payload),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}); err != nil {
|
||||
return "", fmt.Errorf("jobs: enqueue %s: %w", k.name, err)
|
||||
return "", fmt.Errorf("jobs: enqueue %s: %w", kind, err)
|
||||
}
|
||||
select {
|
||||
case k.r.wake <- struct{}{}:
|
||||
case r.wake <- struct{}{}:
|
||||
default: // every worker already has a token and will look again
|
||||
}
|
||||
return id, nil
|
||||
@@ -365,7 +416,7 @@ func (r *Runner) run(ctx context.Context, job db.Job) {
|
||||
return nil
|
||||
}
|
||||
|
||||
result, code, reason := h(ctx, job.Payload, publish)
|
||||
result, code, reason := h.run(ctx, job.Payload, publish)
|
||||
if code != "" {
|
||||
if ctx.Err() != nil {
|
||||
logger.WarnContext(write, "job interrupted by shutdown; it fails at the next start")
|
||||
|
||||
@@ -568,3 +568,39 @@ func TestClaimErrorIsRetried(t *testing.T) {
|
||||
t.Fatalf("claim error text logged: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunnerEnqueueByKindName(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := migratedStore(t)
|
||||
r := newRunner(t, st, 1)
|
||||
register(t, r, "count", func(_ context.Context, p countPayload, _ func(countResult) error) (countResult, error) {
|
||||
return countResult{Done: p.Steps}, nil
|
||||
})
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := r.Enqueue(ctx, "no_such_kind", json.RawMessage(`{}`)); !errors.Is(err, ErrUnknownKind) {
|
||||
t.Errorf("unknown kind error = %v, want ErrUnknownKind", err)
|
||||
}
|
||||
for _, payload := range []string{`{"steps":"three"}`, `{"other":1}`, `[]`, ``} {
|
||||
if _, err := r.Enqueue(ctx, "count", json.RawMessage(payload)); !errors.Is(err, ErrInvalidPayload) {
|
||||
t.Errorf("payload %q error = %v, want ErrInvalidPayload", payload, err)
|
||||
}
|
||||
}
|
||||
var n int
|
||||
if err := st.Reader.QueryRow("SELECT COUNT(*) FROM jobs").Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Errorf("refused enqueues stored %d jobs, want 0", n)
|
||||
}
|
||||
|
||||
id, err := r.Enqueue(ctx, "count", json.RawMessage(`{"steps":3}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
start(t, r)
|
||||
job := waitFor(t, r, id, finished)
|
||||
if job.Kind != "count" || job.Status != StatusSucceeded || string(job.Result) != `{"done":3}` {
|
||||
t.Errorf("job = %+v (result %s), want succeeded with {\"done\":3}", job, job.Result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
@@ -10,6 +14,80 @@ import (
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
)
|
||||
|
||||
// maxJobBody bounds a POST /jobs request body.
|
||||
const maxJobBody = 64 << 10
|
||||
|
||||
// jobRequest is the POST /jobs body. Payload is required; a job kind that
|
||||
// takes no input is sent the empty object {}.
|
||||
type jobRequest struct {
|
||||
Kind string `json:"kind"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
}
|
||||
|
||||
// jobCreated is the POST /jobs response.
|
||||
type jobCreated struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
// postJob serves POST /api/v1/jobs: it enqueues a job of a registered kind
|
||||
// and returns 202 with its id, which GET /api/v1/jobs/{id} reports on.
|
||||
func postJob(logger *slog.Logger, runner *jobs.Runner) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
req, err := decodeJobRequest(http.MaxBytesReader(w, r.Body, maxJobBody))
|
||||
if err != nil {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
id, err := runner.Enqueue(r.Context(), req.Kind, req.Payload)
|
||||
switch {
|
||||
case errors.Is(err, jobs.ErrUnknownKind):
|
||||
writeError(w, r, logger, http.StatusBadRequest, "unknown_kind", "unknown job kind")
|
||||
return
|
||||
case errors.Is(err, jobs.ErrInvalidPayload):
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_payload", "payload does not match the job kind")
|
||||
return
|
||||
case err != nil:
|
||||
logger.ErrorContext(r.Context(), "enqueue job", "kind", req.Kind, "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusAccepted, jobCreated{ID: id})
|
||||
}
|
||||
}
|
||||
|
||||
// decodeJobRequest reads a single JSON object with a non-empty kind and a
|
||||
// payload, refusing unknown fields, an omitted or null payload and trailing
|
||||
// data.
|
||||
func decodeJobRequest(body io.Reader) (jobRequest, error) {
|
||||
data, err := io.ReadAll(body)
|
||||
if err != nil {
|
||||
var tooLarge *http.MaxBytesError
|
||||
if errors.As(err, &tooLarge) {
|
||||
return jobRequest{}, fmt.Errorf("request body exceeds %d bytes", tooLarge.Limit)
|
||||
}
|
||||
return jobRequest{}, errors.New("cannot read request body")
|
||||
}
|
||||
var req jobRequest
|
||||
dec := json.NewDecoder(bytes.NewReader(data))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&req); err != nil {
|
||||
return jobRequest{}, errors.New("request body must be a JSON object with kind and payload")
|
||||
}
|
||||
if _, err := dec.Token(); err != io.EOF {
|
||||
return jobRequest{}, errors.New("request body must be a single JSON object")
|
||||
}
|
||||
if req.Kind == "" {
|
||||
return jobRequest{}, errors.New("kind: required")
|
||||
}
|
||||
if req.Payload == nil {
|
||||
return jobRequest{}, errors.New("payload: required")
|
||||
}
|
||||
if bytes.Equal(req.Payload, []byte("null")) {
|
||||
return jobRequest{}, errors.New("payload: must not be null")
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// getJob serves GET /api/v1/jobs/{id}: the job's id, kind, status, latest
|
||||
// result and error.
|
||||
func getJob(logger *slog.Logger, runner *jobs.Runner) http.HandlerFunc {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -151,3 +152,95 @@ func TestGetJobRequiresSession(t *testing.T) {
|
||||
t.Errorf("status = %d, want 401", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPostJobThenGetProgressAndResult drives the whole flow over the
|
||||
// router: POST enqueues a progressive "draft" job, GET reads its partial
|
||||
// result while the handler is held mid-run, then its final result.
|
||||
func TestPostJobThenGetProgressAndResult(t *testing.T) {
|
||||
t.Parallel()
|
||||
release := make(chan struct{})
|
||||
runner, _ := startedRunner(t, release, nil)
|
||||
router := newTestRouterWith(t, "dev", newTestSettings(t), runner)
|
||||
|
||||
rec, body := serve(t, router, signedIn(http.MethodPost, "/api/v1/jobs", strings.NewReader(`{"kind":"draft","payload":{}}`)))
|
||||
if rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("status = %d, want 202: %s", rec.Code, rec.Body)
|
||||
}
|
||||
id, _ := body["id"].(string)
|
||||
if keys := slices.Sorted(maps.Keys(body)); !slices.Equal(keys, []string{"id"}) || id == "" {
|
||||
t.Fatalf("body = %v, want only a job id", body)
|
||||
}
|
||||
|
||||
mid := pollJob(t, router, id, func(b map[string]any) bool { return resultDays(b) == 1 })
|
||||
if mid["id"] != id || mid["kind"] != "draft" || mid["status"] != "running" || mid["error"] != nil {
|
||||
t.Errorf("mid-run body = %v, want running draft job %s with no error", mid, id)
|
||||
}
|
||||
|
||||
close(release)
|
||||
done := pollJob(t, router, id, func(b map[string]any) bool { return b["status"] == "succeeded" })
|
||||
if done["id"] != id || resultDays(done) != 7 || done["error"] != nil {
|
||||
t.Errorf("finished body = %v, want result {\"days\":7} and no error", done)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostJobRefusesBadRequests(t *testing.T) {
|
||||
t.Parallel()
|
||||
runner, _ := startedRunner(t, nil, nil)
|
||||
router := newTestRouterWith(t, "dev", newTestSettings(t), runner)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name, body, code, message string
|
||||
}{
|
||||
{"unknown kind", `{"kind":"no_such_kind","payload":{}}`, "unknown_kind", "unknown job kind"},
|
||||
{"payload for another type", `{"kind":"draft","payload":{"days":3}}`, "invalid_payload", "payload does not match the job kind"},
|
||||
{"payload not an object", `{"kind":"draft","payload":[1]}`, "invalid_payload", "payload does not match the job kind"},
|
||||
{"missing payload", `{"kind":"draft"}`, "invalid_request", "payload: required"},
|
||||
{"null payload", `{"kind":"draft","payload":null}`, "invalid_request", "payload: must not be null"},
|
||||
{"missing kind", `{"payload":{}}`, "invalid_request", "kind: required"},
|
||||
{"empty body", ``, "invalid_request", "request body must be a JSON object with kind and payload"},
|
||||
{"not an object", `["draft"]`, "invalid_request", "request body must be a JSON object with kind and payload"},
|
||||
{"unknown field", `{"kind":"draft","payload":{},"extra":1}`, "invalid_request", "request body must be a JSON object with kind and payload"},
|
||||
{"trailing data", `{"kind":"draft","payload":{}} {}`, "invalid_request", "request body must be a single JSON object"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, signedIn(http.MethodPost, "/api/v1/jobs", strings.NewReader(tc.body)))
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400: %s", rec.Code, rec.Body)
|
||||
}
|
||||
var body errorBody
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := (errorBody{Error: apiError{Code: tc.code, Message: tc.message}}); body != want {
|
||||
t.Errorf("body = %+v, want %+v", body, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostJobNeedsSessionAndCSRF(t *testing.T) {
|
||||
t.Parallel()
|
||||
router := newTestRouter(t, "dev")
|
||||
const body = `{"kind":"noop","payload":{}}`
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/v1/jobs", strings.NewReader(body)))
|
||||
if rec.Code != http.StatusUnauthorized || rec.Body.String() != unauthenticatedJSON {
|
||||
t.Errorf("no session: %d %s, want 401 %s", rec.Code, rec.Body, unauthenticatedJSON)
|
||||
}
|
||||
|
||||
for _, header := range []string{"", "wrong-token"} {
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/jobs", strings.NewReader(body))
|
||||
req.AddCookie(validSessionCookie)
|
||||
if header != "" {
|
||||
req.Header.Set(CSRFHeader, header)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden || rec.Body.String() != csrfJSON {
|
||||
t.Errorf("CSRF header %q: %d %s, want 403 %s", header, rec.Code, rec.Body, csrfJSON)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ type RouteMounter interface {
|
||||
// only from a peer in trusted (see resolveOrigin). version is reported by
|
||||
// GET /health; auth mounts the /auth routes; sessions guards /api/v1 and
|
||||
// /media; settings serves /api/v1/settings/household; runner serves
|
||||
// /api/v1/jobs/{id}; assets is the web build
|
||||
// POST /api/v1/jobs and GET /api/v1/jobs/{id}; assets is the web build
|
||||
// served for every other GET. /health, /auth and the web build are public.
|
||||
// Requests to /api/v1 other than GET also need the session's CSRF token in CSRFHeader.
|
||||
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, runner *jobs.Runner, assets fs.FS) (http.Handler, error) {
|
||||
@@ -68,6 +68,7 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
||||
apiV1.Get("/me", me(logger))
|
||||
apiV1.Get("/settings/household", getHouseholdSettings(logger, settings))
|
||||
apiV1.Patch("/settings/household", patchHouseholdSettings(logger, settings))
|
||||
apiV1.Post("/jobs", postJob(logger, runner))
|
||||
apiV1.Get("/jobs/{id}", getJob(logger, runner))
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
r.MethodNotAllowed(methodNotAllowed)
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#5a358c" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<title>MealQuest</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "MealQuest",
|
||||
"short_name": "MealQuest",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"theme_color": "#5a358c",
|
||||
"background_color": "#f9f6f1",
|
||||
"icons": [
|
||||
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||
{
|
||||
"src": "/icons/icon-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+5245
-2
File diff suppressed because it is too large
Load Diff
+7
-3
@@ -5,13 +5,16 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"build": "npm run lint:colors && npm run typecheck && vite build && npm run check:dist-fonts",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.sw.json",
|
||||
"test": "npm run lint:colors && vitest run",
|
||||
"lint:colors": "node scripts/lint-colors.mjs",
|
||||
"check:dist-fonts": "node scripts/check-dist-fonts.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.104.1",
|
||||
"lucide-react": "^1.51.0",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0",
|
||||
"react-router": "^7.18.4"
|
||||
@@ -26,6 +29,7 @@
|
||||
"jsdom": "^30.1.1",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.3.2",
|
||||
"vite-plugin-pwa": "^2.0.0",
|
||||
"vitest": "^5.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 505 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 218 KiB |
@@ -0,0 +1,32 @@
|
||||
// Fails when the production build references Google Fonts: fonts are
|
||||
// self-hosted, so dist must never point at fonts.googleapis.com or
|
||||
// fonts.gstatic.com.
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import { join, relative } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const distDir = fileURLToPath(new URL('../dist', import.meta.url))
|
||||
const hosts = ['fonts.googleapis.com', 'fonts.gstatic.com']
|
||||
|
||||
function* files(dir) {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const path = join(dir, entry.name)
|
||||
if (entry.isDirectory()) yield* files(path)
|
||||
else yield path
|
||||
}
|
||||
}
|
||||
|
||||
const violations = []
|
||||
for (const path of files(distDir)) {
|
||||
const content = readFileSync(path, 'latin1')
|
||||
for (const host of hosts) {
|
||||
if (content.includes(host)) violations.push(`${relative(distDir, path)}: ${host}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (violations.length > 0) {
|
||||
console.error('Production build references Google Fonts; fonts must be self-hosted:')
|
||||
for (const v of violations) console.error(` ${v}`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('check:dist-fonts: dist makes no Google Fonts requests')
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Generates the PWA icons in web/public from the MealQuest logo.
|
||||
|
||||
Run from the repository root with Pillow installed:
|
||||
|
||||
python3 web/scripts/generate-icons.py
|
||||
|
||||
The logo fills the regular icons edge to edge. The maskable icon shrinks it
|
||||
onto the colour of its own parchment, so the banner stays inside
|
||||
the circular safe zone (radius 40% of the icon) that launchers may crop to.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFilter, ImageStat
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SOURCE = ROOT / "design" / "logos" / "logo_v1_2Oct2026.jpg"
|
||||
PUBLIC = ROOT / "web" / "public"
|
||||
|
||||
MASKABLE_SCALE = 0.66
|
||||
|
||||
|
||||
def resized(logo: Image.Image, size: int) -> Image.Image:
|
||||
return logo.resize((size, size), Image.Resampling.LANCZOS)
|
||||
|
||||
|
||||
def maskable(logo: Image.Image, size: int) -> Image.Image:
|
||||
# The padding is the median colour of the logo's top edge, which is bare
|
||||
# parchment; a feathered edge hides the seam.
|
||||
top_edge = ImageStat.Stat(logo.crop((0, 0, logo.width, logo.height // 40)))
|
||||
canvas = Image.new("RGB", (size, size), tuple(round(c) for c in top_edge.median))
|
||||
inner = round(size * MASKABLE_SCALE)
|
||||
offset = (size - inner) // 2
|
||||
feather = Image.new("L", (inner, inner), 0)
|
||||
margin = inner // 24
|
||||
ImageDraw.Draw(feather).rectangle((margin, margin, inner - margin, inner - margin), fill=255)
|
||||
feather = feather.filter(ImageFilter.GaussianBlur(margin / 2))
|
||||
canvas.paste(resized(logo, inner), (offset, offset), feather)
|
||||
return canvas
|
||||
|
||||
|
||||
def main() -> None:
|
||||
logo = Image.open(SOURCE).convert("RGB")
|
||||
if logo.width != logo.height:
|
||||
raise SystemExit(f"{SOURCE} is {logo.width}x{logo.height}; the logo must be square")
|
||||
outputs = {
|
||||
PUBLIC / "icons" / "icon-192.png": resized(logo, 192),
|
||||
PUBLIC / "icons" / "icon-512.png": resized(logo, 512),
|
||||
PUBLIC / "icons" / "icon-maskable-512.png": maskable(logo, 512),
|
||||
PUBLIC / "apple-touch-icon.png": resized(logo, 180),
|
||||
}
|
||||
for path, image in outputs.items():
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
image.save(path, optimize=True)
|
||||
print(f"wrote {path.relative_to(ROOT)} ({image.width}x{image.height})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,42 @@
|
||||
// Fails when a hex colour literal appears in any CSS or TypeScript file under
|
||||
// web/ outside the design tokens: every colour must come from a custom
|
||||
// property in tokens.css. Dependencies, build output and hidden directories
|
||||
// (tool caches) are not source and are skipped.
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import { extname, join, relative } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const webRoot = fileURLToPath(new URL('..', import.meta.url))
|
||||
const allowed = new Set(['src/styles/tokens.css'])
|
||||
const skippedDirs = new Set([join(webRoot, 'node_modules'), join(webRoot, 'dist')])
|
||||
const extensions = new Set(['.css', '.ts', '.tsx'])
|
||||
const hexColour = /#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3,4})\b/g
|
||||
|
||||
function* sourceFiles(dir) {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const path = join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
if (!skippedDirs.has(path) && !entry.name.startsWith('.')) yield* sourceFiles(path)
|
||||
} else if (extensions.has(extname(entry.name))) yield path
|
||||
}
|
||||
}
|
||||
|
||||
const violations = []
|
||||
for (const path of sourceFiles(webRoot)) {
|
||||
const name = relative(webRoot, path).split('\\').join('/')
|
||||
if (allowed.has(name)) continue
|
||||
readFileSync(path, 'utf8')
|
||||
.split('\n')
|
||||
.forEach((line, i) => {
|
||||
for (const match of line.matchAll(hexColour)) {
|
||||
violations.push(`${name}:${i + 1}: ${match[0]}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (violations.length > 0) {
|
||||
console.error('Hex colour literals found; use a token from src/styles/tokens.css:')
|
||||
for (const v of violations) console.error(` ${v}`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('lint:colors: no hex colour literals outside src/styles/tokens.css')
|
||||
+96
-10
@@ -1,15 +1,101 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import App from './App.tsx'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createApiClient } from './api/index.ts'
|
||||
import { mockViewport } from './test/matchMedia.ts'
|
||||
import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch } from './test/fetchStub.ts'
|
||||
import App, { routes } from './App.tsx'
|
||||
|
||||
describe('App', () => {
|
||||
it('renders the MealQuest shell', () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<App />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
expect(screen.getByRole('heading', { name: 'MealQuest' })).toBeInTheDocument()
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(window, 'matchMedia')
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
function renderAt(path: string, location = fakeLocation(path)) {
|
||||
render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<App api={createApiClient(location)} />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
return location
|
||||
}
|
||||
|
||||
describe('App routes', () => {
|
||||
it('registers every handoff route plus the extra settings pages', () => {
|
||||
expect(routes.map((route) => route.path)).toEqual([
|
||||
'/',
|
||||
'/onboarding',
|
||||
'/plan',
|
||||
'/plan/shopping',
|
||||
'/plan/shopping/archive',
|
||||
'/discover',
|
||||
'/quick',
|
||||
'/cook-from-pantry',
|
||||
'/dinner/:id',
|
||||
'/settings',
|
||||
'/settings/notifications',
|
||||
'/settings/ai',
|
||||
'/settings/household',
|
||||
'/settings/regulars',
|
||||
'/settings/calendar',
|
||||
])
|
||||
})
|
||||
|
||||
it.each(routes.map(({ path, title }) => [path.replace(':id', 'abc123'), title]))(
|
||||
'%s renders its placeholder page inside the shell',
|
||||
async (path, title) => {
|
||||
mockViewport(390)
|
||||
stubFetch(jsonResponse(200, ME))
|
||||
renderAt(path)
|
||||
const heading = await screen.findByRole('heading', { level: 1, name: title })
|
||||
expect(screen.getByRole('main')).toContainElement(heading)
|
||||
expect(screen.getByRole('navigation', { name: 'Primary' })).toBeInTheDocument()
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('App bootstrap', () => {
|
||||
it('shows the skeleton until /me loads, then the routes', async () => {
|
||||
mockViewport(390)
|
||||
const fetchMock = stubFetch(jsonResponse(200, ME))
|
||||
renderAt('/plan')
|
||||
|
||||
expect(screen.getByRole('main', { name: 'Loading MealQuest' })).toHaveAttribute('aria-busy', 'true')
|
||||
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
||||
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Plan' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('main', { name: 'Loading MealQuest' })).not.toBeInTheDocument()
|
||||
expect(request(fetchMock, 0)).toMatchObject({ path: '/api/v1/me', method: 'GET', credentials: 'same-origin' })
|
||||
})
|
||||
|
||||
it('sends a signed-out visitor to sign-in and never renders the routes', async () => {
|
||||
const fetchMock = stubFetch(errorResponse(401, 'unauthenticated'))
|
||||
const location = renderAt('/discover', fakeLocation('/discover', '?swipe=1'))
|
||||
|
||||
await vi.waitFor(() => expect(location.assign).toHaveBeenCalledWith('/auth/login?return=%2Fdiscover%3Fswipe%3D1'))
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
expect(screen.getByRole('main', { name: 'Loading MealQuest' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the not-in-this-household screen on a 403', async () => {
|
||||
const fetchMock = stubFetch(errorResponse(403, 'forbidden'))
|
||||
const location = renderAt('/')
|
||||
|
||||
expect(await screen.findByText(/not in this household/)).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { name: 'Not in this household' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument()
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
expect(location.assign).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a /me failure with a retry instead of rendering the routes', async () => {
|
||||
mockViewport(390)
|
||||
stubFetch(errorResponse(500, 'internal'), jsonResponse(200, ME))
|
||||
renderAt('/')
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: internal message')
|
||||
screen.getByRole('button', { name: 'Retry' }).click()
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
+46
-13
@@ -1,25 +1,58 @@
|
||||
import { useState } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { Route, Routes } from 'react-router'
|
||||
import { ApiProvider, createQueryClient, type ApiClient } from './api/index.ts'
|
||||
import { AuthBootstrap } from './AuthBootstrap.tsx'
|
||||
import { PlaceholderPage } from './pages/PlaceholderPage.tsx'
|
||||
import { AppShell } from './shell/AppShell.tsx'
|
||||
|
||||
// Placeholder shell only: no product UI, auth or API calls yet.
|
||||
function Shell() {
|
||||
// Every route the app serves, with the heading its placeholder page shows.
|
||||
export const routes = [
|
||||
{ path: '/', title: 'Home' },
|
||||
{ path: '/onboarding', title: 'Onboarding' },
|
||||
{ path: '/plan', title: 'Plan' },
|
||||
{ path: '/plan/shopping', title: 'Supply run' },
|
||||
{ path: '/plan/shopping/archive', title: 'Supply archive' },
|
||||
{ path: '/discover', title: 'Discover' },
|
||||
{ path: '/quick', title: 'Quick dinners' },
|
||||
{ path: '/cook-from-pantry', title: 'Cook from what I have' },
|
||||
{ path: '/dinner/:id', title: 'Dinner' },
|
||||
{ path: '/settings', title: 'Settings' },
|
||||
{ path: '/settings/notifications', title: 'Notifications' },
|
||||
{ path: '/settings/ai', title: 'AI assistant' },
|
||||
{ path: '/settings/household', title: 'Household' },
|
||||
{ path: '/settings/regulars', title: 'Regulars' },
|
||||
{ path: '/settings/calendar', title: 'Calendar' },
|
||||
] as const
|
||||
|
||||
// AppRoutes is the shell and its pages, shown once /api/v1/me has loaded.
|
||||
export function AppRoutes() {
|
||||
return (
|
||||
<main>
|
||||
<h1>MealQuest</h1>
|
||||
</main>
|
||||
<Routes>
|
||||
<Route element={<AppShell />}>
|
||||
{routes.map(({ path, title }) => (
|
||||
<Route key={path} path={path} element={<PlaceholderPage title={title} />} />
|
||||
))}
|
||||
</Route>
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
export type AppProps = {
|
||||
api: ApiClient
|
||||
}
|
||||
|
||||
// App expects to be rendered inside a router (BrowserRouter in main.tsx,
|
||||
// MemoryRouter in tests).
|
||||
export default function App() {
|
||||
const [queryClient] = useState(() => new QueryClient())
|
||||
export default function App({ api }: AppProps) {
|
||||
const [queryClient] = useState(createQueryClient)
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Routes>
|
||||
<Route path="*" element={<Shell />} />
|
||||
</Routes>
|
||||
</QueryClientProvider>
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthBootstrap>
|
||||
<AppRoutes />
|
||||
</AuthBootstrap>
|
||||
</QueryClientProvider>
|
||||
</ApiProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useSyncExternalStore, type ReactNode } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { ApiError, ME_PATH, useApi } from './api/index.ts'
|
||||
import { NotInHouseholdPage } from './pages/NotInHouseholdPage.tsx'
|
||||
import { ErrorBanner } from './ui/ErrorBanner.tsx'
|
||||
import { Skeleton } from './ui/Skeleton.tsx'
|
||||
import styles from './pages/FullScreen.module.css'
|
||||
|
||||
export const meQueryKey = [ME_PATH] as const
|
||||
|
||||
// AuthBootstrap loads GET /api/v1/me before rendering children. A 401 is
|
||||
// already navigating to sign-in, so the skeleton stays up; an account outside
|
||||
// the household sees only the "not in this household" screen.
|
||||
export function AuthBootstrap({ children }: { children: ReactNode }) {
|
||||
const api = useApi()
|
||||
const forbidden = useSyncExternalStore(api.subscribe, api.forbidden)
|
||||
const me = useQuery({ queryKey: meQueryKey, queryFn: api.loadMe, staleTime: Infinity })
|
||||
|
||||
if (forbidden) return <NotInHouseholdPage />
|
||||
if (me.isSuccess) return children
|
||||
if (me.isError && !(me.error instanceof ApiError && me.error.status === 401)) {
|
||||
return (
|
||||
<main className={styles.screen}>
|
||||
<ErrorBanner onRetry={() => void me.refetch()}>MealQuest could not load: {me.error.message}</ErrorBanner>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<main className={styles.screen} aria-busy="true" aria-label="Loading MealQuest">
|
||||
<div className={styles.loading}>
|
||||
<Skeleton className={styles.title} />
|
||||
<Skeleton />
|
||||
<Skeleton />
|
||||
<Skeleton />
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createContext, useContext, type ReactNode } from 'react'
|
||||
import type { ApiClient } from './client.ts'
|
||||
|
||||
const ApiContext = createContext<ApiClient | null>(null)
|
||||
|
||||
export function ApiProvider({ client, children }: { client: ApiClient; children: ReactNode }) {
|
||||
return <ApiContext.Provider value={client}>{children}</ApiContext.Provider>
|
||||
}
|
||||
|
||||
// useApi returns the app's API client. It must be rendered inside ApiProvider.
|
||||
export function useApi(): ApiClient {
|
||||
const client = useContext(ApiContext)
|
||||
if (client === null) throw new Error('useApi: no ApiProvider above this component')
|
||||
return client
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { errorResponse, fakeLocation, jsonResponse, ME, request, stubFetch } from '../test/fetchStub.ts'
|
||||
import { ApiError, createApiClient, CSRF_HEADER } from './index.ts'
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('apiFetch', () => {
|
||||
it('sends same-origin JSON and the cached CSRF token on non-GET requests only', async () => {
|
||||
const fetchMock = stubFetch(jsonResponse(200, ME), jsonResponse(200, { ok: 1 }), jsonResponse(200, { ok: 2 }))
|
||||
const api = createApiClient(fakeLocation())
|
||||
await api.loadMe()
|
||||
|
||||
await expect(api.apiFetch('/api/v1/settings/household')).resolves.toEqual({ ok: 1 })
|
||||
await expect(
|
||||
api.apiFetch('/api/v1/settings/household', { method: 'PATCH', body: { dinnerTime: '18:00' } }),
|
||||
).resolves.toEqual({ ok: 2 })
|
||||
|
||||
const get = request(fetchMock, 1)
|
||||
expect(get).toMatchObject({ path: '/api/v1/settings/household', method: 'GET', credentials: 'same-origin' })
|
||||
expect(get.headers.has(CSRF_HEADER)).toBe(false)
|
||||
|
||||
const patch = request(fetchMock, 2)
|
||||
expect(patch).toMatchObject({ method: 'PATCH', credentials: 'same-origin', body: '{"dinnerTime":"18:00"}' })
|
||||
expect(patch.headers.get(CSRF_HEADER)).toBe('token-1')
|
||||
expect(patch.headers.get('Content-Type')).toBe('application/json')
|
||||
})
|
||||
|
||||
it('refuses a non-GET request before /me has loaded', async () => {
|
||||
const fetchMock = stubFetch()
|
||||
const api = createApiClient(fakeLocation())
|
||||
await expect(api.apiFetch('/api/v1/jobs', { method: 'POST', body: {} })).rejects.toThrow(/no CSRF token/)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
'https://evil.example/api/v1/me',
|
||||
'//evil.example/api/v1/me',
|
||||
// The placeholder origin an earlier guard compared against.
|
||||
'//same-origin.invalid/api/v1/me',
|
||||
'https://same-origin.invalid/api/v1/me',
|
||||
'/\\evil.example/api/v1/me',
|
||||
'/auth/logout',
|
||||
'/api/v1/../auth/logout',
|
||||
'/api/v1/%2e%2e/auth/logout',
|
||||
'api/v1/me',
|
||||
])('refuses %s without sending it or the CSRF token', async (path) => {
|
||||
const fetchMock = stubFetch(jsonResponse(200, ME))
|
||||
const api = createApiClient(fakeLocation())
|
||||
await api.loadMe()
|
||||
|
||||
await expect(api.apiFetch(path, { method: 'POST', body: {} })).rejects.toThrow(
|
||||
`POST ${path}: not a same-origin /api/v1/ path`,
|
||||
)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
expect(request(fetchMock, 0)).toMatchObject({ path: '/api/v1/me', method: 'GET' })
|
||||
expect(request(fetchMock, 0).headers.has(CSRF_HEADER)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects with a typed ApiError from the error body', async () => {
|
||||
stubFetch(errorResponse(503, 'unavailable', true))
|
||||
const api = createApiClient(fakeLocation())
|
||||
const error = await api.apiFetch('/api/v1/jobs/j1').catch((e: unknown) => e)
|
||||
expect(error).toBeInstanceOf(ApiError)
|
||||
expect(error).toMatchObject({ status: 503, code: 'unavailable', message: 'unavailable message', retryable: true })
|
||||
})
|
||||
|
||||
it('rejects a response without the API error body', async () => {
|
||||
stubFetch(new Response('<html>bad gateway</html>', { status: 502 }))
|
||||
const api = createApiClient(fakeLocation())
|
||||
await expect(api.apiFetch('/api/v1/jobs/j1')).rejects.toThrow('GET /api/v1/jobs/j1: 502 response is not JSON')
|
||||
})
|
||||
|
||||
it('on 401 navigates to sign-in with the current path and search to return to', async () => {
|
||||
stubFetch(errorResponse(401, 'unauthenticated'))
|
||||
const location = fakeLocation('/plan/shopping', '?week=2026-10-05')
|
||||
const api = createApiClient(location)
|
||||
|
||||
await expect(api.apiFetch('/api/v1/settings/household')).rejects.toMatchObject({ status: 401 })
|
||||
expect(location.assign).toHaveBeenCalledExactlyOnceWith(
|
||||
'/auth/login?return=%2Fplan%2Fshopping%3Fweek%3D2026-10-05',
|
||||
)
|
||||
})
|
||||
|
||||
it('on a csrf 403 refreshes /me once and retries with the new token', async () => {
|
||||
const fetchMock = stubFetch(
|
||||
jsonResponse(200, ME),
|
||||
errorResponse(403, 'csrf'),
|
||||
jsonResponse(200, { ...ME, csrfToken: 'token-2' }),
|
||||
jsonResponse(201, { id: 'j1' }),
|
||||
)
|
||||
const api = createApiClient(fakeLocation())
|
||||
await api.loadMe()
|
||||
|
||||
await expect(api.apiFetch('/api/v1/jobs', { method: 'POST', body: { kind: 'noop' } })).resolves.toEqual({
|
||||
id: 'j1',
|
||||
})
|
||||
expect(fetchMock).toHaveBeenCalledTimes(4)
|
||||
expect(request(fetchMock, 1).headers.get(CSRF_HEADER)).toBe('token-1')
|
||||
expect(request(fetchMock, 2)).toMatchObject({ path: '/api/v1/me', method: 'GET' })
|
||||
expect(request(fetchMock, 3)).toMatchObject({ path: '/api/v1/jobs', method: 'POST', body: '{"kind":"noop"}' })
|
||||
expect(request(fetchMock, 3).headers.get(CSRF_HEADER)).toBe('token-2')
|
||||
expect(api.forbidden()).toBe(false)
|
||||
})
|
||||
|
||||
it('does not loop when the retry gets a csrf 403 too', async () => {
|
||||
const fetchMock = stubFetch(
|
||||
jsonResponse(200, ME),
|
||||
errorResponse(403, 'csrf'),
|
||||
jsonResponse(200, { ...ME, csrfToken: 'token-2' }),
|
||||
errorResponse(403, 'csrf'),
|
||||
)
|
||||
const api = createApiClient(fakeLocation())
|
||||
await api.loadMe()
|
||||
|
||||
await expect(api.apiFetch('/api/v1/jobs', { method: 'POST', body: {} })).rejects.toMatchObject({
|
||||
status: 403,
|
||||
code: 'csrf',
|
||||
})
|
||||
expect(fetchMock).toHaveBeenCalledTimes(4)
|
||||
expect(api.forbidden()).toBe(false)
|
||||
})
|
||||
|
||||
it('marks any other 403 as not in this household and notifies subscribers', async () => {
|
||||
const fetchMock = stubFetch(errorResponse(403, 'forbidden'))
|
||||
const api = createApiClient(fakeLocation())
|
||||
const listener = vi.fn()
|
||||
api.subscribe(listener)
|
||||
|
||||
await expect(api.apiFetch('/api/v1/me')).rejects.toMatchObject({ status: 403, code: 'forbidden' })
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
expect(api.forbidden()).toBe(true)
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not take a 403 without the API error body as not in this household', async () => {
|
||||
stubFetch(new Response('<html>denied by proxy</html>', { status: 403 }))
|
||||
const api = createApiClient(fakeLocation())
|
||||
await expect(api.apiFetch('/api/v1/me')).rejects.toThrow('GET /api/v1/me: 403 response is not JSON')
|
||||
expect(api.forbidden()).toBe(false)
|
||||
})
|
||||
|
||||
it('does not refresh /me for a csrf 403 on a GET, which carries no token', async () => {
|
||||
const fetchMock = stubFetch(errorResponse(403, 'csrf'))
|
||||
const api = createApiClient(fakeLocation())
|
||||
await expect(api.loadMe()).rejects.toMatchObject({ status: 403, code: 'csrf' })
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadMe', () => {
|
||||
it('rejects a /me response without a CSRF token', async () => {
|
||||
stubFetch(jsonResponse(200, { ...ME, csrfToken: '' }))
|
||||
const api = createApiClient(fakeLocation())
|
||||
await expect(api.loadMe()).rejects.toThrow('response is not a member with a CSRF token')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,155 @@
|
||||
import { ApiError, isRecord, parseApiError } from './errors.ts'
|
||||
|
||||
// CSRF_HEADER carries the session's CSRF token on every non-GET request.
|
||||
export const CSRF_HEADER = 'X-MealQuest-CSRF'
|
||||
|
||||
export const ME_PATH = '/api/v1/me'
|
||||
|
||||
// API_PREFIX is the only path apiFetch sends to.
|
||||
const API_PREFIX = '/api/v1/'
|
||||
|
||||
// apiURL resolves path against the page origin and returns it as the
|
||||
// path-absolute URL to fetch. It refuses any path that would not resolve to a
|
||||
// same-origin URL under /api/v1/, so the CSRF token is never sent anywhere
|
||||
// else: absolute and protocol-relative URLs on another origin, and dot
|
||||
// segments that climb out of /api/v1/.
|
||||
function apiURL(origin: string, method: string, path: string): string {
|
||||
const url = new URL(path, origin)
|
||||
if (!path.startsWith('/') || url.origin !== origin || !url.pathname.startsWith(API_PREFIX)) {
|
||||
throw new Error(`${method} ${path}: not a same-origin ${API_PREFIX} path`)
|
||||
}
|
||||
return url.pathname + url.search
|
||||
}
|
||||
|
||||
// Me is the GET /api/v1/me response: the signed-in member, the household and
|
||||
// the session's CSRF token.
|
||||
export type Me = {
|
||||
name: string
|
||||
email: string
|
||||
household: { label: string }
|
||||
csrfToken: string
|
||||
}
|
||||
|
||||
// ApiLocation is the part of window.location the client reads and navigates.
|
||||
// origin is the page origin every request must stay on.
|
||||
export type ApiLocation = Pick<Location, 'origin' | 'pathname' | 'search' | 'assign'>
|
||||
|
||||
export type ApiRequest = {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
|
||||
// Sent as JSON.
|
||||
body?: unknown
|
||||
}
|
||||
|
||||
export type ApiClient = {
|
||||
// apiFetch sends a same-origin JSON request to path, which must be under
|
||||
// /api/v1/, and resolves with the decoded response body, or rejects with an
|
||||
// ApiError.
|
||||
apiFetch<T>(path: string, request?: ApiRequest): Promise<T>
|
||||
// loadMe fetches GET /api/v1/me and caches its CSRF token.
|
||||
loadMe(): Promise<Me>
|
||||
// forbidden reports whether a request was refused because the signed-in
|
||||
// account is not in this household; subscribe is notified when it changes.
|
||||
forbidden(): boolean
|
||||
subscribe(listener: () => void): () => void
|
||||
}
|
||||
|
||||
// createApiClient builds the app's API client. location is window.location in
|
||||
// the app: a 401 sends it to /auth/login with the current path to return to.
|
||||
export function createApiClient(location: ApiLocation): ApiClient {
|
||||
let csrfToken: string | null = null
|
||||
let refreshing: Promise<Me> | null = null
|
||||
let forbidden = false
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
async function send(method: string, path: string, body: unknown): Promise<Response> {
|
||||
const headers: Record<string, string> = { Accept: 'application/json' }
|
||||
if (body !== undefined) headers['Content-Type'] = 'application/json'
|
||||
if (method !== 'GET') {
|
||||
if (csrfToken === null) {
|
||||
throw new Error(`${method} ${path}: no CSRF token; ${ME_PATH} has not loaded`)
|
||||
}
|
||||
headers[CSRF_HEADER] = csrfToken
|
||||
}
|
||||
return fetch(path, {
|
||||
method,
|
||||
headers,
|
||||
credentials: 'same-origin',
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
// refreshMe shares one in-flight /me request between every request that
|
||||
// got a csrf 403 at the same time.
|
||||
function refreshMe(): Promise<Me> {
|
||||
refreshing ??= loadMe().finally(() => {
|
||||
refreshing = null
|
||||
})
|
||||
return refreshing
|
||||
}
|
||||
|
||||
async function apiFetch<T>(path: string, request: ApiRequest = {}): Promise<T> {
|
||||
const method = request.method ?? 'GET'
|
||||
const url = apiURL(location.origin, method, path)
|
||||
let response = await send(method, url, request.body)
|
||||
if (response.status === 403) {
|
||||
const error = await parseForbidden(method, path, response)
|
||||
// Only a non-GET carries the token, so only it can be refused for it.
|
||||
if (error.code !== 'csrf' || method === 'GET') throw error
|
||||
// The session's token changed: refresh it once and retry once.
|
||||
await refreshMe()
|
||||
response = await send(method, url, request.body)
|
||||
if (response.status === 403) throw await parseForbidden(method, path, response)
|
||||
}
|
||||
return decode<T>(method, path, response)
|
||||
}
|
||||
|
||||
// parseForbidden reads a 403. Any API error other than csrf means the
|
||||
// account is not in this household.
|
||||
async function parseForbidden(method: string, path: string, response: Response): Promise<ApiError> {
|
||||
const error = await parseApiError(method, path, response)
|
||||
if (error.code !== 'csrf') markForbidden()
|
||||
return error
|
||||
}
|
||||
|
||||
function markForbidden() {
|
||||
if (forbidden) return
|
||||
forbidden = true
|
||||
listeners.forEach((listener) => listener())
|
||||
}
|
||||
|
||||
async function decode<T>(method: string, path: string, response: Response): Promise<T> {
|
||||
if (response.status === 401) {
|
||||
location.assign(`/auth/login?return=${encodeURIComponent(location.pathname + location.search)}`)
|
||||
}
|
||||
if (!response.ok) throw await parseApiError(method, path, response)
|
||||
if (response.status === 204) return undefined as T
|
||||
return (await response.json()) as T
|
||||
}
|
||||
|
||||
async function loadMe(): Promise<Me> {
|
||||
const me = await apiFetch<unknown>(ME_PATH)
|
||||
if (
|
||||
!isRecord(me) ||
|
||||
typeof me.name !== 'string' ||
|
||||
typeof me.email !== 'string' ||
|
||||
!isRecord(me.household) ||
|
||||
typeof me.household.label !== 'string' ||
|
||||
typeof me.csrfToken !== 'string' ||
|
||||
me.csrfToken === ''
|
||||
) {
|
||||
throw new Error(`GET ${ME_PATH}: response is not a member with a CSRF token`)
|
||||
}
|
||||
csrfToken = me.csrfToken
|
||||
return me as Me
|
||||
}
|
||||
|
||||
return {
|
||||
apiFetch,
|
||||
loadMe,
|
||||
forbidden: () => forbidden,
|
||||
subscribe(listener) {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// ApiError is a failed API response carrying the server's
|
||||
// {"error":{"code","message","retryable"}} body.
|
||||
export class ApiError extends Error {
|
||||
readonly status: number
|
||||
readonly code: string
|
||||
readonly retryable: boolean
|
||||
|
||||
constructor(status: number, code: string, message: string, retryable: boolean) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
this.code = code
|
||||
this.retryable = retryable
|
||||
}
|
||||
}
|
||||
|
||||
// parseApiError reads an error response body. A body that is not the API
|
||||
// error shape is itself an error: the server always sends one.
|
||||
export async function parseApiError(method: string, path: string, response: Response): Promise<ApiError> {
|
||||
const text = await response.text()
|
||||
let body: unknown
|
||||
try {
|
||||
body = JSON.parse(text)
|
||||
} catch {
|
||||
throw new Error(`${method} ${path}: ${response.status} response is not JSON: ${text.slice(0, 200)}`)
|
||||
}
|
||||
const error = isRecord(body) ? body.error : undefined
|
||||
if (
|
||||
!isRecord(error) ||
|
||||
typeof error.code !== 'string' ||
|
||||
typeof error.message !== 'string' ||
|
||||
typeof error.retryable !== 'boolean'
|
||||
) {
|
||||
throw new Error(`${method} ${path}: ${response.status} response has no API error body`)
|
||||
}
|
||||
return new ApiError(response.status, error.code, error.message, error.retryable)
|
||||
}
|
||||
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { ApiProvider, useApi } from './ApiContext.tsx'
|
||||
export { createApiClient, CSRF_HEADER, ME_PATH, type ApiClient, type ApiLocation, type ApiRequest, type Me } from './client.ts'
|
||||
export { ApiError } from './errors.ts'
|
||||
export { createQueryClient, shouldRetry } from './queryClient.ts'
|
||||
export { useJob, type Job, type JobState, type JobStatus } from './useJob.ts'
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ApiError, createQueryClient, shouldRetry } from './index.ts'
|
||||
|
||||
describe('query retries', () => {
|
||||
it('retry only an ApiError the server marked retryable, a bounded number of times', () => {
|
||||
expect(shouldRetry(0, new ApiError(503, 'unavailable', 'try later', true))).toBe(true)
|
||||
expect(shouldRetry(3, new ApiError(503, 'unavailable', 'try later', true))).toBe(false)
|
||||
expect(shouldRetry(0, new ApiError(401, 'unauthenticated', 'sign in required', false))).toBe(false)
|
||||
expect(shouldRetry(0, new ApiError(403, 'csrf', 'bad token', false))).toBe(false)
|
||||
expect(shouldRetry(0, new Error('network down'))).toBe(false)
|
||||
})
|
||||
|
||||
it('are the QueryClient default for queries and mutations', () => {
|
||||
const options = createQueryClient().getDefaultOptions()
|
||||
expect(options.queries?.retry).toBe(shouldRetry)
|
||||
expect(options.mutations?.retry).toBe(shouldRetry)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { ApiError } from './errors.ts'
|
||||
|
||||
// MAX_RETRIES bounds retries of a failure the server marked retryable.
|
||||
export const MAX_RETRIES = 3
|
||||
|
||||
// shouldRetry retries only an ApiError the server marked retryable — never a
|
||||
// 401, a 403 or a malformed response.
|
||||
export function shouldRetry(failureCount: number, error: unknown): boolean {
|
||||
return error instanceof ApiError && error.retryable && failureCount < MAX_RETRIES
|
||||
}
|
||||
|
||||
export function createQueryClient(): QueryClient {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: shouldRetry, staleTime: 30_000 },
|
||||
mutations: { retry: shouldRetry },
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { errorResponse, fakeLocation, jsonResponse, request, stubFetch } from '../test/fetchStub.ts'
|
||||
import { ApiProvider, createApiClient, useJob } from './index.ts'
|
||||
|
||||
type Draft = { dinners: string[] }
|
||||
|
||||
function job(status: string, result: Draft | null, error: string | null = null) {
|
||||
return jsonResponse(200, { id: 'j1', kind: 'plan.draft', status, result, error })
|
||||
}
|
||||
|
||||
function renderJob(jobId: string | null) {
|
||||
const api = createApiClient(fakeLocation())
|
||||
const wrapper = ({ children }: { children: ReactNode }) => <ApiProvider client={api}>{children}</ApiProvider>
|
||||
return renderHook(({ id }) => useJob<Draft>(id), { wrapper, initialProps: { id: jobId } })
|
||||
}
|
||||
|
||||
// advance runs the timers due in ms and lets the polls they start answer.
|
||||
async function advance(ms: number) {
|
||||
await act(() => vi.advanceTimersByTimeAsync(ms))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers()
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('useJob', () => {
|
||||
it('exposes the partial result while running, then the terminal result, backing off 500 ms to 2 s', async () => {
|
||||
const fetchMock = stubFetch(
|
||||
job('queued', null),
|
||||
job('running', { dinners: ['Tacos'] }),
|
||||
job('running', { dinners: ['Tacos', 'Chili'] }),
|
||||
job('running', { dinners: ['Tacos', 'Chili'] }),
|
||||
job('succeeded', { dinners: ['Tacos', 'Chili', 'Soup'] }),
|
||||
)
|
||||
const { result } = renderJob('j1')
|
||||
expect(result.current).toEqual({ status: undefined, result: null, error: null })
|
||||
|
||||
await advance(0)
|
||||
expect(result.current.status).toBe('queued')
|
||||
expect(request(fetchMock, 0)).toMatchObject({ path: '/api/v1/jobs/j1', method: 'GET' })
|
||||
|
||||
await advance(499)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
await advance(1)
|
||||
expect(result.current).toEqual({ status: 'running', result: { dinners: ['Tacos'] }, error: null })
|
||||
|
||||
await advance(999)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
await advance(1)
|
||||
expect(result.current.result).toEqual({ dinners: ['Tacos', 'Chili'] })
|
||||
|
||||
await advance(2000)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(4)
|
||||
await advance(2000)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(5)
|
||||
expect(result.current).toEqual({
|
||||
status: 'succeeded',
|
||||
result: { dinners: ['Tacos', 'Chili', 'Soup'] },
|
||||
error: null,
|
||||
})
|
||||
|
||||
// Terminal: no timer left, no further polls.
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
await advance(10_000)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(5)
|
||||
})
|
||||
|
||||
it('exposes a failed job as its error and stops', async () => {
|
||||
const fetchMock = stubFetch(job('running', { dinners: ['Tacos'] }), job('failed', null, 'model refused'))
|
||||
const { result } = renderJob('j1')
|
||||
|
||||
await advance(0)
|
||||
await advance(500)
|
||||
expect(result.current.status).toBe('failed')
|
||||
expect(result.current.error?.message).toBe('model refused')
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('exposes a failed poll as the error and keeps polling to the terminal status', async () => {
|
||||
const fetchMock = stubFetch(
|
||||
job('running', { dinners: ['Tacos'] }),
|
||||
errorResponse(503, 'unavailable', true),
|
||||
job('succeeded', { dinners: ['Tacos', 'Chili'] }),
|
||||
)
|
||||
const { result } = renderJob('j1')
|
||||
|
||||
await advance(0)
|
||||
await advance(500)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
expect(result.current).toMatchObject({ status: 'running', result: { dinners: ['Tacos'] } })
|
||||
expect(result.current.error).toMatchObject({ status: 503, code: 'unavailable' })
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
|
||||
// The backoff carries on from the failed poll: the next one is 1 s later.
|
||||
await advance(999)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
await advance(1)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3)
|
||||
expect(result.current).toEqual({ status: 'succeeded', result: { dinners: ['Tacos', 'Chili'] }, error: null })
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('stops polling on unmount after a failed poll', async () => {
|
||||
const fetchMock = stubFetch(errorResponse(503, 'unavailable', true))
|
||||
const { result, unmount } = renderJob('j1')
|
||||
|
||||
await advance(0)
|
||||
expect(result.current).toMatchObject({ status: undefined, result: null })
|
||||
expect(result.current.error).toMatchObject({ status: 503 })
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
unmount()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
await advance(10_000)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('stops polling the old job when the job id changes', async () => {
|
||||
const fetchMock = stubFetch(job('running', null), job('running', null))
|
||||
const { result, rerender } = renderJob('j1')
|
||||
|
||||
await advance(0)
|
||||
rerender({ id: 'j2' })
|
||||
expect(result.current).toEqual({ status: undefined, result: null, error: null })
|
||||
await advance(0)
|
||||
expect(request(fetchMock, 1).path).toBe('/api/v1/jobs/j2')
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
})
|
||||
|
||||
it('stops polling on unmount', async () => {
|
||||
const fetchMock = stubFetch(job('running', null))
|
||||
const { unmount } = renderJob('j1')
|
||||
|
||||
await advance(0)
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
unmount()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
await advance(10_000)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('ignores a poll that answers after unmount', async () => {
|
||||
let answer!: (response: Response) => void
|
||||
const fetchMock = stubFetch()
|
||||
fetchMock.mockImplementationOnce(() => new Promise((resolve) => (answer = resolve)))
|
||||
const { result, unmount } = renderJob('j1')
|
||||
|
||||
unmount()
|
||||
await act(async () => answer(await Promise.resolve(job('running', null))))
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(result.current.status).toBeUndefined()
|
||||
})
|
||||
|
||||
it('polls nothing without a job id', async () => {
|
||||
const fetchMock = stubFetch()
|
||||
const { result } = renderJob(null)
|
||||
await advance(5000)
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
expect(result.current).toEqual({ status: undefined, result: null, error: null })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useApi } from './ApiContext.tsx'
|
||||
|
||||
export type JobStatus = 'queued' | 'running' | 'succeeded' | 'failed'
|
||||
|
||||
// Job is the GET /api/v1/jobs/{id} response. result may be set while the job
|
||||
// is running (a partial result); error is set only when it failed.
|
||||
export type Job<R> = {
|
||||
id: string
|
||||
kind: string
|
||||
status: JobStatus
|
||||
result: R | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export type JobState<R> = {
|
||||
// undefined until the first poll answers.
|
||||
status: JobStatus | undefined
|
||||
// The latest result, partial while the job runs.
|
||||
result: R | null
|
||||
// The job's failure, or the latest polling request's own failure (cleared
|
||||
// by the next poll that answers).
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
// Polling starts at FIRST_DELAY_MS and doubles up to MAX_DELAY_MS.
|
||||
export const FIRST_DELAY_MS = 500
|
||||
export const MAX_DELAY_MS = 2000
|
||||
|
||||
const idle = { status: undefined, result: null, error: null }
|
||||
|
||||
function isTerminal(status: JobStatus): boolean {
|
||||
return status === 'succeeded' || status === 'failed'
|
||||
}
|
||||
|
||||
// useJob polls GET /api/v1/jobs/{jobId} until the job succeeds or fails,
|
||||
// backing off from 500 ms to 2 s. A failed poll is exposed as the error and
|
||||
// polling continues. Polling stops only at a terminal status, when jobId
|
||||
// changes and on unmount. A null jobId polls nothing.
|
||||
export function useJob<R>(jobId: string | null): JobState<R> {
|
||||
const { apiFetch } = useApi()
|
||||
const [state, setState] = useState<{ jobId: string | null; job: JobState<R> }>({ jobId, job: idle })
|
||||
|
||||
useEffect(() => {
|
||||
if (jobId === null) return
|
||||
let active = true
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let delay = FIRST_DELAY_MS
|
||||
|
||||
function next() {
|
||||
timer = setTimeout(poll, delay)
|
||||
delay = Math.min(delay * 2, MAX_DELAY_MS)
|
||||
}
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
const job = await apiFetch<Job<R>>(`/api/v1/jobs/${encodeURIComponent(jobId!)}`)
|
||||
if (!active) return
|
||||
const error = job.status === 'failed' ? new Error(job.error ?? `job ${job.id} failed without an error message`) : null
|
||||
setState({ jobId, job: { status: job.status, result: job.result, error } })
|
||||
if (isTerminal(job.status)) return
|
||||
} catch (error) {
|
||||
if (!active) return
|
||||
// A failed poll is not the job's end: keep the last known status and
|
||||
// result, expose the request's error, and poll again.
|
||||
setState((prev) => ({
|
||||
jobId,
|
||||
job: { ...(prev.jobId === jobId ? prev.job : idle), error: error instanceof Error ? error : new Error(String(error)) },
|
||||
}))
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
||||
void poll()
|
||||
return () => {
|
||||
active = false
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}, [apiFetch, jobId])
|
||||
|
||||
// State left from a previous jobId is not this job's.
|
||||
return state.jobId === jobId ? state.job : idle
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2022 The Bagel Fat Project Authors (https://github.com/JAMO-TYPEFACE/BagelFat)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2022 The Figtree Project Authors (https://github.com/erikdkennedy/figtree)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
+6
-1
@@ -1,17 +1,22 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router'
|
||||
import './styles/tokens.css'
|
||||
import './styles/base.css'
|
||||
import App from './App.tsx'
|
||||
import { createApiClient } from './api/index.ts'
|
||||
|
||||
const root = document.getElementById('root')
|
||||
if (!root) {
|
||||
throw new Error('MealQuest: #root element missing from index.html')
|
||||
}
|
||||
|
||||
const api = createApiClient(window.location)
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
<App api={api} />
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/* A full-viewport screen shown instead of the app shell. */
|
||||
.screen {
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-8) var(--space-4);
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.loading {
|
||||
width: min(100%, 480px);
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.title {
|
||||
height: 32px;
|
||||
width: 60%;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { EmptyState } from '../ui/EmptyState.tsx'
|
||||
import styles from './FullScreen.module.css'
|
||||
|
||||
// Shown when the server refuses a signed-in account that is not in this
|
||||
// household. Nothing else renders: the account can do nothing here.
|
||||
export function NotInHouseholdPage() {
|
||||
return (
|
||||
<main className={styles.screen}>
|
||||
<EmptyState title="Not in this household">
|
||||
You are signed in, but this account is not in this household. Ask someone in Our kitchen to add it.
|
||||
</EmptyState>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export type PlaceholderPageProps = {
|
||||
// The route's screen name, shown as the page heading.
|
||||
title: string
|
||||
}
|
||||
|
||||
// Stands in for a screen until its feature is built.
|
||||
export function PlaceholderPage({ title }: PlaceholderPageProps) {
|
||||
return <h1>{title}</h1>
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// @vitest-environment node
|
||||
// Builds the app with the production config and serves the result, so the
|
||||
// assertions cover what ships in dist: the web manifest, the service worker,
|
||||
// every icon, and the service worker registration that only builds include.
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { build, createServer, preview, type PreviewServer, type ViteDevServer } from 'vite'
|
||||
import tokens from './styles/tokens.css?raw'
|
||||
|
||||
const outDir = 'node_modules/.tmp/pwa-test-dist'
|
||||
|
||||
const icons = [
|
||||
{ path: '/icons/icon-192.png', size: 192 },
|
||||
{ path: '/icons/icon-512.png', size: 512 },
|
||||
{ path: '/icons/icon-maskable-512.png', size: 512 },
|
||||
{ path: '/apple-touch-icon.png', size: 180 },
|
||||
]
|
||||
|
||||
function token(name: string): string {
|
||||
const value = tokens.match(new RegExp(`^\\s*${name}:\\s*([^;\\s]+);`, 'm'))?.[1]
|
||||
if (!value) throw new Error(`${name} is not defined in tokens.css`)
|
||||
return value
|
||||
}
|
||||
|
||||
// Width and height from a PNG's IHDR chunk, which follows the 8-byte
|
||||
// signature, the chunk length and the chunk type.
|
||||
// The precache list vite-plugin-pwa injects into sw.js: a JSON array of
|
||||
// { revision, url } entries.
|
||||
function precacheEntries(sw: string): Array<{ revision: string | null; url: string }> {
|
||||
const start = sw.indexOf('[{"revision":')
|
||||
expect(start, 'injected precache list').toBeGreaterThanOrEqual(0)
|
||||
return JSON.parse(sw.slice(start, sw.indexOf('}]', start) + 2))
|
||||
}
|
||||
|
||||
function pngSize(bytes: Uint8Array): { width: number; height: number } {
|
||||
const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
|
||||
expect(Array.from(bytes.subarray(0, 8))).toEqual(signature)
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
|
||||
return { width: view.getUint32(16), height: view.getUint32(20) }
|
||||
}
|
||||
|
||||
describe('production build', () => {
|
||||
let server: PreviewServer
|
||||
let origin: string
|
||||
|
||||
async function get(path: string): Promise<Response> {
|
||||
const response = await fetch(new URL(path, origin))
|
||||
expect(response.status, path).toBe(200)
|
||||
return response
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await build({ logLevel: 'error', build: { outDir, emptyOutDir: true } })
|
||||
server = await preview({
|
||||
logLevel: 'error',
|
||||
build: { outDir },
|
||||
preview: { port: 0, strictPort: false, host: '127.0.0.1' },
|
||||
})
|
||||
const url = server.resolvedUrls?.local[0]
|
||||
if (!url) throw new Error('preview server did not report a URL')
|
||||
origin = url
|
||||
}, 60_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.close()
|
||||
})
|
||||
|
||||
it('emits manifest.webmanifest with the MealQuest identity', async () => {
|
||||
const manifest = await (await get('/manifest.webmanifest')).json()
|
||||
expect(manifest).toMatchObject({
|
||||
name: 'MealQuest',
|
||||
short_name: 'MealQuest',
|
||||
start_url: '/',
|
||||
scope: '/',
|
||||
display: 'standalone',
|
||||
// --color-accent and --color-bg hold the manifest colours; hex colours
|
||||
// may not be written outside tokens.css.
|
||||
theme_color: token('--color-accent'),
|
||||
background_color: token('--color-bg'),
|
||||
})
|
||||
expect(manifest.icons).toEqual([
|
||||
{ src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
|
||||
{ src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
|
||||
{
|
||||
src: '/icons/icon-maskable-512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
purpose: 'maskable',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it.each(icons)('ships $path as a $size px square PNG', async ({ path, size }) => {
|
||||
const bytes = new Uint8Array(await (await get(path)).arrayBuffer())
|
||||
expect(pngSize(bytes)).toEqual({ width: size, height: size })
|
||||
})
|
||||
|
||||
it('emits sw.js with the precache list injected', async () => {
|
||||
const sw = await (await get('/sw.js')).text()
|
||||
expect(sw).not.toContain('self.__WB_MANIFEST')
|
||||
expect(sw).toContain('"url":"index.html"')
|
||||
for (const { path } of icons.slice(0, 3)) {
|
||||
expect(sw).toContain(`"url":"${path.slice(1)}"`)
|
||||
}
|
||||
})
|
||||
|
||||
it('precaches the shell, JS, CSS and every self-hosted font', async () => {
|
||||
const entries = precacheEntries(await (await get('/sw.js')).text())
|
||||
const urls = entries.map((entry) => entry.url)
|
||||
// index.html keeps its URL across builds, so it carries a content
|
||||
// revision; the other assets have content hashes in their names.
|
||||
expect(entries.find((entry) => entry.url === 'index.html')?.revision).toMatch(/^[0-9a-f]{32}$/)
|
||||
expect(urls.some((url) => url.endsWith('.js'))).toBe(true)
|
||||
expect(urls.some((url) => url.endsWith('.css'))).toBe(true)
|
||||
for (const font of [
|
||||
'BagelFatOne-LatinExt',
|
||||
'BagelFatOne-Latin',
|
||||
'Figtree-Regular',
|
||||
'Figtree-SemiBold',
|
||||
'Figtree-Bold',
|
||||
]) {
|
||||
expect(urls.some((url) => url.startsWith(`assets/${font}`) && url.endsWith('.woff2'))).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('links the manifest, theme colour and apple-touch-icon from index.html', async () => {
|
||||
const html = await (await get('/')).text()
|
||||
expect(html.match(/<link rel="manifest" href="\/manifest\.webmanifest">/g)).toHaveLength(1)
|
||||
expect(html).toContain(`<meta name="theme-color" content="${token('--color-accent')}" />`)
|
||||
expect(html).toContain('<link rel="apple-touch-icon" href="/apple-touch-icon.png" />')
|
||||
})
|
||||
|
||||
it('registers /sw.js at scope / from index.html', async () => {
|
||||
const html = await (await get('/')).text()
|
||||
expect(html).toContain('<script id="vite-plugin-pwa:register-sw" src="/registerSW.js"></script>')
|
||||
const register = await (await get('/registerSW.js')).text()
|
||||
expect(register).toContain("navigator.serviceWorker.register('/sw.js', { scope: '/' })")
|
||||
})
|
||||
})
|
||||
|
||||
describe('dev server', () => {
|
||||
let server: ViteDevServer
|
||||
|
||||
beforeAll(async () => {
|
||||
server = await createServer({ logLevel: 'error', server: { middlewareMode: true } })
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await server?.close()
|
||||
})
|
||||
|
||||
it('does not register a service worker', async () => {
|
||||
const html = await server.transformIndexHtml('/', '<html><head></head><body></body></html>')
|
||||
expect(html).not.toContain('registerSW')
|
||||
expect(html).not.toContain('serviceWorker')
|
||||
expect(html).not.toContain('rel="manifest"')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
.shell {
|
||||
position: relative;
|
||||
height: 100dvh;
|
||||
display: flex;
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* The bottom bar overlays the last 94 px of the screen (plus the safe area),
|
||||
so the scroll area reserves at least 100 px beneath its content. */
|
||||
.mobile .main {
|
||||
padding: 0 20px;
|
||||
padding-bottom: calc(100px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.tablet .main,
|
||||
.desktop .main {
|
||||
padding: 32px 36px;
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { render, screen, within } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { AppRoutes } from '../App.tsx'
|
||||
import { declaredStyle, outerSize } from '../test/cssRules.ts'
|
||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||
import { mockViewport } from '../test/matchMedia.ts'
|
||||
import bottomBarStyles from './BottomBar.module.css'
|
||||
import railStyles from './Rail.module.css'
|
||||
import sidebarStyles from './Sidebar.module.css'
|
||||
|
||||
const ORDER = ['Home', 'Plan', 'Discover', 'Quick', 'Settings']
|
||||
const MOBILE = 390
|
||||
const TABLET = 1180
|
||||
const DESKTOP = 1440
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(window, 'matchMedia')
|
||||
})
|
||||
|
||||
function renderAt(path: string, width: number) {
|
||||
const resize = mockViewport(width)
|
||||
render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<AppRoutes />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
return resize
|
||||
}
|
||||
|
||||
function nav() {
|
||||
return screen.getByRole('navigation', { name: 'Primary' })
|
||||
}
|
||||
|
||||
function navLinks() {
|
||||
return within(nav()).getAllByRole('link')
|
||||
}
|
||||
|
||||
function current() {
|
||||
return navLinks()
|
||||
.filter((link) => link.getAttribute('aria-current') === 'page')
|
||||
.map((link) => link.textContent)
|
||||
}
|
||||
|
||||
describe('AppShell', () => {
|
||||
it.each([MOBILE, TABLET, DESKTOP])('orders the nav Home · Plan · Discover · Quick · Settings at %i px', (width) => {
|
||||
renderAt('/', width)
|
||||
expect(navLinks().map((link) => link.textContent)).toEqual(ORDER)
|
||||
expect(navLinks().map((link) => link.getAttribute('href'))).toEqual([
|
||||
'/',
|
||||
'/plan',
|
||||
'/discover',
|
||||
'/quick',
|
||||
'/settings',
|
||||
])
|
||||
})
|
||||
|
||||
it('shows only the mobile bottom bar below 768 px', () => {
|
||||
renderAt('/', 767)
|
||||
expect(screen.getAllByRole('navigation')).toHaveLength(1)
|
||||
expect(nav()).toHaveClass(bottomBarStyles.bar!)
|
||||
expect(screen.queryByText('M')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('MealQuest')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Our kitchen')).not.toBeInTheDocument()
|
||||
|
||||
const bar = declaredStyle(nav())
|
||||
expect(bar.position).toBe('absolute')
|
||||
expect(bar.left).toBe('12px')
|
||||
expect(bar.right).toBe('12px')
|
||||
expect(bar.bottom).toBe('calc(16px + env(safe-area-inset-bottom))')
|
||||
expect(bar.height).toBe('62px')
|
||||
expect(outerSize(nav(), 'height')).toBe(62)
|
||||
expect(bar.background).toBe('var(--color-text)')
|
||||
expect(bar.borderRadius).toBe('var(--radius-pill)')
|
||||
})
|
||||
|
||||
it('shows the active mobile item as icon and label over the one grape pill, inactive items as neutral icons', () => {
|
||||
renderAt('/discover', MOBILE)
|
||||
const pills = nav().querySelectorAll(`.${bottomBarStyles.pill}`)
|
||||
expect(pills).toHaveLength(1)
|
||||
const pill = declaredStyle(pills[0]!)
|
||||
expect(pill.position).toBe('absolute')
|
||||
expect(pill.top).toBe('10px')
|
||||
expect(pill.height).toBe('42px')
|
||||
expect(pill.background).toBe('var(--color-accent)')
|
||||
expect(Number(pill.zIndex)).toBeLessThan(Number(declaredStyle(navLinks()[0]!).zIndex))
|
||||
|
||||
const active = screen.getByRole('link', { name: 'Discover' })
|
||||
expect(declaredStyle(active).color).toBe('var(--on-accent)')
|
||||
expect(within(active).getByText('Discover')).toHaveClass(bottomBarStyles.label!)
|
||||
expect(declaredStyle(within(active).getByText('Discover')).fontSize).toBe('13px')
|
||||
|
||||
const inactive = screen.getByRole('link', { name: 'Plan' })
|
||||
expect(declaredStyle(inactive).color).toBe('var(--color-neutral-400)')
|
||||
expect(within(inactive).getByText('Plan')).toHaveClass(bottomBarStyles.hiddenLabel!)
|
||||
})
|
||||
|
||||
it('makes the full 62 px bar height the mobile tap target', () => {
|
||||
renderAt('/', MOBILE)
|
||||
for (const link of navLinks()) {
|
||||
expect(declaredStyle(link).height).toBe('62px')
|
||||
expectMinHitArea(link)
|
||||
}
|
||||
})
|
||||
|
||||
it('reserves at least 100 px below the mobile scroll area', () => {
|
||||
renderAt('/', MOBILE)
|
||||
const padding = declaredStyle(screen.getByRole('main')).paddingBottom
|
||||
const reserved = /^calc\((\d+)px \+ env\(safe-area-inset-bottom\)\)$/.exec(padding)
|
||||
expect(reserved, `padding-bottom ${JSON.stringify(padding)}`).not.toBeNull()
|
||||
expect(Number(reserved![1])).toBeGreaterThanOrEqual(100)
|
||||
})
|
||||
|
||||
it.each([768, TABLET, 1199])('shows only the tablet rail with the "M" mark at %i px', (width) => {
|
||||
renderAt('/plan', width)
|
||||
expect(screen.getAllByRole('navigation')).toHaveLength(1)
|
||||
expect(nav()).toHaveClass(railStyles.rail!)
|
||||
expect(declaredStyle(nav()).width).toBe('96px')
|
||||
expect(outerSize(nav(), 'width')).toBe(96)
|
||||
expect(declaredStyle(nav()).background).toBe('var(--color-surface)')
|
||||
const mark = within(nav()).getByText('M')
|
||||
expect(declaredStyle(mark).width).toBe('48px')
|
||||
expect(outerSize(mark, 'width')).toBe(48)
|
||||
expect(outerSize(mark, 'height')).toBe(48)
|
||||
expect(declaredStyle(mark).background).toBe('var(--color-accent)')
|
||||
expect(screen.queryByText('MealQuest')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Our kitchen')).not.toBeInTheDocument()
|
||||
|
||||
for (const link of navLinks()) {
|
||||
const pill = link.firstElementChild!
|
||||
expect(declaredStyle(pill).width).toBe('56px')
|
||||
expect(declaredStyle(pill).height).toBe('36px')
|
||||
expect(outerSize(pill, 'width')).toBe(56)
|
||||
expect(outerSize(pill, 'height')).toBe(36)
|
||||
expect(declaredStyle(link).fontSize).toBe('11px')
|
||||
expect(declaredStyle(link).fontWeight).toBe('700')
|
||||
expectMinHitArea(link)
|
||||
}
|
||||
expect(screen.getByRole('link', { name: 'Plan' }).firstElementChild).toHaveClass(railStyles.active!)
|
||||
expect(screen.getByRole('link', { name: 'Home' }).firstElementChild).not.toHaveClass(railStyles.active!)
|
||||
})
|
||||
|
||||
it.each([1200, DESKTOP])('shows only the desktop sidebar with wordmark and household card at %i px', (width) => {
|
||||
renderAt('/quick', width)
|
||||
expect(screen.getAllByRole('navigation')).toHaveLength(1)
|
||||
const sidebar = nav().closest('aside')!
|
||||
expect(sidebar).toHaveClass(sidebarStyles.sidebar!)
|
||||
expect(declaredStyle(sidebar).width).toBe('208px')
|
||||
expect(declaredStyle(sidebar).background).toBe('var(--color-surface)')
|
||||
expect(declaredStyle(sidebar).padding).toBe('28px 16px')
|
||||
// The 16 px side padding sits inside the 208 px, not outside it.
|
||||
expect(outerSize(sidebar, 'width')).toBe(208)
|
||||
|
||||
const wordmark = within(sidebar).getByText('MealQuest')
|
||||
expect(declaredStyle(wordmark).fontFamily).toBe('var(--font-heading)')
|
||||
expect(declaredStyle(wordmark).fontSize).toBe('28px')
|
||||
expect(declaredStyle(wordmark).color).toBe('var(--color-accent)')
|
||||
|
||||
expect(within(sidebar).getByText('Our kitchen')).toBeInTheDocument()
|
||||
expect(within(sidebar).getByText('Shared household')).toBeInTheDocument()
|
||||
expect(screen.queryByText('M')).not.toBeInTheDocument()
|
||||
|
||||
const active = screen.getByRole('link', { name: 'Quick' })
|
||||
expect(active).toHaveClass(sidebarStyles.active!)
|
||||
expect(declaredStyle(active).background).toBe('var(--color-accent)')
|
||||
expect(declaredStyle(active).color).toBe('var(--on-accent)')
|
||||
expect(declaredStyle(active).fontSize).toBe('15px')
|
||||
expect(screen.getByRole('link', { name: 'Home' })).not.toHaveClass(sidebarStyles.active!)
|
||||
// Rows stretch across the sidebar, so only their height needs declaring.
|
||||
for (const link of navLinks()) expect(declaredStyle(link).minHeight).toBe('44px')
|
||||
})
|
||||
|
||||
it('switches layout when the viewport crosses a breakpoint', () => {
|
||||
const resize = renderAt('/', MOBILE)
|
||||
expect(nav()).toHaveClass(bottomBarStyles.bar!)
|
||||
resize(TABLET)
|
||||
expect(nav()).toHaveClass(railStyles.rail!)
|
||||
resize(DESKTOP)
|
||||
expect(nav().closest('aside')).toHaveClass(sidebarStyles.sidebar!)
|
||||
resize(MOBILE)
|
||||
expect(nav()).toHaveClass(bottomBarStyles.bar!)
|
||||
expect(screen.getAllByRole('navigation')).toHaveLength(1)
|
||||
})
|
||||
|
||||
describe.each([
|
||||
['mobile', MOBILE],
|
||||
['tablet', TABLET],
|
||||
['desktop', DESKTOP],
|
||||
])('aria-current (%s)', (_layout, width) => {
|
||||
it.each([
|
||||
['/', ['Home']],
|
||||
['/plan', ['Plan']],
|
||||
['/plan/shopping', ['Plan']],
|
||||
['/plan/shopping/archive', ['Plan']],
|
||||
['/discover', ['Discover']],
|
||||
['/quick', ['Quick']],
|
||||
['/settings', ['Settings']],
|
||||
['/settings/notifications', ['Settings']],
|
||||
['/settings/ai', ['Settings']],
|
||||
['/settings/household', ['Settings']],
|
||||
['/settings/regulars', ['Settings']],
|
||||
['/settings/calendar', ['Settings']],
|
||||
['/onboarding', []],
|
||||
['/cook-from-pantry', []],
|
||||
['/dinner/abc123', []],
|
||||
])('%s marks %j current', (path, expected) => {
|
||||
renderAt(path, width)
|
||||
expect(current()).toEqual(expected)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Outlet } from 'react-router'
|
||||
import { BottomBar } from './BottomBar.tsx'
|
||||
import { Rail } from './Rail.tsx'
|
||||
import { Sidebar } from './Sidebar.tsx'
|
||||
import { useBreakpoint } from './useBreakpoint.ts'
|
||||
import styles from './AppShell.module.css'
|
||||
|
||||
// The responsive frame around every page: one nav layout per breakpoint and a
|
||||
// scrolling main area for the routed page.
|
||||
export function AppShell() {
|
||||
const breakpoint = useBreakpoint()
|
||||
return (
|
||||
<div className={`${styles.shell} ${styles[breakpoint]}`}>
|
||||
{breakpoint === 'mobile' && <BottomBar />}
|
||||
{breakpoint === 'tablet' && <Rail />}
|
||||
{breakpoint === 'desktop' && <Sidebar />}
|
||||
<main className={styles.main}>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
.bar {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
bottom: calc(16px + env(safe-area-inset-bottom));
|
||||
height: 62px;
|
||||
padding: 0 6px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-text);
|
||||
}
|
||||
|
||||
/* The one grape pill, shared by every item: BottomBar sets its left and width
|
||||
to the active item's and slides it between items. */
|
||||
.pill {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
height: 42px;
|
||||
z-index: 0;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-accent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* The whole bar height is the tap target; the 42 px pill sits behind it.
|
||||
Items stay above the pill. */
|
||||
.item {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
height: 62px;
|
||||
min-width: 44px;
|
||||
padding: 0 13px;
|
||||
box-sizing: border-box;
|
||||
color: var(--color-neutral-400);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
transition: color .2s;
|
||||
}
|
||||
|
||||
.item[aria-current='page'] {
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Inactive items are icon-only on screen but keep their name for assistive tech. */
|
||||
.hiddenLabel {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||
import { AppRoutes } from '../App.tsx'
|
||||
import { declaredStyle } from '../test/cssRules.ts'
|
||||
import { mockViewport } from '../test/matchMedia.ts'
|
||||
import styles from './BottomBar.module.css'
|
||||
|
||||
const MOBILE = 390
|
||||
const SPRING = 'cubic-bezier(.34, 1.4, .64, 1)'
|
||||
|
||||
// jsdom does no layout, so lay the bar out here: items sit side by side from
|
||||
// 6 px in, inactive items 48 px wide and the active one (icon + label) 110 px.
|
||||
function layOut(element: HTMLElement): { left: number; width: number } {
|
||||
const bar = element.parentElement!
|
||||
const items = Array.from(bar.querySelectorAll<HTMLElement>(`.${styles.item}`))
|
||||
let left = 6
|
||||
for (const item of items) {
|
||||
const width = item.getAttribute('aria-current') === 'page' ? 110 : 48
|
||||
if (item === element) return { left, width }
|
||||
left += width
|
||||
}
|
||||
return { left: 0, width: 0 }
|
||||
}
|
||||
|
||||
// jsdom paints nothing either, so bounding boxes come from the laid-out
|
||||
// offsets with the bar at the viewport's left edge. A test mid-slide sets
|
||||
// where the pill is drawn and how far each item is still translated.
|
||||
let drawnPill: { left: number; width: number } | null
|
||||
let translated: Map<Element, number>
|
||||
|
||||
function box(left: number, width: number): DOMRect {
|
||||
return { left, width, x: left, right: left + width, top: 0, y: 0, bottom: 0, height: 0 } as DOMRect
|
||||
}
|
||||
|
||||
let animate: Mock<Element['animate']>
|
||||
|
||||
beforeEach(() => {
|
||||
drawnPill = null
|
||||
translated = new Map()
|
||||
// jsdom has no Web Animations API.
|
||||
animate = vi.fn<Element['animate']>(() => ({ cancel: vi.fn() }) as unknown as Animation)
|
||||
Element.prototype.animate = animate
|
||||
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
|
||||
if (!(this instanceof HTMLElement)) return box(0, 0)
|
||||
if (this.classList.contains(styles.pill!)) {
|
||||
return drawnPill
|
||||
? box(drawnPill.left, drawnPill.width)
|
||||
: box(parseFloat(this.style.left), parseFloat(this.style.width))
|
||||
}
|
||||
if (this.classList.contains(styles.item!)) {
|
||||
return box(this.offsetLeft + (translated.get(this) ?? 0), this.offsetWidth)
|
||||
}
|
||||
return box(0, 0)
|
||||
})
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetLeft', 'get').mockImplementation(function (this: HTMLElement) {
|
||||
return layOut(this).left
|
||||
})
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(function (this: HTMLElement) {
|
||||
return layOut(this).width
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
Reflect.deleteProperty(Element.prototype, 'animate')
|
||||
Reflect.deleteProperty(window, 'matchMedia')
|
||||
})
|
||||
|
||||
function renderAt(path: string, reducedMotion = false) {
|
||||
mockViewport(MOBILE, { reducedMotion })
|
||||
render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<AppRoutes />
|
||||
</MemoryRouter>,
|
||||
)
|
||||
}
|
||||
|
||||
function nav() {
|
||||
return screen.getByRole('navigation', { name: 'Primary' })
|
||||
}
|
||||
|
||||
function pill(): HTMLElement {
|
||||
return nav().querySelector<HTMLElement>(`.${styles.pill}`)!
|
||||
}
|
||||
|
||||
function link(name: string): HTMLElement {
|
||||
return within(nav()).getByRole('link', { name })
|
||||
}
|
||||
|
||||
function runningOn(element: Element): Animation[] {
|
||||
return animate.mock.contexts
|
||||
.map((target, i) => ({ target, animation: animate.mock.results[i]!.value as Animation }))
|
||||
.filter(({ target }) => target === element)
|
||||
.map(({ animation }) => animation)
|
||||
}
|
||||
|
||||
function animationsOf(element: Element) {
|
||||
return animate.mock.contexts
|
||||
.map((target, i) => ({ target, args: animate.mock.calls[i]! }))
|
||||
.filter(({ target }) => target === element)
|
||||
.map(({ args }) => ({ keyframes: args[0], options: args[1] as KeyframeAnimationOptions }))
|
||||
}
|
||||
|
||||
describe('BottomBar', () => {
|
||||
it('places the pill on the active item without animating on first render', () => {
|
||||
renderAt('/plan')
|
||||
expect(pill().style.left).toBe('54px')
|
||||
expect(pill().style.width).toBe('110px')
|
||||
expect(pill()).toBeVisible()
|
||||
expect(animate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hides the pill when no item is active', () => {
|
||||
renderAt('/onboarding')
|
||||
expect(pill()).not.toBeVisible()
|
||||
})
|
||||
|
||||
it('slides the pill from the old item to the new one over 420 ms on the spring curve', () => {
|
||||
renderAt('/')
|
||||
fireEvent.click(link('Quick'))
|
||||
|
||||
expect(animationsOf(pill())).toEqual([
|
||||
{
|
||||
keyframes: [
|
||||
{ left: '6px', width: '110px' },
|
||||
{ left: '150px', width: '110px' },
|
||||
],
|
||||
options: { duration: 420, easing: SPRING },
|
||||
},
|
||||
])
|
||||
expect(pill().style.left).toBe('150px')
|
||||
expect(pill().style.width).toBe('110px')
|
||||
})
|
||||
|
||||
it('FLIPs every item that moved from its old left to its new one with the same timing', () => {
|
||||
renderAt('/')
|
||||
fireEvent.click(link('Quick'))
|
||||
|
||||
// Home shrinks from 110 to 48 px, so Plan and Discover shift 62 px left
|
||||
// and Quick grows in place; Settings ends where it started.
|
||||
for (const name of ['Plan', 'Discover']) {
|
||||
expect(animationsOf(link(name))).toEqual([
|
||||
{
|
||||
keyframes: [{ transform: 'translateX(62px)' }, { transform: 'translateX(0)' }],
|
||||
options: { duration: 420, easing: SPRING },
|
||||
},
|
||||
])
|
||||
}
|
||||
expect(animationsOf(link('Quick'))).toEqual([
|
||||
{
|
||||
keyframes: [{ transform: 'translateX(62px)' }, { transform: 'translateX(0)' }],
|
||||
options: { duration: 420, easing: SPRING },
|
||||
},
|
||||
])
|
||||
expect(animationsOf(link('Home'))).toEqual([])
|
||||
expect(animationsOf(link('Settings'))).toEqual([])
|
||||
})
|
||||
|
||||
it('fades the new label in over 220 ms ease-out after 120 ms', () => {
|
||||
renderAt('/')
|
||||
fireEvent.click(link('Discover'))
|
||||
|
||||
const label = within(link('Discover')).getByText('Discover')
|
||||
expect(label).toHaveClass(styles.label!)
|
||||
expect(animationsOf(label)).toEqual([
|
||||
{
|
||||
keyframes: [
|
||||
{ opacity: 0, transform: 'translateX(-4px)' },
|
||||
{ opacity: 1, transform: 'none' },
|
||||
],
|
||||
options: { duration: 220, delay: 120, easing: 'ease-out', fill: 'backwards' },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('does not animate when the active item stays the same', () => {
|
||||
renderAt('/plan')
|
||||
fireEvent.click(link('Plan'))
|
||||
expect(animate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('jumps the pill to the new item with no animation under reduced motion', () => {
|
||||
renderAt('/', true)
|
||||
fireEvent.click(link('Settings'))
|
||||
|
||||
expect(link('Settings')).toHaveAttribute('aria-current', 'page')
|
||||
expect(animate).not.toHaveBeenCalled()
|
||||
expect(pill().style.left).toBe('198px')
|
||||
expect(pill().style.width).toBe('110px')
|
||||
})
|
||||
|
||||
it('re-places the pill when the viewport resizes', () => {
|
||||
renderAt('/discover')
|
||||
expect(pill().style.left).toBe('102px')
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetLeft', 'get').mockReturnValue(120)
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(96)
|
||||
fireEvent(window, new Event('resize'))
|
||||
expect(pill().style.left).toBe('120px')
|
||||
expect(pill().style.width).toBe('96px')
|
||||
expect(animate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('starts a second slide from where the first one is on screen, not from its destination', () => {
|
||||
renderAt('/')
|
||||
fireEvent.click(link('Quick'))
|
||||
const [firstPill] = runningOn(pill())
|
||||
const firstPlan = runningOn(link('Plan'))[0]!
|
||||
|
||||
// Midway: the pill is drawn at 78 px on its way from 6 to 150, and Plan
|
||||
// still carries 31 of its 62 px FLIP translation.
|
||||
drawnPill = { left: 78, width: 110 }
|
||||
translated.set(link('Plan'), 31)
|
||||
animate.mockClear()
|
||||
fireEvent.click(link('Settings'))
|
||||
|
||||
expect(firstPill!.cancel).toHaveBeenCalled()
|
||||
expect(firstPlan.cancel).toHaveBeenCalled()
|
||||
expect(animationsOf(pill())).toEqual([
|
||||
{
|
||||
keyframes: [
|
||||
{ left: '78px', width: '110px' },
|
||||
{ left: '198px', width: '110px' },
|
||||
],
|
||||
options: { duration: 420, easing: SPRING },
|
||||
},
|
||||
])
|
||||
// Plan's layout does not move between Quick and Settings, so it only
|
||||
// finishes the 31 px it had left. Settings moves 62 px left.
|
||||
expect(animationsOf(link('Plan'))).toEqual([
|
||||
{
|
||||
keyframes: [{ transform: 'translateX(31px)' }, { transform: 'translateX(0)' }],
|
||||
options: { duration: 420, easing: SPRING },
|
||||
},
|
||||
])
|
||||
expect(animationsOf(link('Settings'))).toEqual([
|
||||
{
|
||||
keyframes: [{ transform: 'translateX(62px)' }, { transform: 'translateX(0)' }],
|
||||
options: { duration: 420, easing: SPRING },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('stops a running slide and places the pill on the active item when the viewport resizes', () => {
|
||||
renderAt('/')
|
||||
fireEvent.click(link('Quick'))
|
||||
const slide = [pill(), link('Plan'), link('Discover'), link('Quick')].flatMap(runningOn)
|
||||
expect(slide).toHaveLength(4)
|
||||
|
||||
animate.mockClear()
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetLeft', 'get').mockReturnValue(140)
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(96)
|
||||
fireEvent(window, new Event('resize'))
|
||||
|
||||
for (const animation of slide) expect(animation.cancel).toHaveBeenCalled()
|
||||
expect(animate).not.toHaveBeenCalled()
|
||||
expect(pill().style.left).toBe('140px')
|
||||
expect(pill().style.width).toBe('96px')
|
||||
})
|
||||
|
||||
it('re-places the pill once the web fonts are ready', async () => {
|
||||
let fontsReady!: () => void
|
||||
const ready = new Promise<void>((resolve) => (fontsReady = resolve))
|
||||
Object.defineProperty(document, 'fonts', { configurable: true, value: { ready } })
|
||||
try {
|
||||
renderAt('/discover')
|
||||
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(124)
|
||||
fontsReady()
|
||||
await ready
|
||||
expect(pill().style.width).toBe('124px')
|
||||
} finally {
|
||||
Reflect.deleteProperty(document, 'fonts')
|
||||
}
|
||||
})
|
||||
|
||||
it('crossfades the icon colour with a .2s colour transition', () => {
|
||||
renderAt('/')
|
||||
for (const item of within(nav()).getAllByRole('link')) {
|
||||
expect(declaredStyle(item).transition).toBe('color .2s')
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,152 @@
|
||||
import { useLayoutEffect, useRef } from 'react'
|
||||
import { NavLink, useLocation } from 'react-router'
|
||||
import { Icon } from '../ui/Icon.tsx'
|
||||
import { navItems } from './navItems.ts'
|
||||
import styles from './BottomBar.module.css'
|
||||
|
||||
// The handoff's Motion §1 timing. SPRING is the --ease-spring token's value.
|
||||
const SLIDE_MS = 420
|
||||
const SPRING = 'cubic-bezier(.34, 1.4, .64, 1)'
|
||||
const LABEL_MS = 220
|
||||
const LABEL_DELAY_MS = 120
|
||||
const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)'
|
||||
|
||||
// The active item and every item's layout offset within the bar when the pill
|
||||
// was last placed (offsets ignore transforms, so a running FLIP does not skew
|
||||
// them).
|
||||
type Geometry = {
|
||||
active: HTMLElement | null
|
||||
itemLefts: number[]
|
||||
}
|
||||
|
||||
// Mobile nav: a dark pill bar floating over the bottom of the screen. Inactive
|
||||
// items show only their icon; the active item adds its label. One shared
|
||||
// grape pill sits behind the items and slides to the active one.
|
||||
export function BottomBar() {
|
||||
const barRef = useRef<HTMLElement>(null)
|
||||
const pillRef = useRef<HTMLSpanElement>(null)
|
||||
const placed = useRef<Geometry | null>(null)
|
||||
// The pill slide and item FLIPs currently running.
|
||||
const sliding = useRef<Animation[]>([])
|
||||
const stopSlide = () => {
|
||||
sliding.current.forEach((animation) => animation.cancel())
|
||||
sliding.current = []
|
||||
}
|
||||
const { pathname } = useLocation()
|
||||
|
||||
// After each navigation: if the active item changed, move the pill to it and,
|
||||
// motion allowed, slide the pill, FLIP the items and fade the label in. A
|
||||
// slide still running is measured where it is on screen, then stopped, so
|
||||
// the next one starts from there instead of from its destination.
|
||||
useLayoutEffect(() => {
|
||||
const bar = barRef.current!
|
||||
const pill = pillRef.current!
|
||||
const active = activeItem(bar)
|
||||
const before = placed.current
|
||||
if (before && before.active === active) return
|
||||
const from = before && onScreen(bar, pill, before)
|
||||
stopSlide()
|
||||
placed.current = place(bar, pill, active)
|
||||
if (!before || !from || !active || window.matchMedia(REDUCED_MOTION_QUERY).matches) return
|
||||
|
||||
const after = placed.current
|
||||
if (before.active) {
|
||||
sliding.current.push(
|
||||
pill.animate(
|
||||
[
|
||||
{ left: `${from.pill.left}px`, width: `${from.pill.width}px` },
|
||||
{ left: `${active.offsetLeft}px`, width: `${active.offsetWidth}px` },
|
||||
],
|
||||
{ duration: SLIDE_MS, easing: SPRING },
|
||||
),
|
||||
)
|
||||
}
|
||||
items(bar).forEach((item, i) => {
|
||||
const dx = from.itemLefts[i]! - after.itemLefts[i]!
|
||||
if (dx === 0) return
|
||||
sliding.current.push(
|
||||
item.animate([{ transform: `translateX(${dx}px)` }, { transform: 'translateX(0)' }], {
|
||||
duration: SLIDE_MS,
|
||||
easing: SPRING,
|
||||
}),
|
||||
)
|
||||
})
|
||||
active.querySelector(`.${styles.label}`)!.animate(
|
||||
[
|
||||
{ opacity: 0, transform: 'translateX(-4px)' },
|
||||
{ opacity: 1, transform: 'none' },
|
||||
],
|
||||
{ duration: LABEL_MS, delay: LABEL_DELAY_MS, easing: 'ease-out', fill: 'backwards' },
|
||||
)
|
||||
}, [pathname])
|
||||
|
||||
// Item widths change with the viewport and once the web fonts arrive, so
|
||||
// stop any running slide and re-place the pill (without animating) on both. A failed font load
|
||||
// rejects fonts.ready and is left to surface as an unhandled rejection.
|
||||
useLayoutEffect(() => {
|
||||
const bar = barRef.current!
|
||||
const pill = pillRef.current!
|
||||
let mounted = true
|
||||
const replace = () => {
|
||||
if (!mounted) return
|
||||
stopSlide()
|
||||
placed.current = place(bar, pill, activeItem(bar))
|
||||
}
|
||||
window.addEventListener('resize', replace)
|
||||
// jsdom has no document.fonts; every browser the app targets does.
|
||||
if (document.fonts) void document.fonts.ready.then(replace)
|
||||
return () => {
|
||||
mounted = false
|
||||
window.removeEventListener('resize', replace)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<nav ref={barRef} aria-label="Primary" className={styles.bar}>
|
||||
<span ref={pillRef} className={styles.pill} aria-hidden="true" />
|
||||
{navItems.map((item) => (
|
||||
<NavLink key={item.to} to={item.to} end={item.end} className={styles.item}>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<Icon icon={item.icon} size="nav" />
|
||||
<span className={isActive ? styles.label : styles.hiddenLabel}>{item.label}</span>
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
function items(bar: HTMLElement): HTMLElement[] {
|
||||
return Array.from(bar.querySelectorAll<HTMLElement>(`.${styles.item}`))
|
||||
}
|
||||
|
||||
// NavLink's aria-current is the one record of which item is active.
|
||||
function activeItem(bar: HTMLElement): HTMLElement | null {
|
||||
return bar.querySelector<HTMLElement>(`.${styles.item}[aria-current="page"]`)
|
||||
}
|
||||
|
||||
// Where the pill and items are on screen right now, as offsets within the
|
||||
// bar. Bounding boxes include a running slide: the pill's box is its animated
|
||||
// left and width, and an item's box is its current layout plus whatever FLIP
|
||||
// translation remains, which applies to the layout it had when last placed.
|
||||
function onScreen(bar: HTMLElement, pill: HTMLElement, before: Geometry) {
|
||||
const origin = bar.getBoundingClientRect().left + bar.clientLeft
|
||||
const pillBox = pill.getBoundingClientRect()
|
||||
return {
|
||||
pill: { left: pillBox.left - origin, width: pillBox.width },
|
||||
itemLefts: items(bar).map(
|
||||
(item, i) => before.itemLefts[i]! + item.getBoundingClientRect().left - origin - item.offsetLeft,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// Puts the pill on the active item (hidden when no item is active) and
|
||||
// returns the resulting geometry.
|
||||
function place(bar: HTMLElement, pill: HTMLElement, active: HTMLElement | null): Geometry {
|
||||
pill.hidden = !active
|
||||
pill.style.left = `${active?.offsetLeft ?? 0}px`
|
||||
pill.style.width = `${active?.offsetWidth ?? 0}px`
|
||||
return { active, itemLefts: items(bar).map((item) => item.offsetLeft) }
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
.rail {
|
||||
width: 96px;
|
||||
padding: 24px 0;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.mark {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: var(--color-accent);
|
||||
color: var(--on-accent);
|
||||
font-family: var(--font-heading);
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-width: 56px;
|
||||
min-height: 44px;
|
||||
color: var(--color-neutral-700);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.pill {
|
||||
width: 56px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
.active {
|
||||
background: var(--color-accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NavLink } from 'react-router'
|
||||
import { Icon } from '../ui/Icon.tsx'
|
||||
import { navItems } from './navItems.ts'
|
||||
import styles from './Rail.module.css'
|
||||
|
||||
// Tablet nav: a narrow icon rail with the "M" mark on top.
|
||||
export function Rail() {
|
||||
return (
|
||||
<nav aria-label="Primary" className={styles.rail}>
|
||||
<div className={styles.mark} aria-hidden="true">
|
||||
M
|
||||
</div>
|
||||
{navItems.map((item) => (
|
||||
<NavLink key={item.to} to={item.to} end={item.end} className={styles.item}>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<span className={isActive ? `${styles.pill} ${styles.active}` : styles.pill}>
|
||||
<Icon icon={item.icon} size="nav" />
|
||||
</span>
|
||||
{item.label}
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
.sidebar {
|
||||
width: 208px;
|
||||
padding: 28px 16px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.wordmark {
|
||||
margin: 0;
|
||||
padding: 0 12px 22px;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 28px;
|
||||
font-weight: 400;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-height: 44px;
|
||||
padding: 12px 16px;
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--radius-pill);
|
||||
color: var(--color-neutral-800);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.active {
|
||||
background: var(--color-accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
.household {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border-radius: 22px;
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: var(--color-accent-2-300);
|
||||
color: var(--color-accent-2-900);
|
||||
font-family: var(--font-heading);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.householdText {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 13px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.householdNote {
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { NavLink } from 'react-router'
|
||||
import { Icon } from '../ui/Icon.tsx'
|
||||
import { navItems } from './navItems.ts'
|
||||
import styles from './Sidebar.module.css'
|
||||
|
||||
// Desktop nav: the MealQuest wordmark, labelled pill rows and the shared
|
||||
// household card at the foot.
|
||||
export function Sidebar() {
|
||||
return (
|
||||
<aside className={styles.sidebar}>
|
||||
<p className={styles.wordmark}>MealQuest</p>
|
||||
<nav aria-label="Primary" className={styles.nav}>
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
className={({ isActive }) => (isActive ? `${styles.item} ${styles.active}` : styles.item)}
|
||||
>
|
||||
<Icon icon={item.icon} size="nav" />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
<div className={styles.household}>
|
||||
<span className={styles.avatar} aria-hidden="true">
|
||||
K
|
||||
</span>
|
||||
<p className={styles.householdText}>
|
||||
<strong>Our kitchen</strong>
|
||||
<span className={styles.householdNote}>Shared household</span>
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { Calendar, Compass, House, SlidersHorizontal, Zap } from 'lucide-react'
|
||||
|
||||
export type NavItem = {
|
||||
to: string
|
||||
label: string
|
||||
icon: LucideIcon
|
||||
// Match the path exactly; otherwise nested paths (/plan/shopping,
|
||||
// /settings/ai) also mark the item current.
|
||||
end: boolean
|
||||
}
|
||||
|
||||
// The primary nav, in the same order in every layout.
|
||||
export const navItems: readonly NavItem[] = [
|
||||
{ to: '/', label: 'Home', icon: House, end: true },
|
||||
{ to: '/plan', label: 'Plan', icon: Calendar, end: false },
|
||||
{ to: '/discover', label: 'Discover', icon: Compass, end: false },
|
||||
{ to: '/quick', label: 'Quick', icon: Zap, end: false },
|
||||
{ to: '/settings', label: 'Settings', icon: SlidersHorizontal, end: false },
|
||||
]
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useCallback, useState, useSyncExternalStore } from 'react'
|
||||
|
||||
export type Breakpoint = 'mobile' | 'tablet' | 'desktop'
|
||||
|
||||
// The handoff's three layouts: mobile < 768 px, tablet 768–1199 px,
|
||||
// desktop ≥ 1200 px.
|
||||
export const TABLET_QUERY = '(min-width: 768px)'
|
||||
export const DESKTOP_QUERY = '(min-width: 1200px)'
|
||||
|
||||
// The layout the current viewport calls for, updated as the viewport crosses
|
||||
// a breakpoint.
|
||||
export function useBreakpoint(): Breakpoint {
|
||||
const [lists] = useState(() => ({
|
||||
tablet: window.matchMedia(TABLET_QUERY),
|
||||
desktop: window.matchMedia(DESKTOP_QUERY),
|
||||
}))
|
||||
const subscribe = useCallback(
|
||||
(onChange: () => void) => {
|
||||
lists.tablet.addEventListener('change', onChange)
|
||||
lists.desktop.addEventListener('change', onChange)
|
||||
return () => {
|
||||
lists.tablet.removeEventListener('change', onChange)
|
||||
lists.desktop.removeEventListener('change', onChange)
|
||||
}
|
||||
},
|
||||
[lists],
|
||||
)
|
||||
return useSyncExternalStore(subscribe, () =>
|
||||
lists.desktop.matches ? 'desktop' : lists.tablet.matches ? 'tablet' : 'mobile',
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/* App-wide base styles. Values come only from tokens.css. */
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/* MealQuest design tokens — final palette "B4: Grape + teal blue on off-white".
|
||||
Built on the Organic design system (styles.css in this bundle); these values OVERRIDE its defaults. */
|
||||
/* Fonts are self-hosted from web/src/assets/fonts (OFL; licence texts alongside)
|
||||
in place of the handoff's Google Fonts @import, so the app never calls Google.
|
||||
Bagel Fat One: Google Fonts latin and latin-ext woff2 subsets.
|
||||
Figtree: upstream static woff2 (github.com/erikdkennedy/figtree). */
|
||||
@font-face {
|
||||
font-family: "Bagel Fat One";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("../assets/fonts/BagelFatOne-LatinExt.woff2") format("woff2");
|
||||
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Bagel Fat One";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("../assets/fonts/BagelFatOne-Latin.woff2") format("woff2");
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Figtree";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("../assets/fonts/Figtree-Regular.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Figtree";
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url("../assets/fonts/Figtree-SemiBold.woff2") format("woff2");
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Figtree";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("../assets/fonts/Figtree-Bold.woff2") format("woff2");
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Ground + ink */
|
||||
--color-bg: #f9f6f1; /* oklch(0.975 0.008 85) — page / phone background */
|
||||
--color-surface: #eee9de; /* oklch(0.935 0.016 85) — cards, rows, inputs */
|
||||
--color-text: #201e1d;
|
||||
--color-divider: color-mix(in srgb, #201e1d 16%, transparent);
|
||||
--on-accent: #f9f6f1; /* text/icons on --color-accent fills (never dark text on grape) */
|
||||
|
||||
/* Primary accent — Grape (hue 300) */
|
||||
--color-accent: #5a358c; /* oklch(0.42 0.14 300) */
|
||||
--color-accent-100: #f8f1ff;
|
||||
--color-accent-200: #eedfff;
|
||||
--color-accent-300: #dac3ff;
|
||||
--color-accent-400: #c1a1f9;
|
||||
--color-accent-500: #a47ee1;
|
||||
--color-accent-600: #865fc0;
|
||||
--color-accent-700: #68449b; /* accent-colored small text on light grounds */
|
||||
--color-accent-800: #4a2d73;
|
||||
--color-accent-900: #2d1a49;
|
||||
|
||||
/* Second accent — Teal blue (hue 215) */
|
||||
--color-accent-2: #29889c; /* oklch(0.58 0.09 215) */
|
||||
--color-accent-2-100: #e0fbff;
|
||||
--color-accent-2-200: #bbf3ff;
|
||||
--color-accent-2-300: #88e0f6;
|
||||
--color-accent-2-400: #3fc7e4;
|
||||
--color-accent-2-500: #00a9ca;
|
||||
--color-accent-2-600: #008aaa; /* toggles ON, "Try it" button, + buttons */
|
||||
--color-accent-2-700: #006b88;
|
||||
--color-accent-2-800: #004c63; /* teal text on teal-100/200 fills */
|
||||
--color-accent-2-900: #002f3e;
|
||||
|
||||
/* Neutrals (unchanged from Organic) */
|
||||
--color-neutral-100: #f9f4ed;
|
||||
--color-neutral-200: #eee7db;
|
||||
--color-neutral-300: #dcd3c4;
|
||||
--color-neutral-400: #c0b6a5;
|
||||
--color-neutral-500: #a19786;
|
||||
--color-neutral-600: #82796a;
|
||||
--color-neutral-700: #645c50;
|
||||
--color-neutral-800: #474238;
|
||||
--color-neutral-900: #2e2b25;
|
||||
|
||||
/* Type */
|
||||
--font-heading: "Bagel Fat One", system-ui, sans-serif; /* weight 400 only */
|
||||
--font-body: "Figtree", system-ui, sans-serif; /* 400 / 600 / 700 */
|
||||
--font-mono: ui-monospace, Menlo, monospace;
|
||||
|
||||
/* Radii */
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 16px;
|
||||
--radius-lg: 28px;
|
||||
--radius-card: 32px; /* mobile hero cards */
|
||||
--radius-pill: 999px; /* buttons, inputs, tags, nav */
|
||||
|
||||
/* Spacing (Organic, density 1.1) */
|
||||
--space-1: 4.4px; --space-2: 8.8px; --space-3: 13.2px; --space-4: 17.6px; --space-6: 26.4px; --space-8: 35.2px;
|
||||
|
||||
/* Elevation */
|
||||
--shadow-sm: 0 1px 2px color-mix(in srgb, #2e2b25 14%, transparent);
|
||||
--shadow-md: 0 3px 10px color-mix(in srgb, #2e2b25 16%, transparent);
|
||||
--shadow-lg: 0 12px 32px color-mix(in srgb, #2e2b25 22%, transparent);
|
||||
|
||||
/* Motion */
|
||||
--ease-spring: cubic-bezier(.34, 1.4, .64, 1); /* nav pill, snap-back, bumps */
|
||||
--ease-out-soft: cubic-bezier(.2, .8, .2, 1); /* card promotion */
|
||||
--ease-throw: cubic-bezier(.55, 0, .8, .4); /* fly-off right */
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:root { --motion-scale: 0; }
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// MealQuest service worker. vite-plugin-pwa replaces self.__WB_MANIFEST with
|
||||
// the build's precache list (shell, JS, CSS and fonts, each with a revision).
|
||||
// Every other request goes through the route src/sw/routes.ts decides.
|
||||
// Fonts are precached too, but their route is registered ahead of the
|
||||
// precache route, so a font fetch goes through the expiring CacheFirst
|
||||
// strategy and falls back to the precached copy when the network fails.
|
||||
|
||||
import { cacheNames as workboxCacheNames, setCacheNameDetails } from 'workbox-core'
|
||||
import type { RouteHandlerCallbackOptions } from 'workbox-core/types'
|
||||
import { ExpirationPlugin } from 'workbox-expiration'
|
||||
import { PrecacheFallbackPlugin, matchPrecache, precacheAndRoute } from 'workbox-precaching'
|
||||
import { registerRoute } from 'workbox-routing'
|
||||
import { CacheFirst, NetworkFirst, NetworkOnly, type Strategy } from 'workbox-strategies'
|
||||
import {
|
||||
cacheNames,
|
||||
cacheVersion,
|
||||
networkTimeoutSeconds,
|
||||
routeFor,
|
||||
staleCaches,
|
||||
type Route,
|
||||
} from './sw/routes'
|
||||
|
||||
declare const self: ServiceWorkerGlobalScope & {
|
||||
__WB_MANIFEST: Array<{ url: string; revision: string | null }>
|
||||
}
|
||||
|
||||
// The precache becomes mealquest-precache-v1.
|
||||
setCacheNameDetails({ prefix: 'mealquest', precache: 'precache', suffix: cacheVersion })
|
||||
|
||||
const day = 24 * 60 * 60
|
||||
|
||||
const strategies = {
|
||||
[cacheNames.api]: new NetworkFirst({ cacheName: cacheNames.api, networkTimeoutSeconds }),
|
||||
[cacheNames.media]: new CacheFirst({
|
||||
cacheName: cacheNames.media,
|
||||
plugins: [
|
||||
new ExpirationPlugin({ maxEntries: 200, maxAgeSeconds: 30 * day, purgeOnQuotaError: true }),
|
||||
],
|
||||
}),
|
||||
[cacheNames.fonts]: new CacheFirst({
|
||||
cacheName: cacheNames.fonts,
|
||||
plugins: [
|
||||
new ExpirationPlugin({ maxEntries: 20, maxAgeSeconds: 365 * day, purgeOnQuotaError: true }),
|
||||
// Offline with no runtime copy yet: the font from the precache.
|
||||
{ handlerDidError: async ({ request }) => matchPrecache(request.url) },
|
||||
],
|
||||
}),
|
||||
}
|
||||
const networkOnly = new NetworkOnly()
|
||||
const navigation = new NetworkOnly({
|
||||
plugins: [new PrecacheFallbackPlugin({ fallbackURL: 'index.html' })],
|
||||
})
|
||||
|
||||
function strategyFor(route: Route): Strategy {
|
||||
switch (route.strategy) {
|
||||
case 'network-first':
|
||||
case 'cache-first':
|
||||
return strategies[route.cacheName]
|
||||
case 'network-only':
|
||||
return networkOnly
|
||||
case 'navigation-fallback':
|
||||
return navigation
|
||||
}
|
||||
}
|
||||
|
||||
function routeOf({ request, url }: { request: Request; url: URL }): Route {
|
||||
return routeFor({ method: request.method, url, mode: request.mode }, self.location.origin)
|
||||
}
|
||||
|
||||
function handle({ request, url, event }: RouteHandlerCallbackOptions): Promise<Response> {
|
||||
return strategyFor(routeOf({ request, url })).handle({ request, event })
|
||||
}
|
||||
|
||||
function isFont(options: { request: Request; url: URL }): boolean {
|
||||
const route = routeOf(options)
|
||||
return route.strategy === 'cache-first' && route.cacheName === cacheNames.fonts
|
||||
}
|
||||
|
||||
// Workbox uses the first route that matches, so the font route comes before
|
||||
// the precache route, which would otherwise answer every precached font.
|
||||
registerRoute(isFont, handle, 'GET')
|
||||
precacheAndRoute(self.__WB_MANIFEST)
|
||||
|
||||
// Workbox routes are per method; every method goes through routeFor, which
|
||||
// sends every non-GET to the network only.
|
||||
for (const method of ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'] as const) {
|
||||
registerRoute(() => true, handle, method)
|
||||
}
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
const current = [workboxCacheNames.precache, ...Object.values(cacheNames)]
|
||||
event.waitUntil(
|
||||
caches
|
||||
.keys()
|
||||
.then((names) => Promise.all(staleCaches(names, current).map((name) => caches.delete(name)))),
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,157 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { cacheNames, routeFor, staleCaches, type Route } from './routes'
|
||||
|
||||
const origin = 'https://mealquest.test'
|
||||
|
||||
function route(path: string, method = 'GET', mode: RequestMode = 'cors'): Route {
|
||||
return routeFor({ method, url: new URL(path, origin), mode }, origin)
|
||||
}
|
||||
|
||||
const apiCache: Route = { strategy: 'network-first', cacheName: cacheNames.api }
|
||||
const networkOnly: Route = { strategy: 'network-only' }
|
||||
|
||||
describe('routeFor', () => {
|
||||
it.each([
|
||||
'/api/v1/plans',
|
||||
'/api/v1/plans/2026-W40',
|
||||
'/api/v1/shopping/current',
|
||||
'/api/v1/dinners',
|
||||
'/api/v1/dinners/01J9ZK?include=recipe',
|
||||
'/api/v1/home',
|
||||
])('GET %s is network-first in the API cache', (path) => {
|
||||
expect(route(path)).toEqual(apiCache)
|
||||
})
|
||||
|
||||
it.each(['/media/photos/abc.jpg', '/media/thumbs/abc.webp'])(
|
||||
'GET %s is cache-first in the media cache',
|
||||
(path) => {
|
||||
expect(route(path)).toEqual({ strategy: 'cache-first', cacheName: cacheNames.media })
|
||||
},
|
||||
)
|
||||
|
||||
it('GET of a font file is cache-first in the fonts cache', () => {
|
||||
expect(route('/assets/Figtree-Bold-a1b2c3.woff2')).toEqual({
|
||||
strategy: 'cache-first',
|
||||
cacheName: cacheNames.fonts,
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
'/auth/login',
|
||||
'/auth/callback?code=x&state=y',
|
||||
'/auth/logout',
|
||||
'/auth/me',
|
||||
'/api/v1/settings',
|
||||
'/api/v1/settings/ai',
|
||||
'/api/v1/jobs',
|
||||
'/api/v1/jobs/01J9ZK',
|
||||
])('GET %s is network-only', (path) => {
|
||||
expect(route(path)).toEqual(networkOnly)
|
||||
})
|
||||
|
||||
it.each(['/auth/login', '/auth/callback?code=x&state=y', '/api/v1/settings/ai'])(
|
||||
'a navigation to %s is network-only with no offline fallback',
|
||||
(path) => {
|
||||
expect(route(path, 'GET', 'navigate')).toEqual(networkOnly)
|
||||
},
|
||||
)
|
||||
|
||||
it.each([
|
||||
'/%61uth/login',
|
||||
'/auth%2Fcallback?code=x&state=y',
|
||||
'/%61pi/v1/settings/household',
|
||||
'/api/v1/%73ettings/ai',
|
||||
'/api/v1/settings%2Fai',
|
||||
'/%61pi/v1/jobs/01J9ZK',
|
||||
'/api/v1/j%6Fbs',
|
||||
'/api/v1/plans%2F..%2Fsettings',
|
||||
'/plan%2F..%2Fauth%2Flogin',
|
||||
])('an encoded reserved path %s is network-only, navigated or not', (path) => {
|
||||
expect(route(path)).toEqual(networkOnly)
|
||||
expect(route(path, 'GET', 'navigate')).toEqual(networkOnly)
|
||||
})
|
||||
|
||||
it.each(['/%E0%A4%A', '/auth%', '/plan/%zz', '/api/v1/plans/%C3'])(
|
||||
'a malformed encoding in %s is network-only, navigated or not',
|
||||
(path) => {
|
||||
expect(route(path)).toEqual(networkOnly)
|
||||
expect(route(path, 'GET', 'navigate')).toEqual(networkOnly)
|
||||
},
|
||||
)
|
||||
|
||||
it('classifies other API paths by their decoded form', () => {
|
||||
expect(route('/%61pi/v1/plans/2026-W40')).toEqual(apiCache)
|
||||
expect(route('/%61pi/v1/recipes', 'GET', 'navigate')).toEqual(networkOnly)
|
||||
})
|
||||
|
||||
it.each(['POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'])(
|
||||
'%s is network-only on every URL class',
|
||||
(method) => {
|
||||
for (const path of [
|
||||
'/api/v1/plans/2026-W40',
|
||||
'/api/v1/shopping/current',
|
||||
'/api/v1/dinners/01J9ZK',
|
||||
'/api/v1/home',
|
||||
'/media/photos/abc.jpg',
|
||||
'/assets/Figtree-Bold-a1b2c3.woff2',
|
||||
'/auth/logout',
|
||||
'/plan',
|
||||
]) {
|
||||
expect(route(path, method), `${method} ${path}`).toEqual(networkOnly)
|
||||
expect(route(path, method, 'navigate'), `${method} ${path}`).toEqual(networkOnly)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
it('matches whole path segments only', () => {
|
||||
expect(route('/api/v1/plansx')).toEqual(networkOnly)
|
||||
expect(route('/api/v1/homepage')).toEqual(networkOnly)
|
||||
expect(route('/mediax/a.jpg')).toEqual(networkOnly)
|
||||
expect(route('/authors')).toEqual(networkOnly)
|
||||
expect(route('/api/v1/settingsx')).toEqual(networkOnly)
|
||||
})
|
||||
|
||||
it('leaves other API calls network-only', () => {
|
||||
expect(route('/api/v1/recipes')).toEqual(networkOnly)
|
||||
expect(route('/api/v1/ideas', 'GET', 'navigate')).toEqual(networkOnly)
|
||||
})
|
||||
|
||||
it('leaves cross-origin requests network-only', () => {
|
||||
for (const path of ['/api/v1/plans', '/media/a.jpg', '/assets/a.woff2']) {
|
||||
const url = new URL(path, 'https://cdn.example')
|
||||
expect(routeFor({ method: 'GET', url, mode: 'no-cors' }, origin)).toEqual(networkOnly)
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['/', '/plan', '/supply-run', '/dinners/01J9ZK'])(
|
||||
'a navigation to the app at %s falls back to the precached index.html',
|
||||
(path) => {
|
||||
expect(route(path, 'GET', 'navigate')).toEqual({ strategy: 'navigation-fallback' })
|
||||
},
|
||||
)
|
||||
|
||||
it('leaves other same-origin GETs network-only', () => {
|
||||
expect(route('/health')).toEqual(networkOnly)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cache names', () => {
|
||||
it('all start with mealquest-', () => {
|
||||
for (const name of Object.values(cacheNames)) expect(name).toMatch(/^mealquest-/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('staleCaches', () => {
|
||||
it('deletes only MealQuest caches that are not current', () => {
|
||||
const current = ['mealquest-precache-v1', ...Object.values(cacheNames)]
|
||||
const names = [
|
||||
...current,
|
||||
'mealquest-precache',
|
||||
'mealquest-api-v0',
|
||||
'workbox-precache-v2-https://mealquest.test/',
|
||||
'other-app-cache',
|
||||
'my-mealquest-cache',
|
||||
]
|
||||
expect(staleCaches(names, current)).toEqual(['mealquest-precache', 'mealquest-api-v0'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,96 @@
|
||||
// Decides how the service worker handles each request. This module is pure —
|
||||
// no Workbox, no service worker globals — so the tests can assert every URL
|
||||
// class without a worker; src/sw.ts maps each route to a Workbox strategy.
|
||||
//
|
||||
// Security rule: /auth/*, /api/v1/settings/*, /api/v1/jobs/* and every
|
||||
// non-GET request are network-only and never reach a cache. Paths are matched
|
||||
// as the server sees them — percent-decoded, before and after dot segments are
|
||||
// resolved — so /%61uth/login is /auth/login; a malformed encoding is
|
||||
// network-only.
|
||||
|
||||
export type Route =
|
||||
// Network with a 3 s timeout, then the last cached response.
|
||||
| { strategy: 'network-first'; cacheName: typeof cacheNames.api }
|
||||
// Cached response when there is one, else the network; entries expire.
|
||||
| { strategy: 'cache-first'; cacheName: typeof cacheNames.media | typeof cacheNames.fonts }
|
||||
// Network only, nothing cached.
|
||||
| { strategy: 'network-only' }
|
||||
// Network only, nothing cached; offline, the precached index.html.
|
||||
| { strategy: 'navigation-fallback' }
|
||||
|
||||
export interface RouteRequest {
|
||||
method: string
|
||||
url: URL
|
||||
mode: RequestMode
|
||||
}
|
||||
|
||||
// Bumping the version renames every cache; activate then deletes the old ones.
|
||||
export const cacheVersion = 'v1'
|
||||
export const cachePrefix = 'mealquest-'
|
||||
|
||||
export const cacheNames = {
|
||||
api: `${cachePrefix}api-${cacheVersion}`,
|
||||
media: `${cachePrefix}media-${cacheVersion}`,
|
||||
fonts: `${cachePrefix}fonts-${cacheVersion}`,
|
||||
} as const
|
||||
|
||||
export const networkTimeoutSeconds = 3
|
||||
|
||||
const neverCached = ['/auth', '/api/v1/settings', '/api/v1/jobs']
|
||||
const networkFirst = ['/api/v1/plans', '/api/v1/shopping', '/api/v1/dinners', '/api/v1/home']
|
||||
|
||||
// True when path is prefix itself or a path below it: /api/v1/plans matches
|
||||
// /api/v1/plans and /api/v1/plans/2026-W40, never /api/v1/plansx.
|
||||
function under(path: string, prefix: string): boolean {
|
||||
return path === prefix || path.startsWith(`${prefix}/`)
|
||||
}
|
||||
|
||||
// The pathname percent-decoded, as the server's router sees it, or undefined
|
||||
// when the encoding is malformed.
|
||||
function decodePath(pathname: string): string | undefined {
|
||||
try {
|
||||
return decodeURIComponent(pathname)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
// Resolves "." and ".." segments the way the server's path.Clean does, so a
|
||||
// decoded %2F cannot carry a request out of a reserved prefix:
|
||||
// /api/v1/plans/../settings cleans to /api/v1/settings.
|
||||
function cleanPath(path: string): string {
|
||||
const segments: string[] = []
|
||||
for (const segment of path.split('/')) {
|
||||
if (segment === '' || segment === '.') continue
|
||||
if (segment === '..') segments.pop()
|
||||
else segments.push(segment)
|
||||
}
|
||||
return `/${segments.join('/')}`
|
||||
}
|
||||
|
||||
const networkOnly: Route = { strategy: 'network-only' }
|
||||
|
||||
export function routeFor(request: RouteRequest, origin: string): Route {
|
||||
const { method, url, mode } = request
|
||||
if (method !== 'GET') return networkOnly
|
||||
if (url.origin !== origin) return networkOnly
|
||||
const path = decodePath(url.pathname)
|
||||
if (path === undefined) return networkOnly
|
||||
const cleaned = cleanPath(path)
|
||||
const reserved = (prefix: string) => under(path, prefix) || under(cleaned, prefix)
|
||||
if (neverCached.some(reserved)) return networkOnly
|
||||
if (networkFirst.some((prefix) => under(path, prefix))) {
|
||||
return { strategy: 'network-first', cacheName: cacheNames.api }
|
||||
}
|
||||
if (under(path, '/media')) return { strategy: 'cache-first', cacheName: cacheNames.media }
|
||||
if (path.endsWith('.woff2')) return { strategy: 'cache-first', cacheName: cacheNames.fonts }
|
||||
if (reserved('/api')) return networkOnly
|
||||
if (mode === 'navigate') return { strategy: 'navigation-fallback' }
|
||||
return networkOnly
|
||||
}
|
||||
|
||||
// The caches activate deletes: MealQuest caches that are no longer current.
|
||||
// Caches without the mealquest- prefix belong to someone else and are kept.
|
||||
export function staleCaches(names: readonly string[], current: readonly string[]): string[] {
|
||||
return names.filter((name) => name.startsWith(cachePrefix) && !current.includes(name))
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// The service worker itself needs worker globals, so these tests read its
|
||||
// source: they pin the parts routes.ts cannot express — the font strategy's
|
||||
// expiration and the font route's place ahead of the precache route.
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import source from '../sw.ts?raw'
|
||||
|
||||
describe('service worker source', () => {
|
||||
it('gives the fonts cache a CacheFirst strategy with an expiration plugin', () => {
|
||||
expect(source).toMatch(
|
||||
/\[cacheNames\.fonts\]: new CacheFirst\(\{\s*cacheName: cacheNames\.fonts,\s*plugins: \[\s*new ExpirationPlugin\(\{ maxEntries: \d+, maxAgeSeconds: [^}]+\}\)/,
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to the precached font when the network fails', () => {
|
||||
expect(source).toContain('handlerDidError: async ({ request }) => matchPrecache(request.url)')
|
||||
})
|
||||
|
||||
it('registers the font route before the precache route', () => {
|
||||
const fontRoute = source.indexOf("registerRoute(isFont, handle, 'GET')")
|
||||
const precache = source.indexOf('precacheAndRoute(self.__WB_MANIFEST)')
|
||||
expect(fontRoute).toBeGreaterThanOrEqual(0)
|
||||
expect(precache).toBeGreaterThan(fontRoute)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
// Reads the CSS Modules' rules straight from the stylesheets Vitest injects
|
||||
// (css: true). jsdom's getComputedStyle cannot answer two things these
|
||||
// primitives need checked: it resolves var() colours to transparent, and it
|
||||
// evaluates only "all" and "screen" media queries, so styles inside
|
||||
// @media (prefers-reduced-motion: reduce) never apply.
|
||||
|
||||
function cssRules(): CSSRule[] {
|
||||
return Array.from(document.styleSheets).flatMap((sheet) => Array.from(sheet.cssRules))
|
||||
}
|
||||
|
||||
function collect(element: Element, rules: CSSRule[], style: CSSStyleDeclaration) {
|
||||
for (const rule of rules) {
|
||||
if (!(rule instanceof CSSStyleRule) || !element.matches(rule.selectorText)) continue
|
||||
for (const property of Array.from(rule.style)) {
|
||||
style.setProperty(property, rule.style.getPropertyValue(property))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The declarations the stylesheets give the element, as written. Read the
|
||||
// shorthands the CSS uses (background, animation): jsdom does not expand
|
||||
// animation, nor any shorthand holding a var(). Later rules win; specificity
|
||||
// is not considered.
|
||||
export function declaredStyle(element: Element): CSSStyleDeclaration {
|
||||
const style = document.createElement('div').style
|
||||
collect(element, cssRules(), style)
|
||||
return style
|
||||
}
|
||||
|
||||
// Only the declarations @media (prefers-reduced-motion: reduce) gives the element.
|
||||
export function reducedMotionStyle(element: Element): CSSStyleDeclaration {
|
||||
const style = document.createElement('div').style
|
||||
for (const rule of cssRules()) {
|
||||
if (rule instanceof CSSMediaRule && rule.conditionText.includes('prefers-reduced-motion: reduce')) {
|
||||
collect(element, Array.from(rule.cssRules), style)
|
||||
}
|
||||
}
|
||||
return style
|
||||
}
|
||||
|
||||
// The @keyframes the element's animation runs. CSS Modules hash keyframe
|
||||
// names, so this follows the element's own animation-name.
|
||||
export function animationKeyframes(element: Element): CSSKeyframesRule {
|
||||
const [name] = declaredStyle(element).animation.split(' ')
|
||||
const keyframes = cssRules().find(
|
||||
(rule): rule is CSSKeyframesRule => rule instanceof CSSKeyframesRule && rule.name === name,
|
||||
)
|
||||
if (!keyframes) throw new Error(`no @keyframes named ${JSON.stringify(name)}`)
|
||||
return keyframes
|
||||
}
|
||||
|
||||
function px(value: string, what: string): number {
|
||||
if (value === '') return 0
|
||||
const match = /^(\d+(?:\.\d+)?)px$/.exec(value)
|
||||
if (!match) throw new Error(`${what} ${JSON.stringify(value)} is not a px length`)
|
||||
return Number(match[1])
|
||||
}
|
||||
|
||||
// The border-box size the element's declared rules give it on one axis:
|
||||
// width or height, plus padding and border unless box-sizing is border-box.
|
||||
// jsdom does no layout (getBoundingClientRect is all zeros), so this is the
|
||||
// way to catch padding that would push a fixed box past its specified size.
|
||||
export function outerSize(element: Element, axis: 'width' | 'height'): number {
|
||||
const style = declaredStyle(element)
|
||||
const size = px(style.getPropertyValue(axis), axis)
|
||||
if (style.boxSizing === 'border-box') return size
|
||||
const sides = axis === 'width' ? ['left', 'right'] : ['top', 'bottom']
|
||||
return sides.reduce(
|
||||
(total, side) =>
|
||||
total +
|
||||
px(style.getPropertyValue(`padding-${side}`), `padding-${side}`) +
|
||||
px(style.getPropertyValue(`border-${side}-width`), `border-${side}-width`),
|
||||
size,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { vi, type Mock } from 'vitest'
|
||||
import type { ApiLocation, Me } from '../api/index.ts'
|
||||
|
||||
export const ME: Me = {
|
||||
name: 'Sam',
|
||||
email: 'sam@example.com',
|
||||
household: { label: 'Our kitchen' },
|
||||
csrfToken: 'token-1',
|
||||
}
|
||||
|
||||
export function jsonResponse(status: number, body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })
|
||||
}
|
||||
|
||||
export function errorResponse(status: number, code: string, retryable = false): Response {
|
||||
return jsonResponse(status, { error: { code, message: `${code} message`, retryable } })
|
||||
}
|
||||
|
||||
// stubFetch replaces the global fetch with a mock answered from responses in
|
||||
// order; a request beyond them fails the test. Undo with vi.unstubAllGlobals.
|
||||
export function stubFetch(...responses: Response[]): Mock<typeof fetch> {
|
||||
const fetchMock = vi.fn<typeof fetch>()
|
||||
for (const response of responses) fetchMock.mockResolvedValueOnce(response)
|
||||
fetchMock.mockImplementation((input, init) => {
|
||||
throw new Error(`unexpected fetch: ${init?.method ?? 'GET'} ${String(input)}`)
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
return fetchMock
|
||||
}
|
||||
|
||||
// request returns the method, path and headers of the fetch mock's nth call.
|
||||
export function request(fetchMock: Mock<typeof fetch>, n: number) {
|
||||
const call = fetchMock.mock.calls[n]
|
||||
if (call === undefined) throw new Error(`fetch was called ${fetchMock.mock.calls.length} times, not ${n + 1}`)
|
||||
const [input, init] = call
|
||||
return {
|
||||
path: String(input),
|
||||
method: init?.method,
|
||||
credentials: init?.credentials,
|
||||
headers: new Headers(init?.headers),
|
||||
body: init?.body,
|
||||
}
|
||||
}
|
||||
|
||||
// fakeLocation stands in for window.location on https://mealquest.test,
|
||||
// recording navigations.
|
||||
export function fakeLocation(pathname = '/', search = ''): ApiLocation & { assign: Mock<(url: string | URL) => void> } {
|
||||
return { origin: 'https://mealquest.test', pathname, search, assign: vi.fn<(url: string | URL) => void>() }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { expect } from 'vitest'
|
||||
|
||||
// Minimum tap target from the handoff's accessibility rules.
|
||||
export const MIN_HIT_AREA = 44
|
||||
|
||||
function px(value: string): number {
|
||||
return value.endsWith('px') ? Number.parseFloat(value) : 0
|
||||
}
|
||||
|
||||
// The tap target an element's stylesheet guarantees: the larger of its
|
||||
// declared size and declared minimum size, per axis. jsdom does no layout, so
|
||||
// this reads the CSS Modules' computed values (Vitest runs with css: true).
|
||||
// A dimension the CSS leaves unsized counts as 0 and fails the check.
|
||||
export function hitArea(element: Element): { width: number; height: number } {
|
||||
const style = getComputedStyle(element)
|
||||
return {
|
||||
width: Math.max(px(style.width), px(style.minWidth)),
|
||||
height: Math.max(px(style.height), px(style.minHeight)),
|
||||
}
|
||||
}
|
||||
|
||||
export function expectMinHitArea(element: Element) {
|
||||
const { width, height } = hitArea(element)
|
||||
expect(width, 'hit area width').toBeGreaterThanOrEqual(MIN_HIT_AREA)
|
||||
expect(height, 'hit area height').toBeGreaterThanOrEqual(MIN_HIT_AREA)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { act } from '@testing-library/react'
|
||||
|
||||
type Listener = (event: MediaQueryListEvent) => void
|
||||
|
||||
// Replaces window.matchMedia with one that answers min-width / max-width
|
||||
// queries for a pretend viewport width, and prefers-reduced-motion: reduce
|
||||
// with reducedMotion (jsdom has no matchMedia and no layout). Returns a
|
||||
// function that resizes the pretend viewport and notifies every query whose
|
||||
// answer changed.
|
||||
export function mockViewport(initialWidth: number, { reducedMotion = false } = {}): (width: number) => void {
|
||||
let width = initialWidth
|
||||
const lists: { list: MediaQueryList; listeners: Set<Listener> }[] = []
|
||||
|
||||
function evaluate(query: string): boolean {
|
||||
if (query === '(prefers-reduced-motion: reduce)') return reducedMotion
|
||||
const min = /\(min-width:\s*(\d+)px\)/.exec(query)
|
||||
const max = /\(max-width:\s*(\d+)px\)/.exec(query)
|
||||
if (!min && !max) throw new Error(`mockViewport cannot evaluate ${JSON.stringify(query)}`)
|
||||
return (!min || width >= Number(min[1])) && (!max || width <= Number(max[1]))
|
||||
}
|
||||
|
||||
window.matchMedia = (query: string) => {
|
||||
const listeners = new Set<Listener>()
|
||||
const list = {
|
||||
media: query,
|
||||
get matches() {
|
||||
return evaluate(query)
|
||||
},
|
||||
onchange: null,
|
||||
addEventListener: (_type: string, listener: Listener) => listeners.add(listener),
|
||||
removeEventListener: (_type: string, listener: Listener) => listeners.delete(listener),
|
||||
addListener: (listener: Listener) => listeners.add(listener),
|
||||
removeListener: (listener: Listener) => listeners.delete(listener),
|
||||
dispatchEvent: () => true,
|
||||
} as unknown as MediaQueryList
|
||||
lists.push({ list, listeners })
|
||||
return list
|
||||
}
|
||||
|
||||
return (next: number) => {
|
||||
const before = lists.map(({ list }) => list.matches)
|
||||
width = next
|
||||
act(() => {
|
||||
lists.forEach(({ list, listeners }, i) => {
|
||||
if (list.matches === before[i]) return
|
||||
const event = { matches: list.matches, media: list.media } as MediaQueryListEvent
|
||||
listeners.forEach((listener) => listener(event))
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
min-width: 44px;
|
||||
min-height: 48px;
|
||||
padding: 0 22px;
|
||||
border: 1.5px solid transparent;
|
||||
border-radius: var(--radius-pill);
|
||||
font-family: var(--font-heading);
|
||||
font-size: 15px;
|
||||
font-weight: 400;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: transform 100ms var(--ease-out-soft), background-color 100ms var(--ease-out-soft);
|
||||
}
|
||||
|
||||
.button:active:not(:disabled) {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.primary {
|
||||
background: var(--color-accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
.primary:hover:not(:disabled),
|
||||
.primary:active:not(:disabled) {
|
||||
background: var(--color-accent-800);
|
||||
}
|
||||
|
||||
.outline {
|
||||
background: transparent;
|
||||
border-color: var(--color-neutral-300);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.outline:hover:not(:disabled),
|
||||
.outline:active:not(:disabled) {
|
||||
background: var(--color-neutral-200);
|
||||
}
|
||||
|
||||
.dark {
|
||||
background: var(--color-text);
|
||||
color: var(--color-bg);
|
||||
}
|
||||
|
||||
.dark:hover:not(:disabled),
|
||||
.dark:active:not(:disabled) {
|
||||
background: var(--color-neutral-800);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.button {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { createRef } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||
import { Button } from './Button.tsx'
|
||||
import styles from './Button.module.css'
|
||||
|
||||
describe('Button', () => {
|
||||
it('is a button that defaults to type="button" and the primary variant', () => {
|
||||
render(<Button>Save changes</Button>)
|
||||
const button = screen.getByRole('button', { name: 'Save changes' })
|
||||
expect(button).toHaveAttribute('type', 'button')
|
||||
expect(button).toHaveClass(styles.button!, styles.primary!)
|
||||
})
|
||||
|
||||
it.each(['primary', 'outline', 'dark'] as const)('renders the %s variant with a ≥ 44 px hit area', (variant) => {
|
||||
render(<Button variant={variant}>Go</Button>)
|
||||
const button = screen.getByRole('button', { name: 'Go' })
|
||||
expect(button).toHaveClass(styles[variant]!)
|
||||
expectMinHitArea(button)
|
||||
})
|
||||
|
||||
it('declares the 100 ms pressed scale transition', () => {
|
||||
render(<Button>Go</Button>)
|
||||
expect(getComputedStyle(screen.getByRole('button')).transition).toContain('transform 100ms')
|
||||
})
|
||||
|
||||
it('does not fire onClick when disabled', () => {
|
||||
const onClick = vi.fn()
|
||||
render(
|
||||
<Button disabled onClick={onClick}>
|
||||
Go
|
||||
</Button>,
|
||||
)
|
||||
const button = screen.getByRole('button', { name: 'Go' })
|
||||
expect(button).toBeDisabled()
|
||||
fireEvent.click(button)
|
||||
expect(onClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('forwards the ref, rest props and an explicit type', () => {
|
||||
const ref = createRef<HTMLButtonElement>()
|
||||
render(
|
||||
<Button ref={ref} type="submit" aria-describedby="hint">
|
||||
Send
|
||||
</Button>,
|
||||
)
|
||||
const button = screen.getByRole('button', { name: 'Send' })
|
||||
expect(ref.current).toBe(button)
|
||||
expect(button).toHaveAttribute('type', 'submit')
|
||||
expect(button).toHaveAttribute('aria-describedby', 'hint')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { ComponentPropsWithRef } from 'react'
|
||||
import styles from './Button.module.css'
|
||||
|
||||
export type ButtonVariant = 'primary' | 'outline' | 'dark'
|
||||
|
||||
export type ButtonProps = ComponentPropsWithRef<'button'> & {
|
||||
variant?: ButtonVariant
|
||||
}
|
||||
|
||||
// Pill button. Primary is grape with --on-accent text; outline and dark are the
|
||||
// secondary treatments from the handoff. `type` defaults to "button" so a
|
||||
// Button inside a form never submits by accident.
|
||||
export function Button({ variant = 'primary', type = 'button', className, ...rest }: ButtonProps) {
|
||||
const classes = [styles.button, styles[variant], className].filter(Boolean).join(' ')
|
||||
return <button type={type} className={classes} {...rest} />
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
.card {
|
||||
padding: var(--space-4);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.surface {
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.muted {
|
||||
background: var(--color-neutral-100);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Card } from './Card.tsx'
|
||||
import styles from './Card.module.css'
|
||||
|
||||
describe('Card', () => {
|
||||
it('renders its children on the surface fill by default', () => {
|
||||
render(<Card aria-label="Tonight">Tacos</Card>)
|
||||
const card = screen.getByLabelText('Tonight')
|
||||
expect(card).toHaveTextContent('Tacos')
|
||||
expect(card).toHaveClass(styles.card!, styles.surface!)
|
||||
})
|
||||
|
||||
it('supports the muted tone', () => {
|
||||
render(<Card data-testid="card" tone="muted" />)
|
||||
expect(screen.getByTestId('card')).toHaveClass(styles.muted!)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { ComponentPropsWithRef } from 'react'
|
||||
import styles from './Card.module.css'
|
||||
|
||||
export type CardTone = 'surface' | 'muted'
|
||||
|
||||
export type CardProps = ComponentPropsWithRef<'div'> & {
|
||||
// surface = --color-surface; muted = the secondary neutral-100 fill.
|
||||
tone?: CardTone
|
||||
}
|
||||
|
||||
export function Card({ tone = 'surface', className, ...rest }: CardProps) {
|
||||
const classes = [styles.card, styles[tone], className].filter(Boolean).join(' ')
|
||||
return <div className={classes} {...rest} />
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
.root {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.input {
|
||||
appearance: none;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
flex: none;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
margin: 0;
|
||||
border: 2px solid var(--color-neutral-400);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* The check mark. */
|
||||
.input::before {
|
||||
content: "";
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: var(--on-accent);
|
||||
clip-path: polygon(14% 44%, 0 59%, 40% 100%, 100% 21%, 85% 7%, 39% 69%);
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
transition: transform 160ms var(--ease-spring), opacity 160ms var(--ease-out-soft);
|
||||
}
|
||||
|
||||
.input:checked {
|
||||
border-color: var(--color-accent);
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
.input:checked::before {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.input:disabled,
|
||||
.input:disabled + .label {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-family: var(--font-body);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.input::before {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||
import { Checkbox } from './Checkbox.tsx'
|
||||
|
||||
describe('Checkbox', () => {
|
||||
it('is a native checkbox named by its label', () => {
|
||||
render(<Checkbox label="Chicken thighs" />)
|
||||
const box = screen.getByRole('checkbox', { name: 'Chicken thighs' })
|
||||
expect(box.tagName).toBe('INPUT')
|
||||
expect(box).toHaveAttribute('type', 'checkbox')
|
||||
expect(box).not.toBeChecked()
|
||||
})
|
||||
|
||||
it('toggles when the input or its label text is clicked', () => {
|
||||
render(<Checkbox label="Limes" />)
|
||||
const box = screen.getByRole('checkbox', { name: 'Limes' })
|
||||
fireEvent.click(box)
|
||||
expect(box).toBeChecked()
|
||||
fireEvent.click(screen.getByText('Limes'))
|
||||
expect(box).not.toBeChecked()
|
||||
})
|
||||
|
||||
it('reflects a controlled checked state and disabled', () => {
|
||||
render(<Checkbox label="Rice" checked disabled readOnly />)
|
||||
const box = screen.getByRole('checkbox', { name: 'Rice' })
|
||||
expect(box).toBeChecked()
|
||||
expect(box).toBeDisabled()
|
||||
})
|
||||
|
||||
it('has a ≥ 44 px hit area (the wrapping label)', () => {
|
||||
render(<Checkbox label="Rice" />)
|
||||
const hit = screen.getByRole('checkbox', { name: 'Rice' }).closest('label')!
|
||||
expectMinHitArea(hit)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { ComponentPropsWithRef, ReactNode } from 'react'
|
||||
import styles from './Checkbox.module.css'
|
||||
|
||||
export type CheckboxProps = Omit<ComponentPropsWithRef<'input'>, 'type'> & {
|
||||
label: ReactNode
|
||||
}
|
||||
|
||||
// Native checkbox, styled. The wrapping label is the hit area (at least
|
||||
// 44×44), so tapping the text toggles it too.
|
||||
export function Checkbox({ label, className, ...rest }: CheckboxProps) {
|
||||
return (
|
||||
<label className={[styles.root, className].filter(Boolean).join(' ')}>
|
||||
<input type="checkbox" className={styles.input} {...rest} />
|
||||
<span className={styles.label}>{label}</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 36px;
|
||||
padding: 0 14px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-accent-2-200);
|
||||
color: var(--color-accent-2-900);
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.removable {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
/* 44 px hit box around a small cross; negative margins keep the chip 36 px
|
||||
tall and the cross close to the label. */
|
||||
.remove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
margin: -4px 0 -4px -14px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--radius-pill);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||
import { Chip } from './Chip.tsx'
|
||||
|
||||
describe('Chip', () => {
|
||||
it('has no remove button unless onRemove is given', () => {
|
||||
render(<Chip label="Beef tacos" />)
|
||||
expect(screen.getByText('Beef tacos')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('exposes an accessible "Remove {label}" button that calls onRemove', () => {
|
||||
const onRemove = vi.fn()
|
||||
render(<Chip label="Beef tacos" onRemove={onRemove} />)
|
||||
const remove = screen.getByRole('button', { name: 'Remove Beef tacos' })
|
||||
expect(remove).toHaveAttribute('type', 'button')
|
||||
fireEvent.click(remove)
|
||||
expect(onRemove).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('gives the remove button a ≥ 44 px hit area', () => {
|
||||
render(<Chip label="Pizza night" onRemove={() => {}} />)
|
||||
expectMinHitArea(screen.getByRole('button', { name: 'Remove Pizza night' }))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ComponentPropsWithRef } from 'react'
|
||||
import styles from './Chip.module.css'
|
||||
|
||||
export type ChipProps = Omit<ComponentPropsWithRef<'span'>, 'children'> & {
|
||||
label: string
|
||||
// When set, the chip shows a "Remove {label}" button that calls it.
|
||||
onRemove?: () => void
|
||||
}
|
||||
|
||||
export function Chip({ label, onRemove, className, ...rest }: ChipProps) {
|
||||
const classes = [styles.chip, onRemove && styles.removable, className].filter(Boolean).join(' ')
|
||||
return (
|
||||
<span className={classes} {...rest}>
|
||||
{label}
|
||||
{onRemove && (
|
||||
<button type="button" className={styles.remove} aria-label={`Remove ${label}`} onClick={onRemove}>
|
||||
<svg viewBox="0 0 12 12" width="10" height="10" aria-hidden="true">
|
||||
<path d="M2 2l8 8M10 2l-8 8" stroke="currentColor" strokeWidth="2.75" strokeLinecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-8) var(--space-4);
|
||||
color: var(--color-text);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 24px;
|
||||
font-weight: 400;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.body {
|
||||
margin: 0;
|
||||
max-width: 32ch;
|
||||
font-family: var(--font-body);
|
||||
font-size: 15px;
|
||||
line-height: 1.45;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.action {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||
import { EmptyState } from './EmptyState.tsx'
|
||||
|
||||
describe('EmptyState', () => {
|
||||
it('shows a Bagel title and body without an action by default', () => {
|
||||
render(<EmptyState title="Quest log is empty">Swipe Try it on a Side quest to save it here.</EmptyState>)
|
||||
const title = screen.getByRole('heading', { name: 'Quest log is empty' })
|
||||
expect(getComputedStyle(title).fontFamily).toBe('var(--font-heading)')
|
||||
expect(screen.getByText('Swipe Try it on a Side quest to save it here.')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the optional primary action and calls its handler', () => {
|
||||
const onClick = vi.fn()
|
||||
render(
|
||||
<EmptyState title="No Regulars yet" action={{ label: 'Add a dinner', onClick }}>
|
||||
Add the dinners you make most.
|
||||
</EmptyState>,
|
||||
)
|
||||
const action = screen.getByRole('button', { name: 'Add a dinner' })
|
||||
expectMinHitArea(action)
|
||||
fireEvent.click(action)
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Button } from './Button.tsx'
|
||||
import styles from './EmptyState.module.css'
|
||||
|
||||
export type EmptyStateAction = {
|
||||
label: string
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
export type EmptyStateProps = {
|
||||
title: string
|
||||
children: ReactNode
|
||||
// Optional primary action, rendered as a primary Button.
|
||||
action?: EmptyStateAction
|
||||
}
|
||||
|
||||
// Bagel title, body text and an optional primary action.
|
||||
export function EmptyState({ title, children, action }: EmptyStateProps) {
|
||||
return (
|
||||
<section className={styles.empty}>
|
||||
<h2 className={styles.title}>{title}</h2>
|
||||
<p className={styles.body}>{children}</p>
|
||||
{action && (
|
||||
<Button className={styles.action} onClick={action.onClick}>
|
||||
{action.label}
|
||||
</Button>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
.banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
background: var(--color-neutral-100);
|
||||
}
|
||||
|
||||
.message {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
color: var(--color-accent-700);
|
||||
font-family: var(--font-body);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { declaredStyle } from '../test/cssRules.ts'
|
||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||
import { ErrorBanner } from './ErrorBanner.tsx'
|
||||
|
||||
describe('ErrorBanner', () => {
|
||||
it('announces the message and calls onRetry from the Retry button', () => {
|
||||
const onRetry = vi.fn()
|
||||
render(<ErrorBanner onRetry={onRetry}>Couldn't load the Supply run.</ErrorBanner>)
|
||||
expect(screen.getByRole('alert')).toHaveTextContent("Couldn't load the Supply run.")
|
||||
const retry = screen.getByRole('button', { name: 'Retry' })
|
||||
expectMinHitArea(retry)
|
||||
fireEvent.click(retry)
|
||||
expect(onRetry).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('uses a caller-supplied retry label', () => {
|
||||
render(
|
||||
<ErrorBanner onRetry={() => {}} retryLabel="Rebuild week">
|
||||
The plan didn't save.
|
||||
</ErrorBanner>,
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Rebuild week' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('is a neutral-100 card with grape-700 text', () => {
|
||||
render(<ErrorBanner onRetry={() => {}}>Offline</ErrorBanner>)
|
||||
expect(declaredStyle(screen.getByRole('alert')).background).toBe('var(--color-neutral-100)')
|
||||
expect(declaredStyle(screen.getByText('Offline')).color).toBe('var(--color-accent-700)')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Button } from './Button.tsx'
|
||||
import { Card } from './Card.tsx'
|
||||
import styles from './ErrorBanner.module.css'
|
||||
|
||||
export type ErrorBannerProps = {
|
||||
// What went wrong, in the caller's words.
|
||||
children: ReactNode
|
||||
onRetry: () => void
|
||||
retryLabel?: string
|
||||
}
|
||||
|
||||
// Muted neutral-100 card with grape-700 text and a Retry button.
|
||||
export function ErrorBanner({ children, onRetry, retryLabel = 'Retry' }: ErrorBannerProps) {
|
||||
return (
|
||||
<Card tone="muted" role="alert" className={styles.banner}>
|
||||
<p className={styles.message}>{children}</p>
|
||||
<Button onClick={onRetry}>{retryLabel}</Button>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
.icon {
|
||||
display: inline-block;
|
||||
flex: none;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.nav {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.inline {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { House } from 'lucide-react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Icon } from './Icon.tsx'
|
||||
|
||||
function svg(container: HTMLElement) {
|
||||
return container.querySelector('svg')!
|
||||
}
|
||||
|
||||
describe('Icon', () => {
|
||||
it('is hidden and unnamed without a label', () => {
|
||||
const { container } = render(<Icon icon={House} />)
|
||||
expect(svg(container)).toHaveAttribute('aria-hidden', 'true')
|
||||
expect(svg(container)).not.toHaveAttribute('aria-label')
|
||||
expect(screen.queryByRole('img')).toBeNull()
|
||||
})
|
||||
|
||||
it('is a named image when given a label', () => {
|
||||
const { container } = render(<Icon icon={House} label="Home" />)
|
||||
expect(svg(container)).not.toHaveAttribute('aria-hidden')
|
||||
expect(screen.getByRole('img', { name: 'Home' })).toBe(svg(container))
|
||||
})
|
||||
|
||||
it('draws at stroke width 2.75', () => {
|
||||
const { container } = render(<Icon icon={House} />)
|
||||
expect(svg(container)).toHaveAttribute('stroke-width', '2.75')
|
||||
})
|
||||
|
||||
it('is 22 px at the nav size', () => {
|
||||
const { container } = render(<Icon icon={House} size="nav" />)
|
||||
const style = getComputedStyle(svg(container))
|
||||
expect([style.width, style.height]).toEqual(['22px', '22px'])
|
||||
})
|
||||
|
||||
it('is 16 px inline by default', () => {
|
||||
const { container } = render(<Icon icon={House} />)
|
||||
const style = getComputedStyle(svg(container))
|
||||
expect([style.width, style.height]).toEqual(['16px', '16px'])
|
||||
})
|
||||
|
||||
it('merges a caller className', () => {
|
||||
const { container } = render(<Icon icon={House} className="extra" />)
|
||||
expect(svg(container)).toHaveClass('extra')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import styles from './Icon.module.css'
|
||||
|
||||
export type IconSize = 'nav' | 'inline'
|
||||
|
||||
export type IconProps = {
|
||||
// A lucide-react icon component, e.g. `import { House } from 'lucide-react'`.
|
||||
icon: LucideIcon
|
||||
// nav = 22 px (bottom bar, sidebar); inline = 16 px (beside text).
|
||||
size?: IconSize
|
||||
// Accessible name. Without one the icon is decorative and hidden.
|
||||
label?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
// Lucide icon at the handoff's stroke width 2.75, sized by the stylesheet.
|
||||
export function Icon({ icon: LucideGlyph, size = 'inline', label, className }: IconProps) {
|
||||
const classes = [styles.icon, styles[size], className].filter(Boolean).join(' ')
|
||||
const a11y = label === undefined ? { 'aria-hidden': true } : { role: 'img', 'aria-label': label }
|
||||
return <LucideGlyph strokeWidth={2.75} className={classes} {...a11y} />
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/* The button is the hit box; .face is the visible circle. A 40 px face sits in
|
||||
a 44 px hit box whose negative margin keeps the 40 px layout footprint. */
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: var(--radius-pill);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.size40 {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
margin: -2px;
|
||||
}
|
||||
|
||||
.size62 {
|
||||
width: 62px;
|
||||
height: 62px;
|
||||
}
|
||||
|
||||
.face {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-pill);
|
||||
transition: transform 100ms var(--ease-out-soft);
|
||||
}
|
||||
|
||||
.size40 .face {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.size62 .face {
|
||||
width: 62px;
|
||||
height: 62px;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.button:active:not(:disabled) .face {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.surface {
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.accent {
|
||||
background: var(--color-accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
.teal {
|
||||
background: var(--color-accent-2-600);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.face {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { hitArea, expectMinHitArea } from '../test/hitArea.ts'
|
||||
import { IconButton } from './IconButton.tsx'
|
||||
|
||||
describe('IconButton', () => {
|
||||
it('is named by its label, not its icon', () => {
|
||||
const onClick = vi.fn()
|
||||
render(
|
||||
<IconButton label="Notifications" onClick={onClick}>
|
||||
<svg />
|
||||
</IconButton>,
|
||||
)
|
||||
const button = screen.getByRole('button', { name: 'Notifications' })
|
||||
expect(button).toHaveAttribute('type', 'button')
|
||||
fireEvent.click(button)
|
||||
expect(onClick).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps a 44 px hit area around the 40 px circle', () => {
|
||||
render(<IconButton label="Back" size={40} />)
|
||||
const button = screen.getByRole('button', { name: 'Back' })
|
||||
expectMinHitArea(button)
|
||||
const face = button.firstElementChild!
|
||||
expect(hitArea(face)).toEqual({ width: 40, height: 40 })
|
||||
})
|
||||
|
||||
it('is 62 px at the large size', () => {
|
||||
render(<IconButton label="Try it" size={62} tone="teal" />)
|
||||
const button = screen.getByRole('button', { name: 'Try it' })
|
||||
expectMinHitArea(button)
|
||||
expect(hitArea(button)).toEqual({ width: 62, height: 62 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { ComponentPropsWithRef } from 'react'
|
||||
import styles from './IconButton.module.css'
|
||||
|
||||
export type IconButtonSize = 40 | 62
|
||||
export type IconButtonTone = 'surface' | 'accent' | 'teal'
|
||||
|
||||
export type IconButtonProps = Omit<ComponentPropsWithRef<'button'>, 'aria-label'> & {
|
||||
// Accessible name; an icon alone never names a button.
|
||||
label: string
|
||||
size?: IconButtonSize
|
||||
tone?: IconButtonTone
|
||||
}
|
||||
|
||||
// Round icon-only button. The 40 px size keeps a 44 px hit box (see the CSS);
|
||||
// the 62 px size is the round action button with --shadow-md.
|
||||
export function IconButton({
|
||||
label,
|
||||
size = 40,
|
||||
tone = 'surface',
|
||||
type = 'button',
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}: IconButtonProps) {
|
||||
const classes = [styles.button, styles[`size${size}`], className].filter(Boolean).join(' ')
|
||||
return (
|
||||
<button type={type} aria-label={label} className={classes} {...rest}>
|
||||
<span className={[styles.face, styles[tone]].join(' ')} aria-hidden="true">
|
||||
{children}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
.row {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
padding: 14px 16px;
|
||||
color: var(--color-text);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.interactive {
|
||||
padding: 0;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.interactive:hover {
|
||||
background: var(--color-neutral-200);
|
||||
}
|
||||
|
||||
.main {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
flex: 1 1 auto;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
padding: 14px 16px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.interactive > .trailing {
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
.leading,
|
||||
.trailing {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-family: var(--font-body);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||
import { ListRow } from './ListRow.tsx'
|
||||
import { Toggle } from './Toggle.tsx'
|
||||
|
||||
describe('ListRow', () => {
|
||||
it('renders leading, title, meta and trailing slots without being interactive', () => {
|
||||
render(
|
||||
<ListRow
|
||||
leading={<span>icon</span>}
|
||||
title="Notifications"
|
||||
meta="Push and Google Chat"
|
||||
trailing={<span>chevron</span>}
|
||||
/>,
|
||||
)
|
||||
for (const text of ['icon', 'Notifications', 'Push and Google Chat', 'chevron']) {
|
||||
expect(screen.getByText(text)).toBeInTheDocument()
|
||||
}
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('is a single button with a ≥ 44 px hit area when it has onClick', () => {
|
||||
const onClick = vi.fn()
|
||||
render(<ListRow title="10 new side quests to swipe" onClick={onClick} />)
|
||||
const row = screen.getByRole('button', { name: '10 new side quests to swipe' })
|
||||
expect(row).toHaveAttribute('type', 'button')
|
||||
expectMinHitArea(row)
|
||||
fireEvent.click(row)
|
||||
expect(onClick).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps a trailing toggle outside the row button so each control fires only its own action', () => {
|
||||
const onClick = vi.fn()
|
||||
const onCheckedChange = vi.fn()
|
||||
render(
|
||||
<ListRow
|
||||
title="Dinner reminders"
|
||||
meta="Push at 4 pm"
|
||||
trailing={<Toggle aria-label="Dinner reminders" checked={false} onCheckedChange={onCheckedChange} />}
|
||||
onClick={onClick}
|
||||
/>,
|
||||
)
|
||||
const row = screen.getByRole('button', { name: /^Dinner reminders/ })
|
||||
const toggle = screen.getByRole('switch', { name: 'Dinner reminders' })
|
||||
expect(row).not.toContainElement(toggle)
|
||||
expect(row.querySelector('button')).toBeNull()
|
||||
|
||||
fireEvent.click(toggle)
|
||||
expect(onCheckedChange).toHaveBeenCalledExactlyOnceWith(true)
|
||||
expect(onClick).not.toHaveBeenCalled()
|
||||
|
||||
fireEvent.click(row)
|
||||
expect(onClick).toHaveBeenCalledOnce()
|
||||
expect(onCheckedChange).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import styles from './ListRow.module.css'
|
||||
|
||||
export type ListRowProps = {
|
||||
title: ReactNode
|
||||
meta?: ReactNode
|
||||
// Leading slot: icon, photo or avatar.
|
||||
leading?: ReactNode
|
||||
// Trailing slot: chevron, toggle, count or action.
|
||||
trailing?: ReactNode
|
||||
// When set, the leading slot, title and meta form one button. The trailing
|
||||
// slot stays a sibling outside it, so a trailing toggle or action is never
|
||||
// nested in the row's button and never triggers the row's action.
|
||||
onClick?: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ListRow({ title, meta, leading, trailing, onClick, className }: ListRowProps) {
|
||||
const main = (
|
||||
<>
|
||||
{leading && <span className={styles.leading}>{leading}</span>}
|
||||
<span className={styles.body}>
|
||||
<span className={styles.title}>{title}</span>
|
||||
{meta && <span className={styles.meta}>{meta}</span>}
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
const trailingSlot = trailing && <span className={styles.trailing}>{trailing}</span>
|
||||
if (onClick) {
|
||||
return (
|
||||
<div className={[styles.row, styles.interactive, className].filter(Boolean).join(' ')}>
|
||||
<button type="button" className={styles.main} onClick={onClick}>
|
||||
{main}
|
||||
</button>
|
||||
{trailingSlot}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className={[styles.row, className].filter(Boolean).join(' ')}>
|
||||
{main}
|
||||
{trailingSlot}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/* Radius and inset come from the --photo-radius and --photo-inset custom
|
||||
properties the component sets from its props. Size it with className. */
|
||||
.frame {
|
||||
display: block;
|
||||
margin: var(--photo-inset);
|
||||
border-radius: var(--photo-radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.photo {
|
||||
object-fit: cover;
|
||||
filter: saturate(0.6) contrast(0.85) brightness(1.1) opacity(0.94);
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: repeating-linear-gradient(
|
||||
135deg,
|
||||
var(--color-neutral-300) 0 8px,
|
||||
var(--color-neutral-200) 8px 16px
|
||||
);
|
||||
color: var(--color-neutral-700);
|
||||
font: 11px var(--font-mono);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { declaredStyle } from '../test/cssRules.ts'
|
||||
import { Photo } from './Photo.tsx'
|
||||
|
||||
const id = '01JABCDEFGHJKMNPQRSTVWXYZ0'
|
||||
|
||||
describe('Photo', () => {
|
||||
it('offers the 400 and 1200 renditions as a srcset', () => {
|
||||
render(<Photo id={id} name="Korean beef bowls" />)
|
||||
const img = screen.getByRole('img', { name: 'Korean beef bowls' })
|
||||
expect(img).toHaveAttribute('src', `/media/photos/${id}-400.jpg`)
|
||||
expect(img.getAttribute('srcset')!.split(', ')).toEqual([
|
||||
`/media/photos/${id}-400.jpg 400w`,
|
||||
`/media/photos/${id}-1200.jpg 1200w`,
|
||||
])
|
||||
})
|
||||
|
||||
it('loads lazily with the dinner name as alt text', () => {
|
||||
render(<Photo id={id} name="Pizza night" />)
|
||||
const img = screen.getByRole('img')
|
||||
expect(img).toHaveAttribute('loading', 'lazy')
|
||||
expect(img).toHaveAttribute('alt', 'Pizza night')
|
||||
})
|
||||
|
||||
it('applies the washed filter', () => {
|
||||
render(<Photo id={id} name="Pizza night" />)
|
||||
expect(declaredStyle(screen.getByRole('img')).filter).toBe(
|
||||
'saturate(0.6) contrast(0.85) brightness(1.1) opacity(0.94)',
|
||||
)
|
||||
})
|
||||
|
||||
it('takes its radius and inset from props', () => {
|
||||
render(<Photo id={id} name="Pizza night" radius="var(--radius-lg)" inset="10px" />)
|
||||
const img = screen.getByRole('img')
|
||||
const style = declaredStyle(img)
|
||||
expect(style.borderRadius).toBe('var(--photo-radius)')
|
||||
expect(style.margin).toBe('var(--photo-inset)')
|
||||
expect(img.style.getPropertyValue('--photo-radius')).toBe('var(--radius-lg)')
|
||||
expect(img.style.getPropertyValue('--photo-inset')).toBe('10px')
|
||||
})
|
||||
|
||||
it('shows the striped placeholder when there is no photo', () => {
|
||||
const { container } = render(<Photo name="Beef tacos" />)
|
||||
expect(container.querySelector('img')).toBeNull()
|
||||
const placeholder = screen.getByRole('img', { name: 'Beef tacos' })
|
||||
expect(placeholder).toHaveTextContent('dish photo')
|
||||
expect(declaredStyle(placeholder).background.replace(/\s+/g, ' ')).toBe(
|
||||
'repeating-linear-gradient( 135deg, var(--color-neutral-300) 0 8px, var(--color-neutral-200) 8px 16px )',
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to the placeholder when the image fails to load', () => {
|
||||
const { container } = render(<Photo id={id} name="Beef tacos" />)
|
||||
fireEvent.error(container.querySelector('img')!)
|
||||
expect(container.querySelector('img')).toBeNull()
|
||||
expect(screen.getByRole('img', { name: 'Beef tacos' })).toHaveTextContent('dish photo')
|
||||
})
|
||||
|
||||
it('tries again when given a new id after a failure', () => {
|
||||
const { container, rerender } = render(<Photo id={id} name="Beef tacos" />)
|
||||
fireEvent.error(container.querySelector('img')!)
|
||||
rerender(<Photo id="01JZZZZZZZZZZZZZZZZZZZZZZZ" name="Beef tacos" />)
|
||||
expect(container.querySelector('img')).toHaveAttribute('src', '/media/photos/01JZZZZZZZZZZZZZZZZZZZZZZZ-400.jpg')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useState, type CSSProperties } from 'react'
|
||||
import styles from './Photo.module.css'
|
||||
|
||||
export type PhotoProps = {
|
||||
// Photo id; the server stores /media/photos/{id}-400.jpg and -1200.jpg.
|
||||
// Without one the striped "dish photo" placeholder is shown.
|
||||
id?: string
|
||||
// The dinner name, used as the alt text.
|
||||
name: string
|
||||
// Corner radius and the margin from the containing card edge, as CSS lengths.
|
||||
radius?: string
|
||||
inset?: string
|
||||
// The rendered width hint for srcset selection.
|
||||
sizes?: string
|
||||
className?: string
|
||||
style?: CSSProperties
|
||||
}
|
||||
|
||||
// Dinner photo through the Organic "washed" filter. Falls back to the
|
||||
// striped placeholder when there is no photo or it fails to load.
|
||||
export function Photo({ id, name, radius = '24px', inset = '0', sizes = '100vw', className, style }: PhotoProps) {
|
||||
// The id whose image failed, so a new id gets a fresh attempt.
|
||||
const [failedId, setFailedId] = useState<string>()
|
||||
const frame = { '--photo-radius': radius, '--photo-inset': inset, ...style } as CSSProperties
|
||||
|
||||
if (id === undefined || failedId === id) {
|
||||
const classes = [styles.frame, styles.placeholder, className].filter(Boolean).join(' ')
|
||||
return (
|
||||
<div role="img" aria-label={name} className={classes} style={frame}>
|
||||
<span aria-hidden="true">dish photo</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const base = `/media/photos/${encodeURIComponent(id)}`
|
||||
const classes = [styles.frame, styles.photo, className].filter(Boolean).join(' ')
|
||||
return (
|
||||
<img
|
||||
src={`${base}-400.jpg`}
|
||||
srcSet={`${base}-400.jpg 400w, ${base}-1200.jpg 1200w`}
|
||||
sizes={sizes}
|
||||
loading="lazy"
|
||||
alt={name}
|
||||
className={classes}
|
||||
style={frame}
|
||||
onError={() => setFailedId(id)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
.track {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.option {
|
||||
flex: 1 1 0;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
padding: 0 var(--space-4);
|
||||
border: 0;
|
||||
border-radius: var(--radius-pill);
|
||||
background: transparent;
|
||||
color: var(--color-neutral-700);
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: background-color 150ms var(--ease-out-soft), box-shadow 150ms var(--ease-out-soft);
|
||||
}
|
||||
|
||||
.selected {
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.option {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { useState } from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||
import { Segmented } from './Segmented.tsx'
|
||||
import styles from './Segmented.module.css'
|
||||
|
||||
const options = [
|
||||
{ value: 'week', label: 'Week' },
|
||||
{ value: 'month', label: 'Month' },
|
||||
{ value: 'year', label: 'Year' },
|
||||
] as const
|
||||
|
||||
type Period = (typeof options)[number]['value']
|
||||
|
||||
function Harness({ initial = 'week' }: { initial?: Period }) {
|
||||
const [value, setValue] = useState<Period>(initial)
|
||||
return <Segmented label="Archive period" options={options} value={value} onValueChange={setValue} />
|
||||
}
|
||||
|
||||
function radio(name: string) {
|
||||
return screen.getByRole('radio', { name })
|
||||
}
|
||||
|
||||
describe('Segmented', () => {
|
||||
it('is a named radiogroup of radios with one checked', () => {
|
||||
render(<Harness />)
|
||||
expect(screen.getByRole('radiogroup', { name: 'Archive period' })).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('radio')).toHaveLength(3)
|
||||
expect(radio('Week')).toHaveAttribute('aria-checked', 'true')
|
||||
expect(radio('Month')).toHaveAttribute('aria-checked', 'false')
|
||||
expect(radio('Week')).toHaveClass(styles.selected!)
|
||||
})
|
||||
|
||||
it('has a single tab stop on the selected option', () => {
|
||||
render(<Harness initial="month" />)
|
||||
expect(radio('Month')).toHaveAttribute('tabindex', '0')
|
||||
expect(radio('Week')).toHaveAttribute('tabindex', '-1')
|
||||
expect(radio('Year')).toHaveAttribute('tabindex', '-1')
|
||||
})
|
||||
|
||||
it('moves selection and focus with the arrow keys, wrapping at the ends', () => {
|
||||
render(<Harness />)
|
||||
radio('Week').focus()
|
||||
|
||||
fireEvent.keyDown(radio('Week'), { key: 'ArrowRight' })
|
||||
expect(radio('Month')).toHaveAttribute('aria-checked', 'true')
|
||||
expect(radio('Month')).toHaveFocus()
|
||||
|
||||
fireEvent.keyDown(radio('Month'), { key: 'ArrowDown' })
|
||||
expect(radio('Year')).toHaveAttribute('aria-checked', 'true')
|
||||
|
||||
fireEvent.keyDown(radio('Year'), { key: 'ArrowRight' })
|
||||
expect(radio('Week')).toHaveAttribute('aria-checked', 'true')
|
||||
expect(radio('Week')).toHaveFocus()
|
||||
|
||||
fireEvent.keyDown(radio('Week'), { key: 'ArrowLeft' })
|
||||
expect(radio('Year')).toHaveAttribute('aria-checked', 'true')
|
||||
|
||||
fireEvent.keyDown(radio('Year'), { key: 'ArrowUp' })
|
||||
expect(radio('Month')).toHaveAttribute('aria-checked', 'true')
|
||||
expect(radio('Month')).toHaveFocus()
|
||||
expect(radio('Year')).toHaveAttribute('aria-checked', 'false')
|
||||
})
|
||||
|
||||
it('jumps to the first and last option with Home and End', () => {
|
||||
render(<Harness initial="month" />)
|
||||
fireEvent.keyDown(radio('Month'), { key: 'End' })
|
||||
expect(radio('Year')).toHaveAttribute('aria-checked', 'true')
|
||||
fireEvent.keyDown(radio('Year'), { key: 'Home' })
|
||||
expect(radio('Week')).toHaveAttribute('aria-checked', 'true')
|
||||
})
|
||||
|
||||
it('selects an option on click', () => {
|
||||
render(<Harness />)
|
||||
fireEvent.click(radio('Year'))
|
||||
expect(radio('Year')).toHaveAttribute('aria-checked', 'true')
|
||||
expect(radio('Week')).toHaveAttribute('aria-checked', 'false')
|
||||
})
|
||||
|
||||
it('gives every option a ≥ 44 px hit area', () => {
|
||||
render(<Harness />)
|
||||
for (const option of screen.getAllByRole('radio')) expectMinHitArea(option)
|
||||
})
|
||||
|
||||
it('fails fast when the value is not one of the options', () => {
|
||||
expect(() =>
|
||||
render(<Segmented label="Archive period" options={options} value={'decade' as Period} onValueChange={() => {}} />),
|
||||
).toThrow('value "decade" is not one of its options')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useRef, type KeyboardEvent } from 'react'
|
||||
import styles from './Segmented.module.css'
|
||||
|
||||
export type SegmentedOption<T extends string> = {
|
||||
value: T
|
||||
label: string
|
||||
}
|
||||
|
||||
export type SegmentedProps<T extends string> = {
|
||||
// Accessible name of the radiogroup.
|
||||
label: string
|
||||
options: readonly SegmentedOption<T>[]
|
||||
value: T
|
||||
onValueChange: (value: T) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
const step: Record<string, number> = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }
|
||||
|
||||
// Segmented control with radiogroup semantics: one tab stop (the selected
|
||||
// option); arrow keys move the selection and focus, wrapping at the ends;
|
||||
// Home and End jump to the first and last option.
|
||||
export function Segmented<T extends string>({ label, options, value, onValueChange, className }: SegmentedProps<T>) {
|
||||
const refs = useRef<(HTMLButtonElement | null)[]>([])
|
||||
const selected = options.findIndex((option) => option.value === value)
|
||||
if (selected === -1) {
|
||||
throw new Error(`Segmented "${label}": value "${value}" is not one of its options`)
|
||||
}
|
||||
|
||||
function select(index: number) {
|
||||
const option = options[index]
|
||||
if (!option) throw new Error(`Segmented "${label}": no option at index ${index}`)
|
||||
onValueChange(option.value)
|
||||
refs.current[index]?.focus()
|
||||
}
|
||||
|
||||
function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {
|
||||
const delta = step[event.key]
|
||||
let next: number
|
||||
if (delta !== undefined) next = (selected + delta + options.length) % options.length
|
||||
else if (event.key === 'Home') next = 0
|
||||
else if (event.key === 'End') next = options.length - 1
|
||||
else return
|
||||
event.preventDefault()
|
||||
select(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={label}
|
||||
className={[styles.track, className].filter(Boolean).join(' ')}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
{options.map((option, index) => {
|
||||
const checked = index === selected
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
ref={(el) => {
|
||||
refs.current[index] = el
|
||||
}}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={checked}
|
||||
tabIndex={checked ? 0 : -1}
|
||||
className={checked ? `${styles.option} ${styles.selected}` : styles.option}
|
||||
onClick={() => select(index)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
.root {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.scrim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: color-mix(in srgb, var(--color-neutral-900) 50%, transparent);
|
||||
animation: fade 320ms var(--ease-out-soft);
|
||||
}
|
||||
|
||||
.panel {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
max-height: 90dvh;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-6) var(--space-4) calc(var(--space-6) + env(safe-area-inset-bottom));
|
||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: translateY(0);
|
||||
animation: rise 320ms var(--ease-out-soft);
|
||||
}
|
||||
|
||||
.panel:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@keyframes rise {
|
||||
from {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.scrim,
|
||||
.panel {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { useState } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import '../styles/tokens.css'
|
||||
import { animationKeyframes, declaredStyle, reducedMotionStyle } from '../test/cssRules.ts'
|
||||
import { Sheet } from './Sheet.tsx'
|
||||
import styles from './Sheet.module.css'
|
||||
|
||||
function Harness({ withControls = true }: { withControls?: boolean }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => setOpen(true)}>
|
||||
Open Supply run
|
||||
</button>
|
||||
<Sheet aria-label="Supply run" open={open} onClose={() => setOpen(false)}>
|
||||
<p>Grouped by dinner</p>
|
||||
{withControls && (
|
||||
<>
|
||||
<button type="button">Try it</button>
|
||||
<button type="button">Pass</button>
|
||||
</>
|
||||
)}
|
||||
</Sheet>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function openSheet() {
|
||||
const opener = screen.getByRole('button', { name: 'Open Supply run' })
|
||||
opener.focus()
|
||||
fireEvent.click(opener)
|
||||
return { opener, dialog: screen.getByRole('dialog', { name: 'Supply run' }) }
|
||||
}
|
||||
|
||||
function scrim() {
|
||||
const element = document.querySelector(`.${styles.scrim}`)
|
||||
if (!element) throw new Error('scrim not rendered')
|
||||
return element
|
||||
}
|
||||
|
||||
describe('Sheet', () => {
|
||||
it('renders nothing while closed', () => {
|
||||
render(<Harness />)
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('is a labelled modal dialog', () => {
|
||||
render(<Harness />)
|
||||
const { dialog } = openSheet()
|
||||
expect(dialog).toHaveAttribute('aria-modal', 'true')
|
||||
})
|
||||
|
||||
it('accepts aria-labelledby as its accessible name', () => {
|
||||
render(
|
||||
<>
|
||||
<h2 id="sheet-title">Quest log</h2>
|
||||
<Sheet aria-labelledby="sheet-title" open onClose={() => {}}>
|
||||
<p>Nothing yet</p>
|
||||
</Sheet>
|
||||
</>,
|
||||
)
|
||||
expect(screen.getByRole('dialog', { name: 'Quest log' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('moves focus to the first control and restores it to the opener on close', () => {
|
||||
render(<Harness />)
|
||||
const { opener } = openSheet()
|
||||
expect(screen.getByRole('button', { name: 'Try it' })).toHaveFocus()
|
||||
fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
||||
expect(opener).toHaveFocus()
|
||||
})
|
||||
|
||||
it('focuses the dialog itself when it has no focusable control', () => {
|
||||
render(<Harness withControls={false} />)
|
||||
const { dialog } = openSheet()
|
||||
expect(dialog).toHaveFocus()
|
||||
})
|
||||
|
||||
it('skips controls that cannot take keyboard focus when moving focus in', () => {
|
||||
render(
|
||||
<Sheet aria-label="Quest log" open onClose={() => {}}>
|
||||
<button type="button" hidden>
|
||||
Hidden
|
||||
</button>
|
||||
<button type="button" style={{ display: 'none' }}>
|
||||
Not displayed
|
||||
</button>
|
||||
<button type="button" style={{ visibility: 'hidden' }}>
|
||||
Invisible
|
||||
</button>
|
||||
<button type="button" tabIndex={-1}>
|
||||
Not tabbable
|
||||
</button>
|
||||
<div aria-hidden="true">
|
||||
<button type="button">Aria hidden</button>
|
||||
</div>
|
||||
<button type="button">Try it</button>
|
||||
</Sheet>,
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Try it' })).toHaveFocus()
|
||||
})
|
||||
|
||||
it('focuses the dialog itself when every control is hidden', () => {
|
||||
render(
|
||||
<Sheet aria-label="Quest log" open onClose={() => {}}>
|
||||
<button type="button" hidden>
|
||||
Hidden
|
||||
</button>
|
||||
</Sheet>,
|
||||
)
|
||||
expect(screen.getByRole('dialog', { name: 'Quest log' })).toHaveFocus()
|
||||
})
|
||||
|
||||
it('does not stop the Tab trap on a hidden control', () => {
|
||||
render(
|
||||
<Sheet aria-label="Quest log" open onClose={() => {}}>
|
||||
<button type="button" hidden>
|
||||
Hidden first
|
||||
</button>
|
||||
<button type="button">Try it</button>
|
||||
<button type="button">Pass</button>
|
||||
<button type="button" hidden>
|
||||
Hidden last
|
||||
</button>
|
||||
</Sheet>,
|
||||
)
|
||||
const first = screen.getByRole('button', { name: 'Try it' })
|
||||
const last = screen.getByRole('button', { name: 'Pass' })
|
||||
|
||||
last.focus()
|
||||
fireEvent.keyDown(last, { key: 'Tab' })
|
||||
expect(first).toHaveFocus()
|
||||
|
||||
fireEvent.keyDown(first, { key: 'Tab', shiftKey: true })
|
||||
expect(last).toHaveFocus()
|
||||
})
|
||||
|
||||
it('does not stop the Tab trap on a control disabled by its fieldset', () => {
|
||||
render(
|
||||
<Sheet aria-label="Quest log" open onClose={() => {}}>
|
||||
<fieldset disabled>
|
||||
<button type="button">Disabled first</button>
|
||||
</fieldset>
|
||||
<button type="button">Try it</button>
|
||||
<button type="button">Pass</button>
|
||||
<fieldset disabled>
|
||||
<button type="button">Disabled last</button>
|
||||
</fieldset>
|
||||
</Sheet>,
|
||||
)
|
||||
const first = screen.getByRole('button', { name: 'Try it' })
|
||||
const last = screen.getByRole('button', { name: 'Pass' })
|
||||
expect(first).toHaveFocus()
|
||||
|
||||
last.focus()
|
||||
fireEvent.keyDown(last, { key: 'Tab' })
|
||||
expect(first).toHaveFocus()
|
||||
|
||||
fireEvent.keyDown(first, { key: 'Tab', shiftKey: true })
|
||||
expect(last).toHaveFocus()
|
||||
})
|
||||
|
||||
it('wraps Tab and Shift+Tab inside the sheet', () => {
|
||||
render(<Harness />)
|
||||
openSheet()
|
||||
const first = screen.getByRole('button', { name: 'Try it' })
|
||||
const last = screen.getByRole('button', { name: 'Pass' })
|
||||
|
||||
last.focus()
|
||||
fireEvent.keyDown(last, { key: 'Tab' })
|
||||
expect(first).toHaveFocus()
|
||||
|
||||
fireEvent.keyDown(first, { key: 'Tab', shiftKey: true })
|
||||
expect(last).toHaveFocus()
|
||||
})
|
||||
|
||||
it('keeps focus on the dialog when Tab has nowhere to go', () => {
|
||||
render(<Harness withControls={false} />)
|
||||
const { dialog } = openSheet()
|
||||
const tab = fireEvent.keyDown(dialog, { key: 'Tab' })
|
||||
expect(tab).toBe(false) // default prevented
|
||||
expect(dialog).toHaveFocus()
|
||||
})
|
||||
|
||||
it('closes on Escape', () => {
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<Sheet aria-label="Supply run" open onClose={onClose}>
|
||||
<button type="button">Try it</button>
|
||||
</Sheet>,
|
||||
)
|
||||
fireEvent.keyDown(screen.getByRole('button', { name: 'Try it' }), { key: 'Escape' })
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('closes on a scrim click but not on a panel click', () => {
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<Sheet aria-label="Supply run" open onClose={onClose}>
|
||||
<p>Grouped by dinner</p>
|
||||
</Sheet>,
|
||||
)
|
||||
fireEvent.click(screen.getByRole('dialog'))
|
||||
fireEvent.click(screen.getByText('Grouped by dinner'))
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
fireEvent.click(scrim())
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('makes the background inert while open and restores it on close', () => {
|
||||
const { container } = render(<Harness />)
|
||||
openSheet()
|
||||
const root = screen.getByRole('dialog').parentElement!
|
||||
expect(root.parentElement).toBe(document.body)
|
||||
expect(container).toHaveAttribute('inert')
|
||||
expect(root).not.toHaveAttribute('inert')
|
||||
|
||||
fireEvent.click(scrim())
|
||||
expect(container).not.toHaveAttribute('inert')
|
||||
})
|
||||
|
||||
it('leaves an already-inert background element inert after close', () => {
|
||||
const other = document.createElement('div')
|
||||
other.setAttribute('inert', '')
|
||||
document.body.append(other)
|
||||
try {
|
||||
render(<Harness />)
|
||||
openSheet()
|
||||
fireEvent.click(scrim())
|
||||
expect(other).toHaveAttribute('inert')
|
||||
} finally {
|
||||
other.remove()
|
||||
}
|
||||
})
|
||||
|
||||
it('rises from 100% to 0 over 320 ms with the soft ease-out', () => {
|
||||
render(<Harness />)
|
||||
const { dialog } = openSheet()
|
||||
expect(declaredStyle(dialog).animation.split(' ').slice(1)).toEqual(['320ms', 'var(--ease-out-soft)'])
|
||||
const easing = getComputedStyle(document.documentElement).getPropertyValue('--ease-out-soft')
|
||||
expect(easing.replace(/\s/g, '')).toBe('cubic-bezier(.2,.8,.2,1)')
|
||||
const rise = animationKeyframes(dialog)
|
||||
expect(rise.findRule('from')?.style.transform).toBe('translateY(100%)')
|
||||
expect(rise.findRule('to')?.style.transform).toBe('translateY(0)')
|
||||
})
|
||||
|
||||
it('fades the scrim in to 50% neutral-900', () => {
|
||||
render(<Harness />)
|
||||
openSheet()
|
||||
const style = declaredStyle(scrim())
|
||||
expect(style.background).toBe('color-mix(in srgb, var(--color-neutral-900) 50%, transparent)')
|
||||
expect(style.animation.split(' ').slice(1)).toEqual(['320ms', 'var(--ease-out-soft)'])
|
||||
const fade = animationKeyframes(scrim())
|
||||
expect(fade.findRule('from')?.style.opacity).toBe('0')
|
||||
expect(fade.findRule('to')?.style.opacity).toBe('1')
|
||||
})
|
||||
|
||||
it('opens in place with no rise or fade under reduced motion', () => {
|
||||
render(<Harness />)
|
||||
const { dialog } = openSheet()
|
||||
expect(reducedMotionStyle(dialog).animation).toBe('none')
|
||||
expect(reducedMotionStyle(scrim()).animation).toBe('none')
|
||||
expect(declaredStyle(dialog).transform).toBe('translateY(0)')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useEffect, useRef, type KeyboardEvent, type ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import styles from './Sheet.module.css'
|
||||
|
||||
// The dialog's accessible name: exactly one of a label or a labelling element.
|
||||
type SheetName =
|
||||
| { 'aria-label': string; 'aria-labelledby'?: never }
|
||||
| { 'aria-labelledby': string; 'aria-label'?: never }
|
||||
|
||||
export type SheetProps = SheetName & {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
const focusableSelector = [
|
||||
'a[href]',
|
||||
'button',
|
||||
'input',
|
||||
'select',
|
||||
'textarea',
|
||||
'[tabindex]',
|
||||
].join(',')
|
||||
|
||||
// Whether an element can take keyboard focus: being disabled (by its own
|
||||
// attribute or a disabled fieldset), a negative tabIndex, a hidden, aria-hidden
|
||||
// or inert ancestor (or self), display: none on the way up to the panel, or
|
||||
// visibility: hidden all rule it out.
|
||||
function isKeyboardFocusable(element: HTMLElement, panel: HTMLElement): boolean {
|
||||
if (element.matches(':disabled')) return false
|
||||
if (element.tabIndex < 0) return false
|
||||
if (element.closest('[hidden], [aria-hidden="true"], [inert]')) return false
|
||||
if (getComputedStyle(element).visibility === 'hidden') return false
|
||||
for (let node: HTMLElement | null = element; node && node !== panel; node = node.parentElement) {
|
||||
if (getComputedStyle(node).display === 'none') return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function focusables(panel: HTMLElement): HTMLElement[] {
|
||||
return Array.from(panel.querySelectorAll<HTMLElement>(focusableSelector)).filter((element) =>
|
||||
isKeyboardFocusable(element, panel),
|
||||
)
|
||||
}
|
||||
|
||||
// Focuses the first control that actually takes focus, or the panel if none does.
|
||||
function focusFirst(panel: HTMLElement) {
|
||||
for (const element of focusables(panel)) {
|
||||
element.focus()
|
||||
if (document.activeElement === element) return
|
||||
}
|
||||
panel.focus()
|
||||
}
|
||||
|
||||
// Modal bottom sheet. Portalled to <body> so everything else there can be made
|
||||
// inert while it is open. Focus moves in on open, Tab is trapped inside, and
|
||||
// focus returns to the opener on close. Escape and a scrim click call onClose.
|
||||
export function Sheet({ open, onClose, children, ...name }: SheetProps) {
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const root = rootRef.current
|
||||
const panel = panelRef.current
|
||||
if (!open || !root || !panel) return
|
||||
|
||||
const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||||
const background = Array.from(document.body.children).filter(
|
||||
(element) => element !== root && !element.hasAttribute('inert'),
|
||||
)
|
||||
for (const element of background) element.setAttribute('inert', '')
|
||||
focusFirst(panel)
|
||||
|
||||
return () => {
|
||||
for (const element of background) element.removeAttribute('inert')
|
||||
opener?.focus()
|
||||
}
|
||||
}, [open])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {
|
||||
if (event.key === 'Escape') {
|
||||
event.stopPropagation()
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
if (event.key !== 'Tab' || !panelRef.current) return
|
||||
const items = focusables(panelRef.current)
|
||||
const first = items[0]
|
||||
const last = items[items.length - 1]
|
||||
if (!first || !last) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
const active = document.activeElement
|
||||
if (event.shiftKey && (active === first || active === panelRef.current)) {
|
||||
event.preventDefault()
|
||||
last.focus()
|
||||
} else if (!event.shiftKey && active === last) {
|
||||
event.preventDefault()
|
||||
first.focus()
|
||||
}
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div ref={rootRef} className={styles.root}>
|
||||
<div className={styles.scrim} aria-hidden="true" onClick={onClose} />
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
tabIndex={-1}
|
||||
className={styles.panel}
|
||||
onKeyDown={onKeyDown}
|
||||
{...name}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
.skeleton {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 16px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-surface);
|
||||
animation: pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.45;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.skeleton {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { render } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { animationKeyframes, declaredStyle, reducedMotionStyle } from '../test/cssRules.ts'
|
||||
import { Skeleton } from './Skeleton.tsx'
|
||||
|
||||
function skeleton(container: HTMLElement) {
|
||||
return container.firstElementChild as HTMLElement
|
||||
}
|
||||
|
||||
describe('Skeleton', () => {
|
||||
it('is a hidden pill in --color-surface', () => {
|
||||
const { container } = render(<Skeleton />)
|
||||
const element = skeleton(container)
|
||||
expect(element).toHaveAttribute('aria-hidden', 'true')
|
||||
const style = declaredStyle(element)
|
||||
expect(style.borderRadius).toBe('var(--radius-pill)')
|
||||
expect(style.background).toBe('var(--color-surface)')
|
||||
})
|
||||
|
||||
it('pulses its opacity over 1.2 s', () => {
|
||||
const { container } = render(<Skeleton />)
|
||||
expect(declaredStyle(skeleton(container)).animation.split(' ').slice(1)).toEqual(['1.2s', 'ease-in-out', 'infinite'])
|
||||
expect(animationKeyframes(skeleton(container)).findRule('50%')?.style.opacity).toBe('0.45')
|
||||
})
|
||||
|
||||
it('does not pulse under reduced motion', () => {
|
||||
const { container } = render(<Skeleton />)
|
||||
expect(reducedMotionStyle(skeleton(container)).animation).toBe('none')
|
||||
})
|
||||
|
||||
it('merges a caller className and forwards style', () => {
|
||||
const { container } = render(<Skeleton className="line" style={{ width: '60%' }} />)
|
||||
expect(skeleton(container)).toHaveClass('line')
|
||||
expect(skeleton(container).style.width).toBe('60%')
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user