@contentrain/astro-kit 0.31.0 → 0.33.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 (102) hide show
  1. package/catalog.json +4600 -1584
  2. package/components/_shared/Button.astro +2 -1
  3. package/components/_shared/KitImage.astro +9 -4
  4. package/components/_shared/Section.astro +3 -1
  5. package/components/_shared/SectionHeader.astro +27 -0
  6. package/components/_shared/measured.ts +23 -0
  7. package/components/_shared/safe-url.ts +67 -0
  8. package/components/_shared/social.ts +73 -0
  9. package/components/announcement/Announcement.astro +2 -1
  10. package/components/announcement/meta.json +1 -1
  11. package/components/band/Band.astro +3 -2
  12. package/components/band/meta.json +1 -0
  13. package/components/breadcrumb/Breadcrumb.astro +2 -1
  14. package/components/breadcrumb/meta.json +1 -1
  15. package/components/card-grid/CardGrid.astro +2 -1
  16. package/components/card-grid/meta.json +1 -0
  17. package/components/consent/Consent.astro +2 -1
  18. package/components/consent/meta.json +1 -1
  19. package/components/contact-details/ContactDetails.astro +3 -2
  20. package/components/contact-details/meta.json +1 -0
  21. package/components/contact-form/ContactForm.astro +51 -14
  22. package/components/contact-form/fixtures.json +169 -9
  23. package/components/contact-form/meta.json +284 -15
  24. package/components/cta/Cta.astro +52 -12
  25. package/components/cta/fixtures.json +5 -1
  26. package/components/cta/meta.json +11 -2
  27. package/components/dialog/Dialog.astro +113 -0
  28. package/components/dialog/fixtures.json +84 -0
  29. package/components/dialog/meta.json +252 -0
  30. package/components/embed/meta.json +1 -1
  31. package/components/faq/Faq.astro +60 -17
  32. package/components/faq/fixtures.json +79 -0
  33. package/components/faq/meta.json +9 -3
  34. package/components/feature-list/FeatureList.astro +28 -4
  35. package/components/feature-list/meta.json +16 -3
  36. package/components/footer/Footer.astro +5 -4
  37. package/components/footer/meta.json +1 -0
  38. package/components/gallery/Gallery.astro +49 -14
  39. package/components/gallery/fixtures.json +275 -0
  40. package/components/gallery/meta.json +267 -14
  41. package/components/header/Header.astro +17 -5
  42. package/components/header/fixtures.json +90 -1
  43. package/components/header/meta.json +5 -3
  44. package/components/hero/meta.json +1 -0
  45. package/components/language-switcher/LanguageSwitcher.astro +68 -0
  46. package/components/language-switcher/fixtures.json +51 -0
  47. package/components/language-switcher/meta.json +73 -0
  48. package/components/logo-cloud/LogoCloud.astro +86 -21
  49. package/components/logo-cloud/fixtures.json +239 -0
  50. package/components/logo-cloud/meta.json +145 -5
  51. package/components/nav/Nav.astro +77 -24
  52. package/components/nav/NavTree.astro +40 -0
  53. package/components/nav/fixtures.json +165 -0
  54. package/components/nav/meta.json +11 -2
  55. package/components/newsletter/Newsletter.astro +2 -1
  56. package/components/newsletter/meta.json +1 -0
  57. package/components/pagination/Pagination.astro +4 -3
  58. package/components/pagination/meta.json +1 -1
  59. package/components/post-card/PostCard.astro +8 -5
  60. package/components/post-card/meta.json +3 -2
  61. package/components/post-list/PostList.astro +115 -0
  62. package/components/post-list/fixtures.json +387 -0
  63. package/components/post-list/meta.json +309 -0
  64. package/components/pricing/Pricing.astro +128 -24
  65. package/components/pricing/fixtures.json +288 -4
  66. package/components/pricing/meta.json +188 -1
  67. package/components/search-box/SearchBox.astro +51 -0
  68. package/components/search-box/fixtures.json +31 -0
  69. package/components/search-box/meta.json +75 -0
  70. package/components/slider/Slider.astro +19 -115
  71. package/components/slider/SliderTrack.astro +178 -0
  72. package/components/slider/fixtures.json +2 -1
  73. package/components/slider/meta.json +11 -5
  74. package/components/social-links/SocialLinks.astro +74 -0
  75. package/components/social-links/fixtures.json +67 -0
  76. package/components/social-links/meta.json +94 -0
  77. package/components/split/Split.astro +76 -14
  78. package/components/split/fixtures.json +8 -1
  79. package/components/split/meta.json +226 -4
  80. package/components/stats/Stats.astro +54 -11
  81. package/components/stats/fixtures.json +260 -0
  82. package/components/stats/meta.json +151 -4
  83. package/components/steps/Steps.astro +36 -8
  84. package/components/steps/fixtures.json +217 -5
  85. package/components/steps/meta.json +157 -3
  86. package/components/tabs/Tabs.astro +10 -3
  87. package/components/tabs/fixtures.json +4 -0
  88. package/components/tabs/meta.json +5 -2
  89. package/components/team/Team.astro +54 -9
  90. package/components/team/fixtures.json +217 -4
  91. package/components/team/meta.json +169 -3
  92. package/components/team/team.ts +4 -0
  93. package/components/testimonial/Testimonial.astro +71 -21
  94. package/components/testimonial/fixtures.json +73 -0
  95. package/components/testimonial/meta.json +168 -6
  96. package/dist/index.mjs +1 -1
  97. package/dist/index.mjs.map +1 -1
  98. package/mapping/classic.json +124 -3
  99. package/mapping/divi.json +26 -8
  100. package/mapping/elementor.json +133 -14
  101. package/mapping/gutenberg.json +38 -10
  102. package/package.json +2 -2
