@ssejal8/recipe-ui-kit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/.claude/settings.local.json +10 -0
  2. package/.npmr +1 -0
  3. package/README.md +354 -0
  4. package/apps/recipe-web/package.json +31 -0
  5. package/apps/recipe-web/src/app.css +418 -0
  6. package/apps/recipe-web/src/app.d.ts +32 -0
  7. package/apps/recipe-web/src/app.html +25 -0
  8. package/apps/recipe-web/src/lib/api/mealdb.ts +240 -0
  9. package/apps/recipe-web/src/lib/components/AppHeader.svelte +290 -0
  10. package/apps/recipe-web/src/lib/components/ConfirmDialog.svelte +46 -0
  11. package/apps/recipe-web/src/lib/components/Modal.svelte +33 -0
  12. package/apps/recipe-web/src/lib/components/RecipeCard.svelte +100 -0
  13. package/apps/recipe-web/src/lib/components/RecipeForm.svelte +560 -0
  14. package/apps/recipe-web/src/lib/components/RecipeGrid.svelte +77 -0
  15. package/apps/recipe-web/src/lib/components/RecipePickerDialog.svelte +185 -0
  16. package/apps/recipe-web/src/lib/components/Toaster.svelte +111 -0
  17. package/apps/recipe-web/src/lib/domain/recipe.ts +130 -0
  18. package/apps/recipe-web/src/lib/domain/validation.ts +189 -0
  19. package/apps/recipe-web/src/lib/stencil/WebComponent.svelte +145 -0
  20. package/apps/recipe-web/src/lib/stencil/register.ts +28 -0
  21. package/apps/recipe-web/src/lib/stores/favorites.svelte.ts +81 -0
  22. package/apps/recipe-web/src/lib/stores/myRecipes.svelte.ts +98 -0
  23. package/apps/recipe-web/src/lib/stores/persisted.svelte.ts +73 -0
  24. package/apps/recipe-web/src/lib/stores/planner.svelte.ts +275 -0
  25. package/apps/recipe-web/src/lib/stores/theme.svelte.ts +67 -0
  26. package/apps/recipe-web/src/lib/stores/toast.svelte.ts +66 -0
  27. package/apps/recipe-web/src/routes/+error.svelte +36 -0
  28. package/apps/recipe-web/src/routes/+layout.svelte +62 -0
  29. package/apps/recipe-web/src/routes/+page.svelte +352 -0
  30. package/apps/recipe-web/src/routes/+page.ts +107 -0
  31. package/apps/recipe-web/src/routes/favorites/+page.svelte +170 -0
  32. package/apps/recipe-web/src/routes/my-recipes/+layout.ts +55 -0
  33. package/apps/recipe-web/src/routes/my-recipes/+page.svelte +180 -0
  34. package/apps/recipe-web/src/routes/my-recipes/[id]/edit/+page.svelte +104 -0
  35. package/apps/recipe-web/src/routes/my-recipes/new/+page.svelte +67 -0
  36. package/apps/recipe-web/src/routes/planner/+page.svelte +422 -0
  37. package/apps/recipe-web/src/routes/recipes/[id]/+page.svelte +538 -0
  38. package/apps/recipe-web/src/routes/recipes/[id]/+page.ts +24 -0
  39. package/apps/recipe-web/static/favicon.svg +8 -0
  40. package/apps/recipe-web/static/robots.txt +2 -0
  41. package/apps/recipe-web/svelte.config.js +35 -0
  42. package/apps/recipe-web/tsconfig.json +14 -0
  43. package/apps/recipe-web/vite.config.ts +23 -0
  44. package/package.json +29 -0
  45. package/packages/recipe-kit-stencil/LICENSE +21 -0
  46. package/packages/recipe-kit-stencil/README.md +185 -0
  47. package/packages/recipe-kit-stencil/package-lock.json +4772 -0
  48. package/packages/recipe-kit-stencil/package.json +91 -0
  49. package/packages/recipe-kit-stencil/src/components/rk-badge/readme.md +38 -0
  50. package/packages/recipe-kit-stencil/src/components/rk-badge/rk-badge.css +102 -0
  51. package/packages/recipe-kit-stencil/src/components/rk-badge/rk-badge.tsx +31 -0
  52. package/packages/recipe-kit-stencil/src/components/rk-chip/readme.md +52 -0
  53. package/packages/recipe-kit-stencil/src/components/rk-chip/rk-chip.css +155 -0
  54. package/packages/recipe-kit-stencil/src/components/rk-chip/rk-chip.tsx +105 -0
  55. package/packages/recipe-kit-stencil/src/components/rk-empty-state/readme.md +44 -0
  56. package/packages/recipe-kit-stencil/src/components/rk-empty-state/rk-empty-state.css +64 -0
  57. package/packages/recipe-kit-stencil/src/components/rk-empty-state/rk-empty-state.tsx +50 -0
  58. package/packages/recipe-kit-stencil/src/components/rk-meal-slot/readme.md +58 -0
  59. package/packages/recipe-kit-stencil/src/components/rk-meal-slot/rk-meal-slot.css +225 -0
  60. package/packages/recipe-kit-stencil/src/components/rk-meal-slot/rk-meal-slot.tsx +182 -0
  61. package/packages/recipe-kit-stencil/src/components/rk-modal/readme.md +57 -0
  62. package/packages/recipe-kit-stencil/src/components/rk-modal/rk-modal.css +163 -0
  63. package/packages/recipe-kit-stencil/src/components/rk-modal/rk-modal.tsx +176 -0
  64. package/packages/recipe-kit-stencil/src/components/rk-rating/readme.md +42 -0
  65. package/packages/recipe-kit-stencil/src/components/rk-rating/rk-rating.css +71 -0
  66. package/packages/recipe-kit-stencil/src/components/rk-rating/rk-rating.tsx +114 -0
  67. package/packages/recipe-kit-stencil/src/components/rk-recipe-card/readme.md +66 -0
  68. package/packages/recipe-kit-stencil/src/components/rk-recipe-card/rk-recipe-card.css +280 -0
  69. package/packages/recipe-kit-stencil/src/components/rk-recipe-card/rk-recipe-card.tsx +179 -0
  70. package/packages/recipe-kit-stencil/src/components/rk-recipe-card/test/rk-recipe-card.spec.tsx +70 -0
  71. package/packages/recipe-kit-stencil/src/components/rk-search-bar/readme.md +67 -0
  72. package/packages/recipe-kit-stencil/src/components/rk-search-bar/rk-search-bar.css +141 -0
  73. package/packages/recipe-kit-stencil/src/components/rk-search-bar/rk-search-bar.tsx +152 -0
  74. package/packages/recipe-kit-stencil/src/components/rk-search-bar/test/rk-search-bar.spec.tsx +93 -0
  75. package/packages/recipe-kit-stencil/src/components/rk-segmented-control/readme.md +41 -0
  76. package/packages/recipe-kit-stencil/src/components/rk-segmented-control/rk-segmented-control.css +91 -0
  77. package/packages/recipe-kit-stencil/src/components/rk-segmented-control/rk-segmented-control.tsx +115 -0
  78. package/packages/recipe-kit-stencil/src/components/rk-tag-input/readme.md +47 -0
  79. package/packages/recipe-kit-stencil/src/components/rk-tag-input/rk-tag-input.css +146 -0
  80. package/packages/recipe-kit-stencil/src/components/rk-tag-input/rk-tag-input.tsx +181 -0
  81. package/packages/recipe-kit-stencil/src/components/rk-tag-input/test/rk-tag-input.spec.tsx +76 -0
  82. package/packages/recipe-kit-stencil/src/components.d.ts +1192 -0
  83. package/packages/recipe-kit-stencil/src/global/rk-global.css +104 -0
  84. package/packages/recipe-kit-stencil/src/index.html +259 -0
  85. package/packages/recipe-kit-stencil/src/index.ts +6 -0
  86. package/packages/recipe-kit-stencil/stencil.config.ts +41 -0
  87. package/packages/recipe-kit-stencil/tsconfig.json +24 -0