@@ -1,7 +1,219 @@
1
1
  [
2
- {"name": "list-numbered", "props": {"id": "how", "heading": "How it works", "intro": "Three steps from WordPress to a static site.", "items": [{"title": "Connect", "text": "Point us at your site; we read everything it publishes."}, {"title": "Review", "text": "Check the plan: every page, model and redirect."}, {"title": "Launch", "text": "Deploy the new site; every old address still works."}]}},
3
- {"name": "row-plain-images", "props": {"layout": "row", "style": "plain", "heading": "Our process", "items": [{"title": "Discover", "text": "We learn the site and its readers.", "image": "/kit-fixtures/photo-1.svg"}, {"title": "Design", "text": "We draw the pages and the system.", "image": "/kit-fixtures/photo-2.svg"}, {"title": "Build", "text": "We ship it, fast and accessible.", "image": "/kit-fixtures/photo-3.svg", "imageAlt": "A laptop showing the finished site"}]}},
4
- {"name": "row-numbered", "props": {"layout": "row", "items": [{"title": "Sign up", "text": "One minute, no card."}, {"title": "Import", "text": "Bring your content."}, {"title": "Edit", "text": "Change it in Studio."}, {"title": "Publish", "text": "Push and it is live."}]}},
5
- {"name": "source-numbers", "props": {"heading": "Getting started", "items": [{"number": "01", "title": "Connect", "text": "Point Calm Status at the services you already monitor."}, {"number": "02", "title": "Write", "text": "Pick the words customers see for each kind of problem."}, {"number": "03", "title": "Publish", "text": "Put the page live on your own domain."}]}},
6
- {"name": "row-plain-source-numbers", "props": {"layout": "row", "style": "plain", "items": [{"number": "Step 1", "title": "Call", "text": "We learn what you make and who buys it."}, {"number": "Step 2", "title": "Design and build", "text": "Four weeks of weekly previews."}, {"number": "Step 3", "title": "Launch", "text": "We switch it on together."}]}}
2
+ {
3
+ "name": "list-numbered",
4
+ "props": {
5
+ "id": "how",
6
+ "heading": "How it works",
7
+ "intro": "Three steps from WordPress to a static site.",
8
+ "items": [
9
+ {
10
+ "title": "Connect",
11
+ "text": "Point us at your site; we read everything it publishes."
12
+ },
13
+ {
14
+ "title": "Review",
15
+ "text": "Check the plan: every page, model and redirect."
16
+ },
17
+ {
18
+ "title": "Launch",
19
+ "text": "Deploy the new site; every old address still works."
20
+ }
21
+ ]
22
+ }
23
+ },
24
+ {
25
+ "name": "row-plain-images",
26
+ "props": {
27
+ "layout": "row",
28
+ "style": "plain",
29
+ "heading": "Our process",
30
+ "items": [
31
+ {
32
+ "title": "Discover",
33
+ "text": "We learn the site and its readers.",
34
+ "image": "/kit-fixtures/photo-1.svg"
35
+ },
36
+ {
37
+ "title": "Design",
38
+ "text": "We draw the pages and the system.",
39
+ "image": "/kit-fixtures/photo-2.svg"
40
+ },
41
+ {
42
+ "title": "Build",
43
+ "text": "We ship it, fast and accessible.",
44
+ "image": "/kit-fixtures/photo-3.svg",
45
+ "imageAlt": "A laptop showing the finished site"
46
+ }
47
+ ]
48
+ }
49
+ },
50
+ {
51
+ "name": "row-numbered",
52
+ "props": {
53
+ "layout": "row",
54
+ "items": [
55
+ {
56
+ "title": "Sign up",
57
+ "text": "One minute, no card."
58
+ },
59
+ {
60
+ "title": "Import",
61
+ "text": "Bring your content."
62
+ },
63
+ {
64
+ "title": "Edit",
65
+ "text": "Change it in Studio."
66
+ },
67
+ {
68
+ "title": "Publish",
69
+ "text": "Push and it is live."
70
+ }
71
+ ]
72
+ }
73
+ },
74
+ {
75
+ "name": "source-numbers",
76
+ "props": {
77
+ "heading": "Getting started",
78
+ "items": [
79
+ {
80
+ "number": "01",
81
+ "title": "Connect",
82
+ "text": "Point Calm Status at the services you already monitor."
83
+ },
84
+ {
85
+ "number": "02",
86
+ "title": "Write",
87
+ "text": "Pick the words customers see for each kind of problem."
88
+ },
89
+ {
90
+ "number": "03",
91
+ "title": "Publish",
92
+ "text": "Put the page live on your own domain."
93
+ }
94
+ ]
95
+ }
96
+ },
97
+ {
98
+ "name": "row-plain-source-numbers",
99
+ "props": {
100
+ "layout": "row",
101
+ "style": "plain",
102
+ "items": [
103
+ {
104
+ "number": "Step 1",
105
+ "title": "Call",
106
+ "text": "We learn what you make and who buys it."
107
+ },
108
+ {
109
+ "number": "Step 2",
110
+ "title": "Design and build",
111
+ "text": "Four weeks of weekly previews."
112
+ },
113
+ {
114
+ "number": "Step 3",
115
+ "title": "Launch",
116
+ "text": "We switch it on together."
117
+ }
118
+ ]
119
+ }
120
+ },
121
+ {
122
+ "name": "timeline-rail",
123
+ "props": {
124
+ "id": "history",
125
+ "heading": "Our story",
126
+ "kind": "timeline",
127
+ "marker": "dot",
128
+ "items": [
129
+ {
130
+ "date": "2019",
131
+ "title": "Founded",
132
+ "text": "Two people and a shared desk."
133
+ },
134
+ {
135
+ "date": "Mar 2021",
136
+ "title": "First customers",
137
+ "text": "The first twenty sites moved over."
138
+ },
139
+ {
140
+ "date": "2024",
141
+ "title": "Open source",
142
+ "text": "The toolkit went public.",
143
+ "current": true
144
+ }
145
+ ]
146
+ }
147
+ },
148
+ {
149
+ "name": "timeline-alternate-dashed",
150
+ "props": {
151
+ "kind": "timeline",
152
+ "layout": "alternate",
153
+ "connector": "dashed",
154
+ "items": [
155
+ {
156
+ "date": "2019",
157
+ "title": "Founded",
158
+ "text": "Two people and a shared desk."
159
+ },
160
+ {
161
+ "date": "Mar 2021",
162
+ "title": "First customers",
163
+ "text": "The first twenty sites moved over."
164
+ },
165
+ {
166
+ "date": "2024",
167
+ "title": "Open source",
168
+ "text": "The toolkit went public.",
169
+ "current": true
170
+ }
171
+ ]
172
+ }
173
+ },
174
+ {
175
+ "name": "timeline-no-connector",
176
+ "props": {
177
+ "kind": "timeline",
178
+ "layout": "rail",
179
+ "connector": "none",
180
+ "items": [
181
+ {
182
+ "date": "2019",
183
+ "title": "Founded",
184
+ "text": "Two people and a shared desk."
185
+ },
186
+ {
187
+ "date": "Mar 2021",
188
+ "title": "First customers",
189
+ "text": "The first twenty sites moved over."
190
+ },
191
+ {
192
+ "title": "No date here",
193
+ "text": "An item with no date reads as a step."
194
+ }
195
+ ]
196
+ }
197
+ },
198
+ {
199
+ "name": "rail-numbered",
200
+ "props": {
201
+ "layout": "rail",
202
+ "marker": "number",
203
+ "items": [
204
+ {
205
+ "title": "Connect",
206
+ "text": "Point us at your site; we read everything it publishes."
207
+ },
208
+ {
209
+ "title": "Review",
210
+ "text": "Check the plan: every page, model and redirect."
211
+ },
212
+ {
213
+ "title": "Launch",
214
+ "text": "Deploy the new site; every old address still works."
215
+ }
216
+ ]
217
+ }
218
+ }
7
219
  ]
@@ -9,16 +9,19 @@
9
9
  "Section.astro",
10
10
  "image.ts",
11
11
  "measured.ts",
12
+ "safe-url.ts",
12
13
  "types.ts"
13
14
  ],
14
15
  "variants": {
15
16
  "layout": {
16
17
  "options": [
17
18
  "list",
18
- "row"
19
+ "row",
20
+ "rail",
21
+ "alternate"
19
22
  ],
20
23
  "default": "list",
21
- "description": "A vertical list, or one row across the page on wide screens."
24
+ "description": "list: a vertical list. row: across the page. rail: one rail on the start side. alternate: items alternate either side of a centre rail from md (one rail on phones); DOM order stays chronological."
22
25
  },
23
26
  "style": {
24
27
  "options": [
@@ -27,6 +30,31 @@
27
30
  ],
28
31
  "default": "numbered",
29
32
  "description": "A number badge before each step, or an accent rule beside it."
33
+ },
34
+ "kind": {
35
+ "options": [
36
+ "steps",
37
+ "timeline"
38
+ ],
39
+ "default": "steps",
40
+ "description": "steps: a numbered sequence. timeline: a date per item on a vertical rail (the rail layouts below; without a layout it uses rail)."
41
+ },
42
+ "marker": {
43
+ "options": [
44
+ "number",
45
+ "dot"
46
+ ],
47
+ "default": "number",
48
+ "description": "What sits on the rail: the style's number badge, or a plain dot (the timeline default). Not yet: icon and date markers, and a visual emphasis for the current item (it has aria-current and a hidden label only)."
49
+ },
50
+ "connector": {
51
+ "options": [
52
+ "none",
53
+ "line",
54
+ "dashed"
55
+ ],
56
+ "default": "line",
57
+ "description": "The rail between items on the rail layouts."
30
58
  }
31
59
  },