@@ -0,0 +1,280 @@
1
+ :host {
2
+ display: block;
3
+ font-family: var(--rk-font);
4
+ font-size: var(--rk-font-size, 15px);
5
+ color: var(--rk-ink, #211a16);
6
+ height: 100%;
7
+ }
8
+
9
+ .card {
10
+ position: relative;
11
+ display: flex;
12
+ flex-direction: column;
13
+ height: 100%;
14
+ background: var(--rk-surface, #fff);
15
+ border: 1px solid var(--rk-border, #ecdfd4);
16
+ border-radius: var(--rk-radius-lg, 20px);
17
+ overflow: hidden;
18
+ box-shadow: var(--rk-shadow-sm);
19
+ transition: transform var(--rk-transition), box-shadow var(--rk-transition), border-color var(--rk-transition);
20
+ }
21
+
22
+ .card:hover {
23
+ transform: translateY(-3px);
24
+ box-shadow: var(--rk-shadow);
25
+ border-color: var(--rk-border-strong, #d9c6b8);
26
+ }
27
+
28
+ /* ---------- media ---------- */
29
+
30
+ .media {
31
+ position: relative;
32
+ display: block;
33
+ aspect-ratio: 4 / 3;
34
+ background: var(--rk-surface-2, #fdf5ef);
35
+ overflow: hidden;
36
+ text-decoration: none;
37
+ }
38
+
39
+ .media img {
40
+ width: 100%;
41
+ height: 100%;
42
+ object-fit: cover;
43
+ display: block;
44
+ transition: transform 420ms cubic-bezier(0.2, 0, 0.2, 1);
45
+ }
46
+
47
+ .card:hover .media img {
48
+ transform: scale(1.045);
49
+ }
50
+
51
+ .media-fallback {
52
+ position: absolute;
53
+ inset: 0;
54
+ display: grid;
55
+ place-items: center;
56
+ font-size: 2.6rem;
57
+ opacity: 0.5;
58
+ }
59
+
60
+ .owned {
61
+ position: absolute;
62
+ left: 10px;
63
+ bottom: 10px;
64
+ padding: 4px 10px;
65
+ border-radius: var(--rk-radius-pill, 999px);
66
+ background: var(--rk-accent, #0f766e);
67
+ color: #fff;
68
+ font-size: 0.72em;
69
+ font-weight: 700;
70
+ letter-spacing: 0.03em;
71
+ text-transform: uppercase;
72
+ }
73
+
74
+ /* ---------- favorite ---------- */
75
+
76
+ .fav {
77
+ position: absolute;
78
+ top: 10px;
79
+ right: 10px;
80
+ z-index: 2;
81
+ display: inline-flex;
82
+ align-items: center;
83
+ justify-content: center;
84
+ width: 38px;
85
+ height: 38px;
86
+ padding: 0;
87
+ border: 0;
88
+ border-radius: 50%;
89
+ background: rgba(255, 255, 255, 0.9);
90
+ backdrop-filter: blur(6px);
91
+ color: var(--rk-ink-2, #52443c);
92
+ cursor: pointer;
93
+ box-shadow: var(--rk-shadow-sm);
94
+ transition: background var(--rk-transition), color var(--rk-transition), transform var(--rk-transition);
95
+ }
96
+
97
+ .fav svg {
98
+ width: 20px;
99
+ height: 20px;
100
+ fill: none;
101
+ transition: fill var(--rk-transition);
102
+ }
103
+
104
+ .fav:hover {
105
+ transform: scale(1.08);
106
+ color: var(--rk-primary, #d94f1b);
107
+ }
108
+
109
+ .fav:focus-visible {
110
+ outline: none;
111
+ box-shadow: var(--rk-focus-ring);
112
+ }
113
+
114
+ .fav.is-on {
115
+ color: var(--rk-primary, #d94f1b);
116
+ }
117
+
118
+ .fav.is-on svg {
119
+ fill: currentColor;
120
+ }
121
+
122
+ /* ---------- body ---------- */
123
+
124
+ .body {
125
+ display: flex;
126
+ flex-direction: column;
127
+ gap: 7px;
128
+ padding: 15px 16px 0;
129
+ flex: 1 1 auto;
130
+ }
131
+
132
+ .title {
133
+ margin: 0;
134
+ font-family: var(--rk-font-display, serif);
135
+ font-size: 1.12em;
136
+ font-weight: 650;
137
+ line-height: 1.28;
138
+ letter-spacing: -0.01em;
139
+ }
140
+
141
+ .title a {
142
+ color: inherit;
143
+ text-decoration: none;
144
+ display: -webkit-box;
145
+ -webkit-line-clamp: 2;
146
+ -webkit-box-orient: vertical;
147
+ overflow: hidden;
148
+ }
149
+
150
+ .title a:hover {
151
+ color: var(--rk-primary, #d94f1b);
152
+ }
153
+
154
+ .title a:focus-visible {
155
+ outline: none;
156
+ border-radius: 4px;
157
+ box-shadow: var(--rk-focus-ring);
158
+ }
159
+
160
+ .meta {
161
+ display: flex;
162
+ flex-wrap: wrap;
163
+ align-items: center;
164
+ gap: 6px 14px;
165
+ margin: 0;
166
+ font-size: 0.83em;
167
+ color: var(--rk-muted, #857166);
168
+ }
169
+
170
+ .meta-item {
171
+ position: relative;
172
+ white-space: nowrap;
173
+ }
174
+
175
+ .meta-item + .meta-item::before {
176
+ content: '';
177
+ position: absolute;
178
+ left: -8px;
179
+ top: 50%;
180
+ width: 3px;
181
+ height: 3px;
182
+ border-radius: 50%;
183
+ background: currentColor;
184
+ opacity: 0.55;
185
+ transform: translateY(-50%);
186
+ }
187
+
188
+ .extra {
189
+ display: flex;
190
+ flex-wrap: wrap;
191
+ align-items: center;
192
+ gap: 6px;
193
+ margin-top: 3px;
194
+ }
195
+
196
+ /* ---------- footer ---------- */
197
+
198
+ .footer {
199
+ display: flex;
200
+ align-items: center;
201
+ gap: 8px;
202
+ padding: 14px 16px;
203
+ margin-top: 12px;
204
+ border-top: 1px solid var(--rk-border, #ecdfd4);
205
+ }
206
+
207
+ .footer ::slotted(*) {
208
+ flex: 1 1 auto;
209
+ }
210
+
211
+ .extra.is-empty,
212
+ .footer.is-empty {
213
+ display: none;
214
+ }
215
+
216
+ /* Without a footer the body has to supply its own bottom padding. */
217
+ .card:has(.footer.is-empty) .body {
218
+ padding-bottom: 16px;
219
+ }
220
+
221
+ /* ---------- compact variant ---------- */
222
+
223
+ .card.is-compact {
224
+ flex-direction: row;
225
+ align-items: stretch;
226
+ border-radius: var(--rk-radius, 14px);
227
+ }
228
+
229
+ .card.is-compact:hover {
230
+ transform: none;
231
+ }
232
+
233
+ .card.is-compact .media {
234
+ flex: 0 0 96px;
235
+ aspect-ratio: 1 / 1;
236
+ }
237
+
238
+ .card.is-compact .body {
239
+ padding: 12px 14px;
240
+ gap: 4px;
241
+ justify-content: center;
242
+ }
243
+
244
+ .card.is-compact .title {
245
+ font-size: 1em;
246
+ }
247
+
248
+ .card.is-compact .fav {
249
+ width: 32px;
250
+ height: 32px;
251
+ top: 8px;
252
+ right: 8px;
253
+ }
254
+
255
+ .card.is-compact .fav svg {
256
+ width: 17px;
257
+ height: 17px;
258
+ }
259
+
260
+ .card.is-compact .footer {
261
+ margin-top: 0;
262
+ border-top: 0;
263
+ border-left: 1px solid var(--rk-border, #ecdfd4);
264
+ padding: 12px;
265
+ }
266
+
267
+ .card.is-compact .owned {
268
+ display: none;
269
+ }
270
+
271
+ @media (prefers-reduced-motion: reduce) {
272
+ .card,
273
+ .card:hover,
274
+ .media img,
275
+ .card:hover .media img,
276
+ .fav {
277
+ transition: none;
278
+ transform: none;
279
+ }
280
+ }
@@ -0,0 +1,179 @@
1
+ import { Component, Element, Event, EventEmitter, h, Prop, State } from '@stencil/core';
2
+
3
+ export interface RkRecipeCardSelectDetail {
4
+ recipeId: string;
5
+ name: string;
6
+ origin: 'api' | 'custom';
7
+ }
8
+
9
+ export interface RkFavoriteToggleDetail {
10
+ recipeId: string;
11
+ name: string;
12
+ origin: 'api' | 'custom';
13
+ /** The state the host app should move *to*. */
14
+ favorite: boolean;
15
+ }
16
+
17
+ /**
18
+ * The primary recipe tile used across discovery, favorites and search results.
19
+ *
20
+ * All data arrives as individual primitive properties rather than one object
21
+ * prop, so the component works with plain HTML attributes in any framework.
22
+ *
23
+ * @slot - Extra metadata rendered under the title (badges, tags, ratings).
24
+ * @slot actions - Buttons rendered in the card footer (e.g. "Add to plan").
25
+ */
26
+ @Component({
27
+ tag: 'rk-recipe-card',
28
+ styleUrl: 'rk-recipe-card.css',
29
+ shadow: true,
30
+ })
31
+ export class RkRecipeCard {
32
+ @Element() host!: HTMLElement;
33
+
34
+ /** Stable identifier echoed back in every event payload. */
35
+ @Prop() recipeId!: string;
36
+
37
+ /** Recipe title. */
38
+ @Prop() name = '';
39
+
40
+ /** Thumbnail URL. */
41
+ @Prop() image?: string;
42
+
43
+ /** Category, e.g. "Dessert". */
44
+ @Prop() category?: string;
45
+
46
+ /** Cuisine / region, e.g. "Italian". */
47
+ @Prop() area?: string;
48
+
49
+ /** Total time in minutes. Hidden when omitted. */
50
+ @Prop() minutes?: number;
51
+
52
+ /** Number of servings. Hidden when omitted. */
53
+ @Prop() servings?: number;
54
+
55
+ /** Whether this recipe is currently favorited. */
56
+ @Prop() favorite = false;
57
+
58
+ /** `custom` recipes are user-authored and get an "Owned" badge. */
59
+ @Prop() origin: 'api' | 'custom' = 'api';
60
+
61
+ /** Real href used for accessibility, SEO and middle-click / open-in-new-tab. */
62
+ @Prop() href?: string;
63
+
64
+ /** Renders a compact horizontal layout, for dense lists and the planner. */
65
+ @Prop() compact = false;
66
+
67
+ /** Hides the favorite button entirely (e.g. inside the planner). */
68
+ @Prop() hideFavorite = false;
69
+
70
+ /** Emitted on primary activation. The host app performs the navigation. */
71
+ @Event({ eventName: 'rkCardSelect' }) rkCardSelect!: EventEmitter<RkRecipeCardSelectDetail>;
72
+
73
+ /** Emitted when the favorite button is pressed. */
74
+ @Event({ eventName: 'rkFavoriteToggle' }) rkFavoriteToggle!: EventEmitter<RkFavoriteToggleDetail>;
75
+
76
+ @State() private imageFailed = false;
77
+ @State() private hasExtra = false;
78
+ @State() private hasActions = false;
79
+
80
+ componentWillLoad() {
81
+ this.syncSlots();
82
+ }
83
+
84
+ /**
85
+ * Slot content is optional, and an empty wrapper would still paint padding and
86
+ * a border. Tracking what was actually projected lets the card collapse those
87
+ * regions instead. Re-run on `slotchange` so framework-driven updates count.
88
+ */
89
+ private syncSlots = () => {
90
+ const elements = Array.from(this.host.children);
91
+ this.hasActions = elements.some(node => node.getAttribute('slot') === 'actions');
92
+ this.hasExtra =
93
+ elements.some(node => !node.hasAttribute('slot')) ||
94
+ Array.from(this.host.childNodes).some(node => node.nodeType === Node.TEXT_NODE && (node.textContent ?? '').trim() !== '');
95
+ };
96
+
97
+ private get detail(): RkRecipeCardSelectDetail {
98
+ return { recipeId: this.recipeId, name: this.name, origin: this.origin };
99
+ }
100
+
101
+ private handleSelect = (event: MouseEvent) => {
102
+ // Let the browser handle modified clicks so "open in new tab" keeps working.
103
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) {
104
+ return;
105
+ }
106
+ event.preventDefault();
107
+ this.rkCardSelect.emit(this.detail);
108
+ };
109
+
110
+ private handleFavorite = (event: MouseEvent) => {
111
+ event.preventDefault();
112
+ event.stopPropagation();
113
+ this.rkFavoriteToggle.emit({ ...this.detail, favorite: !this.favorite });
114
+ };
115
+
116
+ render() {
117
+ const showImage = this.image && !this.imageFailed;
118
+ const href = this.href ?? `#${this.recipeId}`;
119
+
120
+ return (
121
+ <article class={{ card: true, 'is-compact': this.compact }} part="card">
122
+ <a class="media" part="media" href={href} tabindex={-1} aria-hidden="true" onClick={this.handleSelect}>
123
+ {showImage ? (
124
+ <img src={this.image} alt="" loading="lazy" decoding="async" onError={() => (this.imageFailed = true)} />
125
+ ) : (
126
+ <span class="media-fallback" aria-hidden="true">
127
+ 🍲
128
+ </span>
129
+ )}
130
+
131
+ {this.origin === 'custom' && (
132
+ <span class="owned" part="owned-badge">
133
+ Yours
134
+ </span>
135
+ )}
136
+ </a>
137
+
138
+ {!this.hideFavorite && (
139
+ <button
140
+ class={{ fav: true, 'is-on': this.favorite }}
141
+ part="favorite-button"
142
+ type="button"
143
+ onClick={this.handleFavorite}
144
+ aria-pressed={String(this.favorite)}
145
+ aria-label={this.favorite ? `Remove ${this.name} from favorites` : `Add ${this.name} to favorites`}
146
+ title={this.favorite ? 'Remove from favorites' : 'Add to favorites'}
147
+ >
148
+ <svg viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.9" stroke-linejoin="round" aria-hidden="true">
149
+ <path d="M12 20.5 4.6 13.3a4.7 4.7 0 0 1 0-6.7 4.7 4.7 0 0 1 6.7 0l.7.7.7-.7a4.7 4.7 0 0 1 6.7 0 4.7 4.7 0 0 1 0 6.7Z" />
150
+ </svg>
151
+ </button>
152
+ )}
153
+
154
+ <div class="body" part="body">
155
+ <h3 class="title" part="title">
156
+ <a href={href} onClick={this.handleSelect}>
157
+ {this.name}
158
+ </a>
159
+ </h3>
160
+
161
+ <p class="meta" part="meta">
162
+ {this.category && <span class="meta-item">{this.category}</span>}
163
+ {this.area && <span class="meta-item">{this.area}</span>}
164
+ {this.minutes > 0 && <span class="meta-item">{this.minutes} min</span>}
165
+ {this.servings > 0 && <span class="meta-item">Serves {this.servings}</span>}
166
+ </p>
167
+
168
+ <div class={{ extra: true, 'is-empty': !this.hasExtra }} part="extra">
169
+ <slot onSlotchange={this.syncSlots} />
170
+ </div>
171
+ </div>
172
+
173
+ <footer class={{ footer: true, 'is-empty': !this.hasActions }} part="footer">
174
+ <slot name="actions" onSlotchange={this.syncSlots} />
175
+ </footer>
176
+ </article>
177
+ );
178
+ }
179
+ }
@@ -0,0 +1,70 @@
1
+ import { newSpecPage } from '@stencil/core/testing';
2
+ import { RkRecipeCard } from '../rk-recipe-card';
3
+
4
+ describe('rk-recipe-card', () => {
5
+ it('renders the title, metadata and a real href', async () => {
6
+ const page = await newSpecPage({
7
+ components: [RkRecipeCard],
8
+ html: `<rk-recipe-card recipe-id="52772" name="Teriyaki Chicken" category="Chicken" area="Japanese" href="/recipes/52772"></rk-recipe-card>`,
9
+ });
10
+
11
+ const shadow = page.root.shadowRoot;
12
+ expect(shadow.querySelector('.title a').textContent).toBe('Teriyaki Chicken');
13
+ expect(shadow.querySelector('.title a').getAttribute('href')).toBe('/recipes/52772');
14
+
15
+ const meta = Array.from(shadow.querySelectorAll('.meta-item')).map(node => node.textContent);
16
+ expect(meta).toEqual(['Chicken', 'Japanese']);
17
+ });
18
+
19
+ it('emits rkCardSelect with the recipe identity instead of navigating', async () => {
20
+ const page = await newSpecPage({
21
+ components: [RkRecipeCard],
22
+ html: `<rk-recipe-card recipe-id="52772" name="Teriyaki Chicken" href="/recipes/52772"></rk-recipe-card>`,
23
+ });
24
+
25
+ const handler = jest.fn();
26
+ page.root.addEventListener('rkCardSelect', handler);
27
+
28
+ const link = page.root.shadowRoot.querySelector('.title a') as HTMLAnchorElement;
29
+ link.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
30
+ await page.waitForChanges();
31
+
32
+ expect(handler).toHaveBeenCalledTimes(1);
33
+ expect(handler.mock.calls[0][0].detail).toEqual({ recipeId: '52772', name: 'Teriyaki Chicken', origin: 'api' });
34
+ });
35
+
36
+ it('reports the state to move to, without mutating its own favorite prop', async () => {
37
+ const page = await newSpecPage({
38
+ components: [RkRecipeCard],
39
+ html: `<rk-recipe-card recipe-id="52772" name="Teriyaki Chicken" favorite="true"></rk-recipe-card>`,
40
+ });
41
+
42
+ const handler = jest.fn();
43
+ page.root.addEventListener('rkFavoriteToggle', handler);
44
+
45
+ (page.root.shadowRoot.querySelector('.fav') as HTMLButtonElement).click();
46
+ await page.waitForChanges();
47
+
48
+ expect(handler.mock.calls[0][0].detail.favorite).toBe(false);
49
+ // The host application owns the state; the component stays as it was told.
50
+ expect((page.root as HTMLRkRecipeCardElement).favorite).toBe(true);
51
+ });
52
+
53
+ it('hides the favorite button when asked', async () => {
54
+ const page = await newSpecPage({
55
+ components: [RkRecipeCard],
56
+ html: `<rk-recipe-card recipe-id="1" name="X" hide-favorite="true"></rk-recipe-card>`,
57
+ });
58
+
59
+ expect(page.root.shadowRoot.querySelector('.fav')).toBeNull();
60
+ });
61
+
62
+ it('marks user-authored recipes with an owned badge', async () => {
63
+ const page = await newSpecPage({
64
+ components: [RkRecipeCard],
65
+ html: `<rk-recipe-card recipe-id="my-1" name="Mine" origin="custom"></rk-recipe-card>`,
66
+ });
67
+
68
+ expect(page.root.shadowRoot.querySelector('.owned')).not.toBeNull();
69
+ });
70
+ });
@@ -0,0 +1,67 @@
1
+ # rk-search-bar
2
+
3
+
4
+
5
+ <!-- Auto Generated Below -->
6
+
7
+
8
+ ## Overview
9
+
10
+ A debounced search input with a clear affordance and a slot for extra controls.
11
+
12
+ ## Properties
13
+
14
+ | Property | Attribute | Description | Type | Default |
15
+ | ------------- | ------------- | ------------------------------------------------------------------------- | --------- | ------------------------------------------ |
16
+ | `debounce` | `debounce` | Milliseconds to wait after the last keystroke before emitting `rkSearch`. | `number` | `350` |
17
+ | `disabled` | `disabled` | Disables the whole control. | `boolean` | `false` |
18
+ | `hint` | `hint` | Optional helper text rendered under the field. | `string` | `undefined` |
19
+ | `label` | `label` | Accessible label for the input. | `string` | `'Search recipes'` |
20
+ | `loading` | `loading` | Shows a busy indicator (e.g. while the app is fetching results). | `boolean` | `false` |
21
+ | `placeholder` | `placeholder` | Placeholder shown when the field is empty. | `string` | `'Search recipes, ingredients, cuisines…'` |
22
+ | `value` | `value` | Current text in the field. Two-way: updated as the user types. | `string` | `''` |
23
+
24
+
25
+ ## Events
26
+
27
+ | Event | Description | Type |
28
+ | ---------- | ------------------------------------------------------------------------- | --------------------- |
29
+ | `rkClear` | Emitted when the user clears the field with the clear button or Escape. | `CustomEvent<void>` |
30
+ | `rkSearch` | Emitted with the (trimmed) query after the debounce window, or on submit. | `CustomEvent<string>` |
31
+
32
+
33
+ ## Methods
34
+
35
+ ### `setFocus() => Promise<void>`
36
+
37
+ Programmatically focus the input.
38
+
39
+ #### Returns
40
+
41
+ Type: `Promise<void>`
42
+
43
+
44
+
45
+
46
+ ## Slots
47
+
48
+ | Slot | Description |
49
+ | ----------- | ---------------------------------------------------------------------------------------------------------------------- |
50
+ | `"actions"` | Rendered to the right of the input, inside the field shell. Useful for a "filters" toggle or a sort control. |
51
+
52
+
53
+ ## Shadow Parts
54
+
55
+ | Part | Description |
56
+ | ----------- | ----------- |
57
+ | `"actions"` | |
58
+ | `"clear"` | |
59
+ | `"hint"` | |
60
+ | `"input"` | |
61
+ | `"shell"` | |
62
+ | `"spinner"` | |
63
+
64
+
65
+ ----------------------------------------------
66
+
67
+ *Built with [StencilJS](https://stenciljs.com/)*