32
60
  "props": {
@@ -73,6 +101,16 @@
73
101
  "type": "string",
74
102
  "label": "Image description",
75
103
  "description": "What the image shows; empty if decorative."
104
+ },
105
+ "date": {
106
+ "type": "string",
107
+ "label": "Date",
108
+ "description": "A timeline's date as written (\"2019\", \"Mar 2021\"); marked up as a <time> only when it is an ISO date or a year."
109
+ },
110
+ "current": {
111
+ "type": "boolean",
112
+ "label": "Current",
113
+ "description": "Marks the latest item (aria-current=\"step\" and a hidden \"Current\")."
76
114
  }
77
115
  }
78
116
  },
@@ -82,6 +120,122 @@
82
120
  "type": "string",
83
121
  "content": false,
84
122
  "description": "Anchor id of the section; the heading gets `<id>-title`."
123
+ },
124
+ "currentLabel": {
125
+ "type": "string",
126
+ "default": "Current",
127
+ "content": false,
128
+ "description": "Visually hidden text on the current item, so it is not shown by colour alone."
129
+ },
130
+ "measured": {
131
+ "type": "object",
132
+ "content": false,
133
+ "description": "The source section's measured style (MeasuredStyle): frame width, padding, fill, image, columns per width, gap, text tone, radius, minimum height — all in pixels. Absent, the site's tokens; present, it wins over tone and columns. Values that are not a number, a colour or a known keyword are dropped.",
134
+ "fields": {
135
+ "containerPx": {
136
+ "type": "integer",
137
+ "min": 0,
138
+ "max": 2560,
139
+ "description": "The frame's content width."
140
+ },
141
+ "padY": {
142
+ "type": "integer",
143
+ "min": 0,
144
+ "max": 400,
145
+ "description": "Top and bottom padding."
146
+ },
147
+ "padX": {
148
+ "type": "integer",
149
+ "min": 0,
150
+ "max": 200,
151
+ "description": "The frame's side padding (gutter)."
152
+ },
153
+ "bg": {
154
+ "type": "object",
155
+ "description": "The section's background.",
156
+ "fields": {
157
+ "color": {
158
+ "type": "string",
159
+ "description": "Fill colour: hex, rgb(a) or hsl(a)."
160
+ },
161
+ "image": {
162
+ "type": "string",
163
+ "description": "Background image address (http(s) or root-relative), drawn behind the content."
164
+ },
165
+ "overlay": {
166
+ "type": "string",
167
+ "description": "Colour or linear/radial gradient laid over the image or the fill."
168
+ },
169
+ "size": {
170
+ "type": "string",
171
+ "description": "cover, contain or auto; anything else draws as cover."
172
+ },
173
+ "position": {
174
+ "type": "string",
175
+ "description": "One or two of center/top/bottom/left/right or percentages."
176
+ }
177
+ }
178
+ },
179
+ "columns": {
180
+ "type": "object",
181
+ "description": "Item columns at 390 (base), 768 (md) and 1280 (lg) px; a missing width follows the narrower one.",
182
+ "fields": {
183
+ "390": {
184
+ "type": "integer",
185
+ "min": 1,
186
+ "max": 6,
187
+ "description": "Phone."
188
+ },
189
+ "768": {
190
+ "type": "integer",
191
+ "min": 1,
192
+ "max": 6,
193
+ "description": "Tablet."
194
+ },
195
+ "1280": {
196
+ "type": "integer",
197
+ "min": 1,
198
+ "max": 6,
199
+ "description": "Desktop."
200
+ }
201
+ }
202
+ },
203
+ "gap": {
204
+ "type": "integer",
205
+ "min": 0,
206
+ "max": 200,
207
+ "description": "Gap between items."
208
+ },
209
+ "tone": {
210
+ "type": "select",
211
+ "options": [
212
+ "light",
213
+ "dark"
214
+ ],
215
+ "description": "What the text is set for: dark = light text on a dark band. Without it, a fill's own lightness decides."
216
+ },
217
+ "align": {
218
+ "type": "select",
219
+ "options": [
220
+ "start",
221
+ "center",
222
+ "end"
223
+ ],
224
+ "description": "Where the heading and lead sit; read by a component whose own align is not set."
225
+ },
226
+ "radius": {
227
+ "type": "integer",
228
+ "min": 0,
229
+ "max": 200,
230
+ "description": "Card and item corner radius."
231
+ },
232
+ "minHeight": {
233
+ "type": "integer",
234
+ "min": 0,
235
+ "max": 2000,
236
+ "description": "The section's minimum height."
237
+ }
238
+ }
85
239
  }
86
240
  },
87
241
  "slots": {},
@@ -98,7 +252,7 @@
98
252
  "divi/et_pb_blurb"
99
253
  ]
100
254
  },
101
- "a11y": "Steps are an <ol>, so their order and count are announced; the visible numbers are CSS counters, not text. Each title is an <h3> under the section's <h2>, or an <h2> when the section has no heading. The section is labelled by its heading when it has one.",
255
+ "a11y": "Steps are an <ol>, so their order and count are announced; the visible numbers are CSS counters, not text. Each title is an <h3> under the section's <h2>, or an <h2> when the section has no heading. The section is labelled by its heading when it has one. A timeline is the same <ol>; a date is a <time datetime> only when machine-readable; the rail and dots are CSS and not in the accessibility tree.",
102
256
  "pattern": {
103
257
  "summary": "ordered sequence: numbered title + short text units",
104
258
  "signals": [
@@ -5,7 +5,8 @@
5
5
  // the same markup into tabs.
6
6
  import { tv } from 'tailwind-variants'
7
7
  import Section from '../_shared/Section.astro'
8
- import type { MeasuredStyle } from '../_shared/types'
8
+ import SectionHeader from '../_shared/SectionHeader.astro'
9
+ import type { ActionInput, MeasuredStyle } from '../_shared/types'
9
10
 
10
11
  export interface TabItem {
11
12
  label: string
@@ -42,6 +43,10 @@ const tabs = tv({
42
43
  interface Props {
43
44
  items: TabItem[]
44
45
  heading?: string | undefined
46
+ /** The rest of the section's head, drawn with the heading when any is given. */
47
+ intro?: string | undefined
48
+ eyebrow?: string | undefined
49
+ actions?: ActionInput[] | undefined
45
50
  /** Accessible name of the tab list when there is no heading. */
46
51
  label?: string | undefined
47
52
  style?: 'underline' | 'pills' | undefined
@@ -52,7 +57,7 @@ interface Props {
52
57
  measured?: MeasuredStyle | undefined
53
58
  }
54
59
 
55
- const { items, heading, label, style, orientation = 'horizontal', tone, id = 'tabs', measured } = Astro.props
60
+ const { items, heading, intro, eyebrow, actions, label, style, orientation = 'horizontal', tone, id = 'tabs', measured } = Astro.props
56
61
  const s = tabs({ style, orientation, tone })
57
62
  const headingId = heading ? `${id}-title` : undefined
58
63
  // One level under the section heading, or the section's heading when there is none.
@@ -61,7 +66,9 @@ const value = (i: number) => `tab-${i + 1}`
61
66
  ---
62
67
  {items.length > 0 && (
63
68
  <Section measured={measured} tone={tone} spacing="md" id={id} aria-labelledby={headingId}>
64
- {heading && <h2 id={headingId} class={s.heading()}>{heading}</h2>}
69
+ {intro || eyebrow || actions?.length
70
+ ? <SectionHeader heading={heading} intro={intro} eyebrow={eyebrow} actions={actions} headingId={headingId} />
71
+ : heading && <h2 id={headingId} class={s.heading()}>{heading}</h2>}
65
72
  <div class={s.root()} data-kit-tabs={id} data-orientation={orientation} data-label={label ?? heading ?? undefined}>
66
73
  <div class={s.list()} data-part="list">
67
74
  {items.map((item, i) => <button type="button" class={s.trigger()} data-part="trigger" data-value={value(i)}>{item.label}</button>)}
@@ -33,5 +33,9 @@
33
33
  {
34
34
  "name": "pills-accent",
35
35
  "props": { "style": "pills", "tone": "accent", "label": "Formats", "id": "formats", "items": [{ "label": "Online", "content": "<p>Read on the site.</p>" }, { "label": "Print", "content": "<p>Delivered each season.</p>" }] }
36
+ },
37
+ {
38
+ "name": "four-measured",
39
+ "props": { "eyebrow": "Plans", "heading": "The plan", "intro": "What each part of the plan includes.", "actions": [{ "label": "Compare plans", "href": "/plans/" }], "id": "plan", "items": [{ "label": "Overview", "content": "<p>What the plan covers.</p>" }, { "label": "Pricing", "content": "<p>What it costs each month.</p>" }, { "label": "Security", "content": "<p>How data is kept safe.</p>" }, { "label": "Support", "content": "<p>How to reach a person.</p>" }], "measured": { "padY": 40, "containerPx": 880 } }
36
40
  }
37
41
  ]
@@ -4,13 +4,16 @@
4
4
  "description": "Tabbed panels with the WAI-ARIA tabs pattern from Zag.js (arrow keys, Home/End, roving focus). Without script every panel shows in turn under its own title, so no content is ever hidden from a reader or a crawler.",
5
5
  "category": "content",
6
6
  "uses": [],
7
- "shared": ["Section.astro", "measured.ts", "types.ts"],
7
+ "shared": ["Button.astro", "Section.astro", "SectionHeader.astro", "measured.ts", "safe-url.ts", "types.ts"],
8
8
  "variants": {
9
9
  "style": { "options": ["underline", "pills"], "default": "underline", "description": "underline: a rule under the list, the selected tab underlined in accent. pills: the selected tab filled with accent." },
10
10
  "orientation": { "options": ["horizontal", "vertical"], "default": "horizontal", "description": "vertical: the tab list beside the panel on wide screens (Elementor vertical tabs)." },
11
11
  "tone": { "options": ["default", "muted", "inverse", "accent"], "default": "default", "description": "Section background." }
12
12
  },
13
13
  "props": {
14
+ "intro": {"type": "text", "label": "Introduction", "description": "A sentence under the heading."},
15
+ "eyebrow": {"type": "string", "label": "Eyebrow", "description": "A short line above the heading."},
16
+ "actions": {"type": "array", "label": "Buttons", "required": false, "items": {"type": "object", "fields": {"label": {"type": "string", "label": "Label", "required": true, "description": "The button text."}, "href": {"type": "url", "label": "Link", "required": true, "description": "Where the button goes: a page on this site or a full address."}, "newTab": {"type": "boolean", "label": "Open in a new tab", "description": "Open the link in a new tab (an external site the reader should not leave for)."}, "style": {"type": "select", "label": "Style", "options": ["primary", "secondary", "ghost"], "description": "primary for the main action, secondary or ghost for the ones beside it."}}}, "description": "Buttons under the intro, in order; none by default."},
14
17
  "measured": {"type": "object", "content": false, "description": "The source section's measured style (MeasuredStyle): frame width, padding, fill, image, columns per width, gap, text tone, radius, minimum height — all in pixels. Absent, the site's tokens; present, it wins over tone and columns. Values that are not a number, a colour or a known keyword are dropped.", "fields": {"containerPx": {"type": "integer", "min": 0, "max": 2560, "description": "The frame's content width."}, "padY": {"type": "integer", "min": 0, "max": 400, "description": "Top and bottom padding."}, "padX": {"type": "integer", "min": 0, "max": 200, "description": "The frame's side padding (gutter)."}, "bg": {"type": "object", "description": "The section's background.", "fields": {"color": {"type": "string", "description": "Fill colour: hex, rgb(a) or hsl(a)."}, "image": {"type": "string", "description": "Background image address (http(s) or root-relative), drawn behind the content."}, "overlay": {"type": "string", "description": "Colour or linear/radial gradient laid over the image or the fill."}, "size": {"type": "string", "description": "cover, contain or auto; anything else draws as cover."}, "position": {"type": "string", "description": "One or two of center/top/bottom/left/right or percentages."}}}, "columns": {"type": "object", "description": "Item columns at 390 (base), 768 (md) and 1280 (lg) px; a missing width follows the narrower one.", "fields": {"390": {"type": "integer", "min": 1, "max": 6, "description": "Phone."}, "768": {"type": "integer", "min": 1, "max": 6, "description": "Tablet."}, "1280": {"type": "integer", "min": 1, "max": 6, "description": "Desktop."}}}, "gap": {"type": "integer", "min": 0, "max": 200, "description": "Gap between items."}, "tone": {"type": "select", "options": ["light", "dark"], "description": "What the text is set for: dark = light text on a dark band. Without it, a fill's own lightness decides."}, "align": {"type": "select", "options": ["start", "center", "end"], "description": "Where the heading and lead sit; read by a component whose own align is not set."}, "radius": {"type": "integer", "min": 0, "max": 200, "description": "Card and item corner radius."}, "minHeight": {"type": "integer", "min": 0, "max": 2000, "description": "The section's minimum height."}}},
15
18
  "items": { "type": "array", "required": true, "items": { "type": "object", "fields": { "label": { "type": "string", "required": true }, "content": { "type": "richtext", "required": true } } }, "description": "Tabs in order (Elementor tabs tab_title/tab_content, Divi tab title + content)." },
16
19
  "heading": { "type": "string", "description": "Section heading; also names the tab list." },
@@ -23,7 +26,7 @@
23
26
  "sources": {
24
27
  "elementor": ["elementor/tabs", "elementor/nested-tabs"],
25
28
  "divi": ["divi/et_pb_tabs"],
26
- "classic": ["classic/tabs"]
29
+ "classic": ["classic/tabs", "classic/aria-tabs"]
27
30
  },
28
31
  "a11y": "Zag.js tabs: role=tablist/tab/tabpanel with aria-selected, aria-controls and aria-labelledby; arrow keys (per orientation), Home and End move between tabs. Before the script runs every panel is visible with its own title (an h3 under the section's h2, an h2 when there is no heading); after, the titles become screen-reader-only and the tabs label the panels."
29
32
  }
@@ -4,7 +4,11 @@
4
4
  import { tv } from 'tailwind-variants'
5
5
  import KitImage from '../_shared/KitImage.astro'
6
6
  import Section from '../_shared/Section.astro'
7
+ import { measuredGrid } from '../_shared/measured'
8
+ import type { MeasuredStyle } from '../_shared/types'
7
9
  import { imageOf, type ImageSource } from '../_shared/image'
10
+ import { profile } from './team'
11
+ import { safeHref } from '../_shared/safe-url'
8
12
 
9
13
  const team = tv({
10
14
  slots: {
@@ -12,6 +16,9 @@ const team = tv({
12
16
  heading: 'text-3xl [font-weight:var(--font-weight-heading,700)] tracking-tight text-balance md:text-4xl',
13
17
  intro: 'text-lg opacity-80',
14
18
  list: 'grid gap-6 sm:grid-cols-2 md:gap-8',
19
+ socials: 'relative z-10 mt-1 flex flex-wrap gap-3 text-sm',
20
+ social: 'rounded text-accent-text underline-offset-2 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',
21
+ more: 'text-pretty opacity-80 [&>summary]:text-sm [&>summary]:font-medium [&>summary]:text-accent-text',
15
22
  member: 'group relative flex flex-col gap-3',
16
23
  portrait: 'overflow-hidden rounded-[var(--radius-image,var(--radius-card))]',
17
24
  name: 'text-lg leading-snug [font-weight:var(--font-weight-heading,600)]',
@@ -23,7 +30,16 @@ const team = tv({
23
30
  '2': {},
24
31
  '3': { list: 'md:grid-cols-3' },
25
32
  '4': { list: 'lg:grid-cols-4' },
33
+ '5': { list: 'md:grid-cols-3 lg:grid-cols-5' },
26
34
  },
35
+ // How the members are laid out: a grid, one stacked row each (photo beside text), or the first member large over a grid.
36
+ layout: {
37
+ grid: {},
38
+ list: { list: 'grid-cols-1 sm:grid-cols-1', member: 'sm:flex-row sm:items-start sm:gap-6', portrait: 'sm:w-48 sm:shrink-0' },
39
+ featured: { list: '[&>li:first-child]:col-span-full [&>li:first-child]:sm:flex-row [&>li:first-child]:sm:gap-8 [&>li:first-child_.kit-portrait]:sm:w-1/2' },
40
+ },
41
+ imageShape: { square: {}, rounded: { portrait: 'rounded-[var(--radius-image,1rem)]' }, circle: { portrait: 'rounded-full' } },
42
+ align: { start: {}, center: { member: 'items-center text-center', socials: 'justify-center' } },
27
43
  style: {
28
44
  card: { member: 'rounded-card border border-line p-6', portrait: '-mx-6 -mt-6 mb-2 rounded-b-none' },
29
45
  plain: {},
@@ -31,7 +47,7 @@ const team = tv({
31
47
  // accent: a small accent line. plain: body-size text in the text colour, as a byline.
32
48
  roleStyle: { accent: {}, plain: { role: 'text-[length:var(--text-body,1rem)] font-normal text-current' } },
33
49
  },
34
- defaultVariants: { columns: '3', style: 'card', roleStyle: 'accent' },
50
+ defaultVariants: { columns: '3', layout: 'grid', imageShape: 'square', align: 'start', style: 'card', roleStyle: 'accent' },
35
51
  })
36
52
 
37
53
  interface Member {
@@ -42,44 +58,73 @@ interface Member {
42
58
  image?: ImageSource | undefined
43
59
  imageAlt?: string | undefined
44
60
  href?: string | undefined
61
+ /** Profile addresses (the contract's `links[]`); each is labelled by the network its host names, never an invented one. */
62
+ links?: string[] | undefined
45
63
  }
46
64
 
47
65
  interface Props {
48
66
  heading?: string | undefined
49
67
  intro?: string | undefined
50
68
  items: Member[]
51
- columns?: '2' | '3' | '4' | undefined
69
+ columns?: '2' | '3' | '4' | '5' | undefined
70
+ layout?: 'grid' | 'list' | 'featured' | undefined
71
+ imageShape?: 'square' | 'rounded' | 'circle' | undefined
72
+ align?: 'start' | 'center' | undefined
73
+ /** Clamp a long bio to this many lines with the rest in a native `<details>` ("Read more"). */
74
+ bioClamp?: 2 | 3 | 4 | undefined
75
+ readMoreLabel?: string | undefined
76
+ /** The accessible name of a profile link; `{name}` and `{network}` are replaced ("{name} on {network}"). */
77
+ socialLabel?: string | undefined
52
78
  style?: 'card' | 'plain' | undefined
53
79
  roleStyle?: 'accent' | 'plain' | undefined
80
+ /** The source section's measured style (`MeasuredStyle`): frame, padding, fill, background image, columns per width, gap. Absent, the site's tokens; present, it wins over tone and columns. */
81
+ measured?: MeasuredStyle | undefined
54
82
  id?: string | undefined
55
83
  }
56
84
 
57
- const { heading, intro, items, columns, style, roleStyle, id } = Astro.props
58
- const s = team({ columns, style, roleStyle })
85
+ const { heading, intro, items, columns, layout, imageShape, align, bioClamp, readMoreLabel = 'Read more', socialLabel = '{name} on {network}', style, roleStyle, measured, id } = Astro.props
86
+ const s = team({ columns, layout, imageShape, align, style, roleStyle })
87
+ const clamp = { 2: 'line-clamp-2', 3: 'line-clamp-3', 4: 'line-clamp-4' } as const
88
+ // A bio that fits the clamp needs no disclosure: short means about 160 characters per two lines, a rough, build-time guess.
89
+ const longBio = (bio: string) => bioClamp !== undefined && bio.length > bioClamp * 80
59
90
  const headingId = heading && id ? `${id}-title` : undefined
60
91
  // One level under the section heading, or the section's heading when there is none.
61
92
  const NameHeading = heading ? 'h3' : 'h2'
62
93
  ---
63
94
  {items.length > 0 && (
64
- <Section id={id} aria-labelledby={headingId}>
95
+ <Section measured={measured} id={id} aria-labelledby={headingId}>
65
96
  {(heading || intro) && (
66
97
  <header class={s.header()}>
67
98
  {heading && <h2 id={headingId} class={s.heading()}>{heading}</h2>}
68
99
  {intro && <p class={s.intro()} data-kit-text>{intro}</p>}
69
100
  </header>
70
101
  )}
71
- <ul class={s.list()}>
102
+ <ul class={s.list({ class: measuredGrid(measured) })}>
72
103
  {items.map(member => (
73
104
  <li class={s.member()}>
74
105
  {/* The portrait's text defaults to the name: a photo of a named person is not decoration. */}
75
- {imageOf(member.image, member.imageAlt ?? member.name) && <div class={s.portrait()}><KitImage image={imageOf(member.image, member.imageAlt ?? member.name)!} sizes="(min-width: 1024px) 25vw, (min-width: 640px) 50vw, 100vw" class="aspect-square w-full object-cover" /></div>}
106
+ {imageOf(member.image, member.imageAlt ?? member.name) && <div class={`${s.portrait()} kit-portrait`}><KitImage image={imageOf(member.image, member.imageAlt ?? member.name)!} sizes="(min-width: 1024px) 25vw, (min-width: 640px) 50vw, 100vw" class="aspect-square w-full object-cover" /></div>}
76
107
  <NameHeading class={s.name()}>
77
108
  {member.href
78
- ? <a href={member.href} class="text-current no-underline after:absolute after:inset-0 after:content-[''] hover:text-accent-text">{member.name}</a>
109
+ ? <a href={safeHref(member.href)} class="text-current no-underline after:absolute after:inset-0 after:content-[''] hover:text-accent-text">{member.name}</a>
79
110
  : member.name}
80
111
  </NameHeading>
81
112
  {member.role && <p class={s.role()}>{member.role}</p>}
82
- {member.bio && <p class={s.bio()} data-kit-text>{member.bio}</p>}
113
+ {member.bio && (longBio(member.bio)
114
+ ? (
115
+ <div class="[&:has(details[open])>p]:hidden">
116
+ <p class={`${s.bio()} ${clamp[bioClamp!]}`} data-kit-text>{member.bio}</p>
117
+ <details class={s.more()}><summary>{readMoreLabel}</summary><p class={s.bio()} data-kit-text>{member.bio}</p></details>
118
+ </div>
119
+ )
120
+ : <p class={s.bio()} data-kit-text>{member.bio}</p>)}
121
+ {member.links && member.links.length > 0 && (
122
+ <ul class={s.socials()}>
123
+ {member.links.map(profile).map(link => (
124
+ <li><a href={safeHref(link.href)} class={s.social()} aria-label={socialLabel.replace('{name}', member.name).replace('{network}', link.label)}>{link.label}</a></li>
125
+ ))}
126
+ </ul>
127
+ )}
83
128
  </li>
84
129
  ))}
85
130
  </ul>