@contentrain/astro-kit 0.42.0 → 0.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/catalog.json +121 -10
- package/components/band/Band.astro +8 -3
- package/components/band/fixtures.json +2 -1
- package/components/band/meta.json +17 -0
- package/components/footer/Footer.astro +13 -2
- package/components/footer/fixtures.json +7 -1
- package/components/footer/footer.ts +37 -0
- package/components/footer/meta.json +17 -0
- package/components/gallery/Gallery.astro +19 -9
- package/components/gallery/GalleryLightbox.astro +110 -0
- package/components/gallery/fixtures.json +52 -0
- package/components/gallery/meta.json +24 -5
- package/components/hero/fixtures.json +2 -1
- package/components/pricing/Pricing.astro +38 -3
- package/components/pricing/fixtures.json +84 -0
- package/components/pricing/meta.json +44 -3
- package/components/testimonial/Testimonial.astro +10 -2
- package/components/testimonial/fixtures.json +2 -1
- package/components/testimonial/meta.json +9 -0
- package/package.json +2 -2
package/catalog.json
CHANGED
|
@@ -103,6 +103,7 @@
|
|
|
103
103
|
"shared": [
|
|
104
104
|
"Button.astro",
|
|
105
105
|
"KitImage.astro",
|
|
106
|
+
"Lines.astro",
|
|
106
107
|
"Section.astro",
|
|
107
108
|
"instance-id.ts",
|
|
108
109
|
"measured.ts",
|
|
@@ -146,6 +147,22 @@
|
|
|
146
147
|
],
|
|
147
148
|
"default": "default",
|
|
148
149
|
"description": "Section background when nothing is measured."
|
|
150
|
+
},
|
|
151
|
+
"headingLines": {
|
|
152
|
+
"options": [
|
|
153
|
+
"flow",
|
|
154
|
+
"lines"
|
|
155
|
+
],
|
|
156
|
+
"default": "flow",
|
|
157
|
+
"description": "flow: the heading is one run of text. lines: each line the author broke it into (a line break, \\n, in the heading) is a block of its own, data-kit-line=\"1\", \"2\", …, so a theme or a design layer can give a line its own ink, indent or size. Still one heading, read as one sentence."
|
|
158
|
+
},
|
|
159
|
+
"leadLines": {
|
|
160
|
+
"options": [
|
|
161
|
+
"flow",
|
|
162
|
+
"lines"
|
|
163
|
+
],
|
|
164
|
+
"default": "flow",
|
|
165
|
+
"description": "flow: the lead is one run of text. lines: each line the author broke it into (a line break, \\n, in the lead) is a block of its own, data-kit-line=\"1\", \"2\", …, so a theme or a design layer can give a line its own ink, indent or size. Still one paragraph."
|
|
149
166
|
}
|
|
150
167
|
},
|
|
151
168
|
"props": {
|
|
@@ -2959,6 +2976,23 @@
|
|
|
2959
2976
|
],
|
|
2960
2977
|
"default": "stack",
|
|
2961
2978
|
"description": "Packed columns only, below the md breakpoint. stack: one column. row: still side by side from the start edge, under the brand."
|
|
2979
|
+
},
|
|
2980
|
+
"wordmark": {
|
|
2981
|
+
"options": [
|
|
2982
|
+
"none",
|
|
2983
|
+
"fit",
|
|
2984
|
+
"crop"
|
|
2985
|
+
],
|
|
2986
|
+
"default": "none",
|
|
2987
|
+
"description": "none: no wordmark. fit: the site name drawn once more as a row exactly as wide as the footer, in the heading font and the footer's ink. crop: the same row cut by the footer's bottom edge, only the letters' top showing. A name in a script letter spacing breaks (Arabic, Hebrew, Indic, Thai, …) gets none. Decoration, hidden from screen readers: the name is already the footer's brand."
|
|
2988
|
+
},
|
|
2989
|
+
"wordmarkOrder": {
|
|
2990
|
+
"options": [
|
|
2991
|
+
"last",
|
|
2992
|
+
"first"
|
|
2993
|
+
],
|
|
2994
|
+
"default": "last",
|
|
2995
|
+
"description": "Where the wordmark row sits: after everything else in the footer (last) or before it (first). Only with a wordmark."
|
|
2962
2996
|
}
|
|
2963
2997
|
},
|
|
2964
2998
|
"props": {
|
|
@@ -3130,7 +3164,7 @@
|
|
|
3130
3164
|
{
|
|
3131
3165
|
"id": "gallery",
|
|
3132
3166
|
"name": "Gallery",
|
|
3133
|
-
"description": "Images in a square grid or in masonry columns, with optional captions. A click shows the image large in a native popover (no script), opens the image file, or does nothing.",
|
|
3167
|
+
"description": "Images in a square grid or in masonry columns, with optional captions. A click shows the image large in a native popover (no script), steps through the set in a lightbox viewer, opens the image file, or does nothing.",
|
|
3134
3168
|
"category": "section",
|
|
3135
3169
|
"uses": [],
|
|
3136
3170
|
"shared": [
|
|
@@ -3169,10 +3203,11 @@
|
|
|
3169
3203
|
"options": [
|
|
3170
3204
|
"popover",
|
|
3171
3205
|
"link",
|
|
3172
|
-
"none"
|
|
3206
|
+
"none",
|
|
3207
|
+
"lightbox"
|
|
3173
3208
|
],
|
|
3174
3209
|
"default": "popover",
|
|
3175
|
-
"description": "popover: the image opens large in place (Popover API; Escape or a click outside closes it). link: the thumbnail links to the image file (the WordPress 'link to media file'). none: images only."
|
|
3210
|
+
"description": "popover: the image opens large in place (Popover API; Escape or a click outside closes it). link: the thumbnail links to the image file (the WordPress 'link to media file'). none: images only. lightbox: each thumbnail links to its large file (full, else the image); with script a click opens one modal viewer for the whole set, with previous / next, ←/→, a counter and the caption (a source lightbox: Elementor's slideshow group, a WordPress gallery with a lightbox plugin). The viewer and its script are printed only for this option."
|
|
3176
3211
|
},
|
|
3177
3212
|
"aspect": {
|
|
3178
3213
|
"options": [
|
|
@@ -3253,6 +3288,24 @@
|
|
|
3253
3288
|
"content": false,
|
|
3254
3289
|
"description": "Text of the viewer's close button (ui-strings gallery.close)."
|
|
3255
3290
|
},
|
|
3291
|
+
"prevLabel": {
|
|
3292
|
+
"type": "string",
|
|
3293
|
+
"default": "Previous image",
|
|
3294
|
+
"content": false,
|
|
3295
|
+
"description": "Name of the lightbox viewer's previous button (ui-strings slider.prev)."
|
|
3296
|
+
},
|
|
3297
|
+
"nextLabel": {
|
|
3298
|
+
"type": "string",
|
|
3299
|
+
"default": "Next image",
|
|
3300
|
+
"content": false,
|
|
3301
|
+
"description": "Name of the lightbox viewer's next button (ui-strings slider.next)."
|
|
3302
|
+
},
|
|
3303
|
+
"ofLabel": {
|
|
3304
|
+
"type": "string",
|
|
3305
|
+
"default": "of",
|
|
3306
|
+
"content": false,
|
|
3307
|
+
"description": "The word of the lightbox viewer's counter between position and total (\"2 of 5\"; ui-strings slider.of)."
|
|
3308
|
+
},
|
|
3256
3309
|
"tone": {
|
|
3257
3310
|
"type": "select",
|
|
3258
3311
|
"options": [
|
|
@@ -3393,7 +3446,7 @@
|
|
|
3393
3446
|
}
|
|
3394
3447
|
},
|
|
3395
3448
|
"slots": {},
|
|
3396
|
-
"js": "
|
|
3449
|
+
"js": "vanilla",
|
|
3397
3450
|
"dependencies": {},
|
|
3398
3451
|
"sources": {
|
|
3399
3452
|
"gutenberg": [
|
|
@@ -3410,9 +3463,10 @@
|
|
|
3410
3463
|
"classic/gallery"
|
|
3411
3464
|
]
|
|
3412
3465
|
},
|
|
3413
|
-
"a11y": "A list of figures. In popover mode each thumbnail is a <button> named by the image's alt (or caption) that opens the viewer; the viewer has a visible close button, and Escape closes it, returning focus to the thumbnail. Captions are <figcaption>s. A scrolling row is a labelled region with tabindex 0 so the keyboard can scroll it; thumbnail buttons are named \"Enlarge: …\". The viewer
|
|
3466
|
+
"a11y": "A list of figures. In popover mode each thumbnail is a <button> named by the image's alt (or caption) that opens the viewer; the viewer has a visible close button, and Escape closes it, returning focus to the thumbnail. Captions are <figcaption>s. A scrolling row is a labelled region with tabindex 0 so the keyboard can scroll it; thumbnail buttons are named \"Enlarge: …\". The popover viewer shows one image. In lightbox mode each thumbnail is a link to its large file, named \"Enlarge: …\"; with script it opens one modal <dialog> named by the heading (or the region label): focus moves into it and stays there (the page behind is inert), Escape or a click outside the image closes it and focus returns to the thumbnail, labelled previous / next buttons and ←/→ (mirrored right to left), Home and End step through the set, and the counter (\"2 of 5\") and caption are announced as they change. Nothing animates. Without script the links open the files. Other modes ship no script.",
|
|
3414
3467
|
"files": [
|
|
3415
|
-
"Gallery.astro"
|
|
3468
|
+
"Gallery.astro",
|
|
3469
|
+
"GalleryLightbox.astro"
|
|
3416
3470
|
],
|
|
3417
3471
|
"contentFields": {
|
|
3418
3472
|
"items": "items",
|
|
@@ -5382,7 +5436,7 @@
|
|
|
5382
5436
|
"radio"
|
|
5383
5437
|
],
|
|
5384
5438
|
"default": "none",
|
|
5385
|
-
"description": "radio: two labelled radios swap each plan's price for its priceAlt (monthly / yearly), with no script. The radio group is named from the section id, or a hash of the plans without one, so two toggles on a page stay independent."
|
|
5439
|
+
"description": "radio: two labelled radios swap each plan's price for its priceAlt (monthly / yearly), with no script. The radio group is named from the section id, or a hash of the plans without one, so two toggles on a page stay independent. A plan's priceWas, priceNote and priceFoot swap for their Alt values the same way, and billingAltNote shows only with the second option."
|
|
5386
5440
|
}
|
|
5387
5441
|
},
|
|
5388
5442
|
"props": {
|
|
@@ -5453,12 +5507,42 @@
|
|
|
5453
5507
|
"priceAlt": {
|
|
5454
5508
|
"type": "string",
|
|
5455
5509
|
"label": "Price, other period",
|
|
5456
|
-
"description": "The price for the other billing period (\"€150\"), shown when the toggle is on its second option."
|
|
5510
|
+
"description": "The price for the other billing period (\"€150\"), shown when the toggle is on its second option. Without it the plan's price (and its struck price) shows under both options: a free plan, \"Contact us\", or one price for both periods. Its notes do not: they stay first-period lines."
|
|
5457
5511
|
},
|
|
5458
5512
|
"periodAlt": {
|
|
5459
5513
|
"type": "string",
|
|
5460
5514
|
"label": "Period, other period",
|
|
5461
5515
|
"description": "What the other price is per (\"/year\")."
|
|
5516
|
+
},
|
|
5517
|
+
"priceWas": {
|
|
5518
|
+
"type": "string",
|
|
5519
|
+
"label": "Original price",
|
|
5520
|
+
"description": "The original price, struck before the price (\"$35\"; cards layout)."
|
|
5521
|
+
},
|
|
5522
|
+
"priceWasAlt": {
|
|
5523
|
+
"type": "string",
|
|
5524
|
+
"label": "Original price, other period",
|
|
5525
|
+
"description": "The other period's struck original price, shown with priceAlt. No fallback: without it, the second option shows no struck price (priceWas belongs to the first period only, except on a plan with no priceAlt, whose whole price line shows under both)."
|
|
5526
|
+
},
|
|
5527
|
+
"priceNote": {
|
|
5528
|
+
"type": "string",
|
|
5529
|
+
"label": "Price note",
|
|
5530
|
+
"description": "One line under the price (\"USD for the first month\"; cards layout)."
|
|
5531
|
+
},
|
|
5532
|
+
"priceNoteAlt": {
|
|
5533
|
+
"type": "string",
|
|
5534
|
+
"label": "Price note, other period",
|
|
5535
|
+
"description": "The other period's line under the price, shown with priceAlt. No fallback: without it, the second option shows no line there (a note like \"for the first month\" would be wrong under the other price)."
|
|
5536
|
+
},
|
|
5537
|
+
"priceFoot": {
|
|
5538
|
+
"type": "string",
|
|
5539
|
+
"label": "Footnote",
|
|
5540
|
+
"description": "One line at the card's foot, above the button (\"Pay $0 today, $35 after the first month\"; cards layout)."
|
|
5541
|
+
},
|
|
5542
|
+
"priceFootAlt": {
|
|
5543
|
+
"type": "string",
|
|
5544
|
+
"label": "Footnote, other period",
|
|
5545
|
+
"description": "The other period's line at the card's foot, shown with priceAlt. No fallback: without it, the second option shows no line there."
|
|
5462
5546
|
}
|
|
5463
5547
|
}
|
|
5464
5548
|
},
|
|
@@ -5620,6 +5704,17 @@
|
|
|
5620
5704
|
"default": "Billing period",
|
|
5621
5705
|
"content": false,
|
|
5622
5706
|
"description": "Legend of the toggle, read to screen readers."
|
|
5707
|
+
},
|
|
5708
|
+
"billingAltNote": {
|
|
5709
|
+
"type": "string",
|
|
5710
|
+
"label": "Second-option note",
|
|
5711
|
+
"description": "A line under the billing toggle shown only while its second option is chosen (\"Save $70 by paying annually\")."
|
|
5712
|
+
},
|
|
5713
|
+
"wasLabel": {
|
|
5714
|
+
"type": "string",
|
|
5715
|
+
"default": "Was",
|
|
5716
|
+
"content": false,
|
|
5717
|
+
"description": "Read before a struck original price by screen readers, which do not announce the strike. 'Was' is only the kit default: the writer passes it in the site's language."
|
|
5623
5718
|
}
|
|
5624
5719
|
},
|
|
5625
5720
|
"slots": {},
|
|
@@ -5636,7 +5731,7 @@
|
|
|
5636
5731
|
"divi/et_pb_pricing_tables"
|
|
5637
5732
|
]
|
|
5638
5733
|
},
|
|
5639
|
-
"a11y": "Plans are a <ul>; each name is an <h3> under the section's <h2>, or an <h2> when the section has no heading. A featured plan is marked by a text badge and a ring, not colour alone. Buttons are links with their own label. The table layout is a real <table> with a caption, column and row headers, and Included / Not included text in every cell; the billing toggle is a fieldset with a legend and labelled radios.",
|
|
5734
|
+
"a11y": "Plans are a <ul>; each name is an <h3> under the section's <h2>, or an <h2> when the section has no heading. A featured plan is marked by a text badge and a ring, not colour alone. Buttons are links with their own label. The table layout is a real <table> with a caption, column and row headers, and Included / Not included text in every cell; the billing toggle is a fieldset with a legend and labelled radios. A struck original price is a <del> read as \"Was <price>\" (the strike alone is not announced); the period's notes swap with its price, hidden (display: none) for the period not chosen.",
|
|
5640
5735
|
"pattern": {
|
|
5641
5736
|
"summary": "repeated plan units: name + price + feature list + button",
|
|
5642
5737
|
"signals": [
|
|
@@ -5663,7 +5758,14 @@
|
|
|
5663
5758
|
"plans[].featured": "plans[].featured",
|
|
5664
5759
|
"plans[].badge": "plans[].badge",
|
|
5665
5760
|
"plans[].priceAlt": "plans[].price_alt",
|
|
5666
|
-
"plans[].periodAlt": "plans[].period_alt"
|
|
5761
|
+
"plans[].periodAlt": "plans[].period_alt",
|
|
5762
|
+
"plans[].priceWas": "plans[].price_was",
|
|
5763
|
+
"plans[].priceWasAlt": "plans[].price_was_alt",
|
|
5764
|
+
"plans[].priceNote": "plans[].price_note",
|
|
5765
|
+
"plans[].priceNoteAlt": "plans[].price_note_alt",
|
|
5766
|
+
"plans[].priceFoot": "plans[].price_foot",
|
|
5767
|
+
"plans[].priceFootAlt": "plans[].price_foot_alt",
|
|
5768
|
+
"billingAltNote": "billing_alt_note"
|
|
5667
5769
|
}
|
|
5668
5770
|
},
|
|
5669
5771
|
{
|
|
@@ -7852,6 +7954,7 @@
|
|
|
7852
7954
|
"uses": [],
|
|
7853
7955
|
"shared": [
|
|
7854
7956
|
"KitImage.astro",
|
|
7957
|
+
"Lines.astro",
|
|
7855
7958
|
"Section.astro",
|
|
7856
7959
|
"image.ts",
|
|
7857
7960
|
"instance-id.ts",
|
|
@@ -7924,6 +8027,14 @@
|
|
|
7924
8027
|
],
|
|
7925
8028
|
"default": "marks",
|
|
7926
8029
|
"description": "marks: quotation marks around the text. glyph: one large decorative mark above. none: no marks."
|
|
8030
|
+
},
|
|
8031
|
+
"quoteLines": {
|
|
8032
|
+
"options": [
|
|
8033
|
+
"flow",
|
|
8034
|
+
"lines"
|
|
8035
|
+
],
|
|
8036
|
+
"default": "flow",
|
|
8037
|
+
"description": "flow: the quote is one run of text. lines: each line the author broke it into (a line break, \\n, in the quote) is a block of its own, data-kit-line=\"1\", \"2\", …, so a theme or a design layer can give a line its own ink, indent or size. Still one quote in one paragraph; its marks go before the first line and after the last."
|
|
7927
8038
|
}
|
|
7928
8039
|
},
|
|
7929
8040
|
"props": {
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// one of the site's sections and never as bare text.
|
|
5
5
|
import { tv } from 'tailwind-variants'
|
|
6
6
|
import Button from '../_shared/Button.astro'
|
|
7
|
+
import Lines from '../_shared/Lines.astro'
|
|
7
8
|
import KitImage from '../_shared/KitImage.astro'
|
|
8
9
|
import Section, { type Tone } from '../_shared/Section.astro'
|
|
9
10
|
import { measuredGrid } from '../_shared/measured'
|
|
@@ -83,9 +84,13 @@ interface Props {
|
|
|
83
84
|
tone?: Tone | undefined
|
|
84
85
|
measured?: MeasuredStyle | undefined
|
|
85
86
|
id?: string | undefined
|
|
87
|
+
/** `lines`: each line the author broke the heading into (`\n`) is a block of its own, `data-kit-line="1"`, `"2"`, …, for a theme to set its ink, indent or size; still one heading. `flow` (absent): one run of text, as before. */
|
|
88
|
+
headingLines?: 'flow' | 'lines' | undefined
|
|
89
|
+
/** The same for the lead: `lines` draws each of its lines as a block, still one paragraph. */
|
|
90
|
+
leadLines?: 'flow' | 'lines' | undefined
|
|
86
91
|
}
|
|
87
92
|
|
|
88
|
-
const { heading, eyebrow, lead, media, items = [], actions = [], headingLevel = 2, layout, columns, align, tone, measured, id } = Astro.props
|
|
93
|
+
const { heading, eyebrow, lead, media, items = [], actions = [], headingLevel = 2, layout, columns, align, tone, measured, id, headingLines, leadLines } = Astro.props
|
|
89
94
|
// The measured alignment when none is given (`end` has no band layout: it reads as `start`).
|
|
90
95
|
const aligned = align ?? (measured?.align === 'center' ? 'center' : undefined)
|
|
91
96
|
const s = band({ layout: media ? layout : 'stack', columns, align: aligned, tone })
|
|
@@ -102,8 +107,8 @@ const hasCopy = Boolean(heading || eyebrow || lead || actions.length)
|
|
|
102
107
|
{hasCopy && (
|
|
103
108
|
<div class={s.copy()}>
|
|
104
109
|
{eyebrow && <p class={s.eyebrow()}>{eyebrow}</p>}
|
|
105
|
-
{heading && <Heading id={headingId} class={s.heading()}>{heading}</Heading>}
|
|
106
|
-
{lead && <p class={s.lead()} data-kit-text>{lead}</p>}
|
|
110
|
+
{heading && <Heading id={headingId} class={s.heading()}>{headingLines === 'lines' ? <Lines text={heading} /> : heading}</Heading>}
|
|
111
|
+
{lead && <p class={s.lead()} data-kit-text>{leadLines === 'lines' ? <Lines text={lead} /> : lead}</p>}
|
|
107
112
|
{actions.length > 0 && <div class={s.actions()}>{actions.map(action => <Button action={action} size="lg" />)}</div>}
|
|
108
113
|
</div>
|
|
109
114
|
)}
|
|
@@ -4,5 +4,6 @@
|
|
|
4
4
|
{"name": "measured-dark-fill", "props": {"heading": "Our numbers", "align": "center", "measured": {"containerPx": 1140, "padY": 96, "padX": 24, "bg": {"color": "#0f172a"}, "columns": {"390": 1, "768": 2, "1280": 4}, "gap": 32, "radius": 8}, "items": [{"title": "12", "text": "years"}, {"title": "340", "text": "sites"}, {"title": "9", "text": "countries"}, {"title": "1", "text": "team"}], "columns": "4"}},
|
|
5
5
|
{"name": "measured-image-overlay", "props": {"heading": "Visit the farm", "lead": "Open every weekend.", "actions": [{"label": "Plan a visit", "href": "/visit/"}], "measured": {"padY": 120, "minHeight": 480, "tone": "dark", "bg": {"image": "/kit-fixtures/photo-2.svg", "overlay": "rgba(0, 0, 0, 0.55)", "size": "cover", "position": "center"}}, "columns": "1"}},
|
|
6
6
|
{"name": "items-links", "props": {"heading": "Services", "columns": "2", "tone": "muted", "items": [{"title": "Audits", "text": "Where a site loses readers.", "href": "/audits/", "linkLabel": "Read more", "media": "/kit-fixtures/photo-3.svg"}, {"title": "Hosting", "text": "Static hosting, managed.", "href": "/hosting/", "icon": "/kit-fixtures/logo.svg"}]}},
|
|
7
|
-
{"name": "values-points", "props": {"heading": "Plans", "columns": "3", "items": [{"value": "$9", "title": "Starter", "meta": "per month", "points": ["One site", "Email support"]}, {"value": "$29", "title": "Team", "meta": "per month", "points": ["Five sites", "Shared components"], "href": "/pricing/team/", "linkLabel": "Choose Team"}, {"value": "250+", "title": "Sites moved", "text": "Since 2019."}]}}
|
|
7
|
+
{"name": "values-points", "props": {"heading": "Plans", "columns": "3", "items": [{"value": "$9", "title": "Starter", "meta": "per month", "points": ["One site", "Email support"]}, {"value": "$29", "title": "Team", "meta": "per month", "points": ["Five sites", "Shared components"], "href": "/pricing/team/", "linkLabel": "Choose Team"}, {"value": "250+", "title": "Sites moved", "text": "Since 2019."}]}},
|
|
8
|
+
{"name": "heading-lines", "props": {"heading": "Fewer tools.\nMore writing.", "headingLines": "lines", "lead": "Your posts, pages and forms in one place.\nNothing to update, nothing to patch.", "leadLines": "lines", "actions": [{"label": "See how", "href": "/how/"}]}}
|
|
8
9
|
]
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
"shared": [
|
|
8
8
|
"Button.astro",
|
|
9
9
|
"KitImage.astro",
|
|
10
|
+
"Lines.astro",
|
|
10
11
|
"Section.astro",
|
|
11
12
|
"instance-id.ts",
|
|
12
13
|
"measured.ts",
|
|
@@ -50,6 +51,22 @@
|
|
|
50
51
|
],
|
|
51
52
|
"default": "default",
|
|
52
53
|
"description": "Section background when nothing is measured."
|
|
54
|
+
},
|
|
55
|
+
"headingLines": {
|
|
56
|
+
"options": [
|
|
57
|
+
"flow",
|
|
58
|
+
"lines"
|
|
59
|
+
],
|
|
60
|
+
"default": "flow",
|
|
61
|
+
"description": "flow: the heading is one run of text. lines: each line the author broke it into (a line break, \\n, in the heading) is a block of its own, data-kit-line=\"1\", \"2\", …, so a theme or a design layer can give a line its own ink, indent or size. Still one heading, read as one sentence."
|
|
62
|
+
},
|
|
63
|
+
"leadLines": {
|
|
64
|
+
"options": [
|
|
65
|
+
"flow",
|
|
66
|
+
"lines"
|
|
67
|
+
],
|
|
68
|
+
"default": "flow",
|
|
69
|
+
"description": "flow: the lead is one run of text. lines: each line the author broke it into (a line break, \\n, in the lead) is a block of its own, data-kit-line=\"1\", \"2\", …, so a theme or a design layer can give a line its own ink, indent or size. Still one paragraph."
|
|
53
70
|
}
|
|
54
71
|
},
|
|
55
72
|
"props": {
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// links and the copyright line.
|
|
4
4
|
import { tv } from 'tailwind-variants'
|
|
5
5
|
import type { LinkInput } from '../_shared/types'
|
|
6
|
-
import { contactKeys, footerLinkSizeClasses, footerVars, type ContactInput } from './footer'
|
|
6
|
+
import { contactKeys, footerLinkSizeClasses, footerVars, wordmarkBox, type ContactInput } from './footer'
|
|
7
7
|
import { safeHref } from '../_shared/safe-url'
|
|
8
8
|
import { muted as quiet, mutedInverse } from '../_shared/muted'
|
|
9
9
|
|
|
@@ -80,9 +80,13 @@ interface Props {
|
|
|
80
80
|
linkSizeNarrow?: string | undefined
|
|
81
81
|
/** A border on the footer's top side: absent, the default tone has one and the others do not. */
|
|
82
82
|
border?: boolean | undefined
|
|
83
|
+
/** The site name drawn once more as a row as wide as the footer: `fit` whole, `crop` cut by the footer's bottom edge (only its top shows). `none` (absent): no row. Decoration, so hidden from screen readers: the name is already the footer's brand. */
|
|
84
|
+
wordmark?: 'none' | 'fit' | 'crop' | undefined
|
|
85
|
+
/** Where the wordmark row sits: after everything else (`last`, absent) or before it (`first`). */
|
|
86
|
+
wordmarkOrder?: 'last' | 'first' | undefined
|
|
83
87
|
}
|
|
84
88
|
|
|
85
|
-
const { siteName, tagline, columns = [], links = [], social = [], contact, copyright, consentLabel, navLabel = 'Footer', layout = 'simple', tone, align, pack, packGap, narrow, packGapNarrow, linkSize, linkSizeNarrow, border } = Astro.props
|
|
89
|
+
const { siteName, tagline, columns = [], links = [], social = [], contact, copyright, consentLabel, navLabel = 'Footer', layout = 'simple', tone, align, pack, packGap, narrow, packGapNarrow, linkSize, linkSizeNarrow, border, wordmark, wordmarkOrder } = Astro.props
|
|
86
90
|
const { root, inner, muted, link } = footer({ layout, tone, align, pack: layout === 'columns' ? pack : undefined, narrow: layout === 'columns' && pack === 'end' ? narrow : undefined, border: (border ?? (tone ?? 'default') === 'default') ? 'on' : 'off' })
|
|
87
91
|
// Custom properties the packed columns and sized links read; none when none is set.
|
|
88
92
|
const vars = footerVars({ layout, columnCount: columns.length, pack, narrow, packGap, packGapNarrow, linkSize, linkSizeNarrow })
|
|
@@ -93,9 +97,15 @@ const contactOrder = contactKeys(contact?.order)
|
|
|
93
97
|
const line = copyright === false ? undefined : copyright?.replaceAll('{year}', year) ?? `© ${year} ${siteName}`
|
|
94
98
|
// The line and the cookie settings control share one paragraph; neither: no paragraph.
|
|
95
99
|
const hasBottom = Boolean(line || consentLabel)
|
|
100
|
+
// The wordmark: the name set in an SVG whose text is stretched (letter spacing only) to the box's width, so it fills the
|
|
101
|
+
// footer's width at every size whatever its letters are. The box is 1000 wide; the font size guesses the name's width on
|
|
102
|
+
// the wide side, letter by letter, so the stretch only adds space. `crop` ends the box halfway up the letters: they run
|
|
103
|
+
// over its bottom edge and are cut there. A name in a script letter spacing breaks (Arabic, Indic, …) gets none.
|
|
104
|
+
const mark = wordmark === 'fit' || wordmark === 'crop' ? wordmarkBox(siteName, wordmark) : undefined
|
|
96
105
|
---
|
|
97
106
|
<!-- The cookie settings control is a button, not a link: the Consent component's listener reopens its bar on any [data-kit-consent-open]. -->
|
|
98
107
|
<footer class={root()}>
|
|
108
|
+
{mark && wordmarkOrder === 'first' && <div class="container-page pt-10" data-kit-wordmark><svg viewBox={`0 0 1000 ${mark.height}`} class="block h-auto w-full fill-current" aria-hidden="true" focusable="false"><text x="0" y={mark.baseline} textLength="1000" lengthAdjust="spacing" font-size={mark.size} class="[font-family:var(--font-heading,inherit)] [font-weight:var(--font-weight-heading,700)]">{siteName}</text></svg></div>}
|
|
99
109
|
<div class:list={[inner(), ...footerLinkSizeClasses({ linkSize, linkSizeNarrow })]} data-kit-footer style={vars || undefined}>
|
|
100
110
|
<div class="flex flex-col gap-3">
|
|
101
111
|
<p class="text-base font-semibold" data-kit-brand>{siteName}</p>
|
|
@@ -144,4 +154,5 @@ const hasBottom = Boolean(line || consentLabel)
|
|
|
144
154
|
</ul>
|
|
145
155
|
)}
|
|
146
156
|
</div>
|
|
157
|
+
{mark && wordmarkOrder !== 'first' && <div class="container-page" data-kit-wordmark><svg viewBox={`0 0 1000 ${mark.height}`} class="block h-auto w-full fill-current" aria-hidden="true" focusable="false"><text x="0" y={mark.baseline} textLength="1000" lengthAdjust="spacing" font-size={mark.size} class="[font-family:var(--font-heading,inherit)] [font-weight:var(--font-weight-heading,700)]">{siteName}</text></svg></div>}
|
|
147
158
|
</footer>
|
|
@@ -8,5 +8,11 @@
|
|
|
8
8
|
{ "name": "columns-end-row", "props": { "siteName": "Golden Works", "layout": "columns", "pack": "end", "narrow": "row", "packGap": "128px", "packGapNarrow": "48px", "linkSize": "17.88px", "copyright": "© 2026 Golden Works", "columns": [{ "label": "Studio", "links": [{ "label": "About", "href": "/about/" }, { "label": "Work", "href": "/work/" }] }, { "label": "Contact", "links": [{ "label": "Email", "href": "mailto:hello@example.com" }] }] } },
|
|
9
9
|
{ "name": "columns-end-row-narrow-size", "props": { "siteName": "Golden Works", "layout": "columns", "pack": "end", "narrow": "row", "packGap": "128px", "packGapNarrow": "48px", "linkSize": "17.88px", "linkSizeNarrow": "16.4px", "copyright": "© 2026 Golden Works", "columns": [{ "label": "Studio", "links": [{ "label": "About", "href": "/about/" }, { "label": "Work", "href": "/work/" }] }, { "label": "Contact", "links": [{ "label": "Email", "href": "mailto:hello@example.com" }] }] } },
|
|
10
10
|
{ "name": "columns-end-row-long-brand", "props": { "siteName": "Golden Works Engineering Consultancy", "tagline": "Websites, systems and signalling for the people who keep cities moving every single day", "layout": "columns", "pack": "end", "narrow": "row", "packGap": "128px", "packGapNarrow": "70px", "linkSize": "17.88px", "linkSizeNarrow": "16.14px", "copyright": "© 2026 Golden Works Engineering Consultancy, all rights reserved", "columns": [{ "label": "Studio", "links": [{ "label": "About", "href": "/about/" }, { "label": "Work", "href": "/work/" }] }, { "label": "Contact", "links": [{ "label": "Email", "href": "mailto:hello@example.com" }] }] } },
|
|
11
|
-
{ "name": "contact-labels", "props": { "siteName": "Private Sigorta", "tagline": "Private Sigorta, yenilikçi yönetim ve ileri teknolojiyi geçmişin tecrübeleriyle birleştiren bir acentedir. Web Tasarım: Farkol Digital", "contact": { "address": "G.O.P. Mah. Barbaros Cad. No: 2 D: 403 Çerkezköy/Tekirdağ", "phone": "+90 (282) 725 1400", "email": "info@privatesigorta.com.tr", "labels": { "email": "Email:", "phone": "Telefon:" }, "order": ["email", "phone", "address"] }, "links": [{ "label": "Hakkımızda", "href": "/hakkimizda/" }, { "label": "İletişim", "href": "/iletisim/" }] } }
|
|
11
|
+
{ "name": "contact-labels", "props": { "siteName": "Private Sigorta", "tagline": "Private Sigorta, yenilikçi yönetim ve ileri teknolojiyi geçmişin tecrübeleriyle birleştiren bir acentedir. Web Tasarım: Farkol Digital", "contact": { "address": "G.O.P. Mah. Barbaros Cad. No: 2 D: 403 Çerkezköy/Tekirdağ", "phone": "+90 (282) 725 1400", "email": "info@privatesigorta.com.tr", "labels": { "email": "Email:", "phone": "Telefon:" }, "order": ["email", "phone", "address"] }, "links": [{ "label": "Hakkımızda", "href": "/hakkimizda/" }, { "label": "İletişim", "href": "/iletisim/" }] } },
|
|
12
|
+
{ "name": "wordmark-fit", "props": { "siteName": "Northwind Studio", "wordmark": "fit", "copyright": "© 2026 Northwind Studio", "links": [{ "label": "About", "href": "/about/" }, { "label": "Privacy", "href": "/privacy/" }] } },
|
|
13
|
+
{ "name": "wordmark-crop-inverse", "props": { "siteName": "Northwind", "wordmark": "crop", "tone": "inverse", "tagline": "Calm, fast websites for teams who write.", "layout": "columns", "copyright": "© 2026 Northwind Studio", "columns": [{ "title": "Studio", "links": [{ "label": "About", "href": "/about/" }, { "label": "Team", "href": "/about/team/" }] }, { "title": "Help", "links": [{ "label": "Contact", "href": "/contact/" }] }] } },
|
|
14
|
+
{ "name": "wordmark-first-muted", "props": { "siteName": "Lumen & Pine", "wordmark": "fit", "wordmarkOrder": "first", "tone": "muted", "copyright": "© 2026 Lumen & Pine", "links": [{ "label": "Journal", "href": "/journal/" }] } },
|
|
15
|
+
{ "name": "wordmark-upper", "props": { "siteName": "NORTHWIND STUDIO", "wordmark": "fit", "copyright": "© 2026 Northwind Studio", "links": [{ "label": "About", "href": "/about/" }] } },
|
|
16
|
+
{ "name": "wordmark-ja", "props": { "siteName": "北風スタジオ", "wordmark": "fit", "tone": "muted", "copyright": "© 2026 北風スタジオ", "navLabel": "フッター", "links": [{ "label": "会社概要", "href": "/about/" }] } },
|
|
17
|
+
{ "name": "wordmark-ar", "props": { "siteName": "استوديو الشمال", "wordmark": "fit", "copyright": "© 2026 استوديو الشمال", "navLabel": "التذييل", "links": [{ "label": "من نحن", "href": "/about/" }] } }
|
|
12
18
|
]
|
|
@@ -51,3 +51,40 @@ export function footerLinkSizeClasses(i: Pick<FooterVarsInput, 'linkSize' | 'lin
|
|
|
51
51
|
i.linkSizeNarrow ? 'max-md:[&_nav_a]:text-[length:var(--footer-link-size-narrow)]' : '',
|
|
52
52
|
].filter(Boolean)
|
|
53
53
|
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Scripts that letter spacing breaks: joined (Arabic, Syriac, Thaana, N'Ko, Mongolian), set without it (Hebrew), or
|
|
57
|
+
* built of clusters it splits (Indic and Southeast Asian), the same set the starter's stylesheet takes tracking from.
|
|
58
|
+
* The wordmark is stretched by letter spacing, so a name in one of them gets none.
|
|
59
|
+
*/
|
|
60
|
+
const UNSPACED = /[\p{Script=Arabic}\p{Script=Syriac}\p{Script=Thaana}\p{Script=Nko}\p{Script=Mongolian}\p{Script=Hebrew}\p{Script=Devanagari}\p{Script=Bengali}\p{Script=Gurmukhi}\p{Script=Gujarati}\p{Script=Oriya}\p{Script=Tamil}\p{Script=Telugu}\p{Script=Kannada}\p{Script=Malayalam}\p{Script=Sinhala}\p{Script=Thai}\p{Script=Lao}\p{Script=Khmer}\p{Script=Myanmar}\p{Script=Tibetan}]/u
|
|
61
|
+
/** Full-width letters: CJK ideographs, kana, hangul and the full-width forms. */
|
|
62
|
+
const FULL = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}\u3000-\u303F\uFF00-\uFFEF]/u
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A letter's width in em, guessed on the wide side for a bold heading face: the name's natural width then stays at or
|
|
66
|
+
* under the box, so the stretch only ever adds letter spacing and never draws letters over each other.
|
|
67
|
+
*/
|
|
68
|
+
function letterWidth(letter: string): number {
|
|
69
|
+
if (FULL.test(letter)) return 1.05
|
|
70
|
+
if (/\s/u.test(letter)) return 0.3
|
|
71
|
+
if (/[MWmw@&%]/u.test(letter)) return 1
|
|
72
|
+
if (/[\p{Lu}\p{N}]/u.test(letter)) return 0.8
|
|
73
|
+
if (/\p{L}/u.test(letter)) return 0.65
|
|
74
|
+
return 0.45
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The wordmark's SVG box (1000 wide) for a site name, or none for a name letter spacing would break. The font size makes
|
|
79
|
+
* the name's guessed width 1000 (its text is then stretched to exactly 1000 by letter spacing); the baseline sits under
|
|
80
|
+
* the tallest letters (lower for full-width ones, which rise higher), and `fit` keeps room for descenders under it while
|
|
81
|
+
* `crop` ends the box halfway up the letters, so the footer cuts them.
|
|
82
|
+
*/
|
|
83
|
+
export function wordmarkBox(name: string, mode: 'fit' | 'crop'): { size: number, baseline: number, height: number } | undefined {
|
|
84
|
+
const letters = [...name.trim()]
|
|
85
|
+
if (letters.length === 0 || UNSPACED.test(name)) return undefined
|
|
86
|
+
const size = Math.floor(1000 / letters.reduce((sum, letter) => sum + letterWidth(letter), 0))
|
|
87
|
+
const rise = letters.some(letter => FULL.test(letter)) ? 0.88 : 0.78
|
|
88
|
+
const baseline = Math.round(size * rise)
|
|
89
|
+
return { size, baseline, height: mode === 'fit' ? Math.round(size * (rise + 0.22)) : Math.round(size * (rise - 0.36)) }
|
|
90
|
+
}
|
|
@@ -50,6 +50,23 @@
|
|
|
50
50
|
],
|
|
51
51
|
"default": "stack",
|
|
52
52
|
"description": "Packed columns only, below the md breakpoint. stack: one column. row: still side by side from the start edge, under the brand."
|
|
53
|
+
},
|
|
54
|
+
"wordmark": {
|
|
55
|
+
"options": [
|
|
56
|
+
"none",
|
|
57
|
+
"fit",
|
|
58
|
+
"crop"
|
|
59
|
+
],
|
|
60
|
+
"default": "none",
|
|
61
|
+
"description": "none: no wordmark. fit: the site name drawn once more as a row exactly as wide as the footer, in the heading font and the footer's ink. crop: the same row cut by the footer's bottom edge, only the letters' top showing. A name in a script letter spacing breaks (Arabic, Hebrew, Indic, Thai, …) gets none. Decoration, hidden from screen readers: the name is already the footer's brand."
|
|
62
|
+
},
|
|
63
|
+
"wordmarkOrder": {
|
|
64
|
+
"options": [
|
|
65
|
+
"last",
|
|
66
|
+
"first"
|
|
67
|
+
],
|
|
68
|
+
"default": "last",
|
|
69
|
+
"description": "Where the wordmark row sits: after everything else in the footer (last) or before it (first). Only with a wordmark."
|
|
53
70
|
}
|
|
54
71
|
},
|
|
55
72
|
"props": {
|
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
// shows it large in a native popover — no script: `popovertarget` opens it,
|
|
4
4
|
// Escape and a click outside close it. `link` opens the image file instead.
|
|
5
5
|
// Layouts: grid, masonry, a scroll-snap `row`, and a `mosaic` whose first image
|
|
6
|
-
// spans 2×2. The popover shows one image;
|
|
6
|
+
// spans 2×2. The popover shows one image; `lightbox` steps through the whole set in one viewer (GalleryLightbox: a
|
|
7
|
+
// modal dialog and a small script, both printed only for that option), and each tile is a link to its large file.
|
|
7
8
|
import { tv } from 'tailwind-variants'
|
|
9
|
+
import GalleryLightbox from './GalleryLightbox.astro'
|
|
8
10
|
import KitImage from '../_shared/KitImage.astro'
|
|
9
11
|
import Section from '../_shared/Section.astro'
|
|
10
12
|
import { measuredGrid } from '../_shared/measured'
|
|
@@ -49,7 +51,7 @@ const gallery = tv({
|
|
|
49
51
|
captionStyle: { below: {}, overlay: { caption: 'absolute inset-x-0 bottom-0 m-0 bg-ink/70 p-2 text-surface md:translate-y-full md:transition-transform md:group-focus-within:translate-y-0 md:group-hover:translate-y-0' } },
|
|
50
52
|
hoverEffect: { none: {}, zoom: { thumb: 'motion-safe:group-hover:scale-105' } },
|
|
51
53
|
columns: { '2': { list: 'grid-cols-2' }, '3': { list: 'grid-cols-2 md:grid-cols-3' }, '4': { list: 'grid-cols-2 md:grid-cols-4' }, '5': { list: 'grid-cols-2 md:grid-cols-5' }, '6': { list: 'grid-cols-2 md:grid-cols-3 lg:grid-cols-6' } },
|
|
52
|
-
open: { popover: {}, link: {}, none: { frame: 'cursor-default' } },
|
|
54
|
+
open: { popover: {}, link: {}, lightbox: {}, none: { frame: 'cursor-default' } },
|
|
53
55
|
},
|
|
54
56
|
compoundVariants: [
|
|
55
57
|
{ layout: 'masonry', columns: '3', class: { list: 'md:columns-3' } },
|
|
@@ -77,29 +79,34 @@ interface Props {
|
|
|
77
79
|
/** Name of the scrolling row's region when there is no heading ("Images"). */
|
|
78
80
|
regionLabel?: string | undefined
|
|
79
81
|
enlargeLabel?: string | undefined
|
|
80
|
-
/** What a click on an image does: show it large in place, open the file, or nothing. */
|
|
81
|
-
open?: 'popover' | 'link' | 'none' | undefined
|
|
82
|
+
/** What a click on an image does: show it large in place, step through the set in a viewer, open the file, or nothing. */
|
|
83
|
+
open?: 'popover' | 'link' | 'lightbox' | 'none' | undefined
|
|
82
84
|
/** Text of the viewer's close button ("Close"). */
|
|
83
85
|
closeLabel?: string | undefined
|
|
86
|
+
/** The lightbox viewer's buttons ("Previous image", "Next image") and the word of its counter ("2 of 5"). */
|
|
87
|
+
prevLabel?: string | undefined
|
|
88
|
+
nextLabel?: string | undefined
|
|
89
|
+
ofLabel?: string | undefined
|
|
84
90
|
tone?: 'default' | 'muted' | 'inverse' | 'accent' | undefined
|
|
85
91
|
/** 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. */
|
|
86
92
|
measured?: MeasuredStyle | undefined
|
|
87
93
|
id?: string | undefined
|
|
88
94
|
}
|
|
89
95
|
|
|
90
|
-
const { items: given, heading, layout, columns, aspect, gap, captionStyle, hoverEffect, enlargeLabel = 'Enlarge', regionLabel = 'Images', open = 'popover', closeLabel = 'Close', tone, measured, id: givenId } = Astro.props
|
|
96
|
+
const { items: given, heading, layout, columns, aspect, gap, captionStyle, hoverEffect, enlargeLabel = 'Enlarge', regionLabel = 'Images', open = 'popover', closeLabel = 'Close', prevLabel = 'Previous image', nextLabel = 'Next image', ofLabel = 'of', tone, measured, id: givenId } = Astro.props
|
|
91
97
|
// Two on one page never share an id: their labels and controls would point into each other.
|
|
92
98
|
const id = instanceId(Astro.locals, givenId, 'gallery')
|
|
93
99
|
const items = given.flatMap(item => { const image = imageOf(item.image, item.imageAlt); return image ? [{ ...item, image, full: imageOf(item.full, item.imageAlt) }] : [] }) satisfies Array<{ image: ImageInput, caption?: string | undefined }>
|
|
94
100
|
const s = gallery({ layout, columns, open, aspect, gap, captionStyle, hoverEffect })
|
|
95
101
|
const headingId = heading ? `${id}-title` : undefined
|
|
102
|
+
const lightbox = open === 'lightbox' ? `${id}-viewer` : undefined
|
|
96
103
|
const sizes = columns === '2' ? '50vw' : columns === '4' ? '(min-width: 768px) 25vw, 50vw' : '(min-width: 768px) 33vw, 50vw'
|
|
97
104
|
---
|
|
98
105
|
{items.length > 0 && (
|
|
99
106
|
<Section kit="gallery" measured={measured} tone={tone} spacing="md" id={id} aria-labelledby={headingId}>
|
|
100
107
|
{heading && <h2 id={headingId} class={s.heading()}>{heading}</h2>}
|
|
101
108
|
{/* A scrolling strip is a labelled, focusable region so the keyboard can scroll it. */}
|
|
102
|
-
<div tabindex={layout === 'row' ? 0 : undefined} role={layout === 'row' ? 'region' : undefined} aria-label={layout === 'row' ? heading ?? regionLabel : undefined}>
|
|
109
|
+
<div tabindex={layout === 'row' ? 0 : undefined} role={layout === 'row' ? 'region' : undefined} aria-label={layout === 'row' ? heading ?? regionLabel : undefined} data-kit-lightbox={lightbox}>
|
|
103
110
|
<ul class={s.list({ class: layout === 'grid' || layout === 'mosaic' ? measuredGrid(measured) : undefined })} role="list">
|
|
104
111
|
{items.map((item, i) => {
|
|
105
112
|
const viewer = `${id}-image-${i + 1}`
|
|
@@ -108,9 +115,11 @@ const sizes = columns === '2' ? '50vw' : columns === '4' ? '(min-width: 768px) 2
|
|
|
108
115
|
<figure class={captionStyle === 'overlay' ? 'group relative m-0 overflow-hidden' : 'm-0'}>
|
|
109
116
|
{open === 'popover'
|
|
110
117
|
? <button type="button" popovertarget={viewer} class={s.frame()} aria-label={`${enlargeLabel}: ${item.image.alt || item.caption || ''}`.trim()}><KitImage image={item.image} sizes={sizes} class={s.thumb()} /></button>
|
|
111
|
-
: open === '
|
|
112
|
-
? <a href={safeHref(item.image.src)} class={s.frame()}><KitImage image={item.image} sizes={sizes} class={s.thumb()} /></a>
|
|
113
|
-
:
|
|
118
|
+
: open === 'lightbox'
|
|
119
|
+
? <a href={safeHref((item.full ?? item.image).src)} class={s.frame()} aria-label={`${enlargeLabel}: ${item.image.alt || item.caption || ''}`.trim()} data-kit-lightbox-item data-caption={item.caption || undefined}><KitImage image={item.image} sizes={sizes} class={s.thumb()} /></a>
|
|
120
|
+
: open === 'link'
|
|
121
|
+
? <a href={safeHref(item.image.src)} class={s.frame()}><KitImage image={item.image} sizes={sizes} class={s.thumb()} /></a>
|
|
122
|
+
: <div class={s.frame()}><KitImage image={item.image} sizes={sizes} class={s.thumb()} /></div>}
|
|
114
123
|
{item.caption && <figcaption class={s.caption()}>{item.caption}</figcaption>}
|
|
115
124
|
</figure>
|
|
116
125
|
{open === 'popover' && (
|
|
@@ -126,6 +135,7 @@ const sizes = columns === '2' ? '50vw' : columns === '4' ? '(min-width: 768px) 2
|
|
|
126
135
|
)
|
|
127
136
|
})}
|
|
128
137
|
</ul>
|
|
138
|
+
{lightbox && <GalleryLightbox id={lightbox} label={heading ?? regionLabel} closeLabel={closeLabel} prevLabel={prevLabel} nextLabel={nextLabel} ofLabel={ofLabel} single={items.length < 2} />}
|
|
129
139
|
</div>
|
|
130
140
|
</Section>
|
|
131
141
|
)}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
---
|
|
2
|
+
// The viewer of a Gallery set to `open: 'lightbox'`: one modal dialog for the whole set, printed (with its script) only
|
|
3
|
+
// for that option, so a gallery that does not ask for it ships neither. Each tile is a link to its large file
|
|
4
|
+
// (`data-kit-lightbox-item`); a plain click opens that file here instead, and previous / next step through the set.
|
|
5
|
+
//
|
|
6
|
+
// The dialog is modal (`showModal`): the page behind it is inert, so focus stays inside it, and Escape closes it.
|
|
7
|
+
// Closing hands focus back to the tile it was opened from. ←/→ step (mirrored right to left), Home and End jump to
|
|
8
|
+
// the ends, and a click outside the image closes. The counter and the caption are announced as they change. Nothing
|
|
9
|
+
// animates. Text goes in through `textContent` only. With no script the tiles stay links to the files.
|
|
10
|
+
interface Props {
|
|
11
|
+
/** The dialog's id; the gallery names it in `data-kit-lightbox`. */
|
|
12
|
+
id: string
|
|
13
|
+
/** The dialog's name: the gallery's heading, or its region label. */
|
|
14
|
+
label: string
|
|
15
|
+
closeLabel: string
|
|
16
|
+
prevLabel: string
|
|
17
|
+
nextLabel: string
|
|
18
|
+
/** The counter's word between position and total ("2 of 5"). */
|
|
19
|
+
ofLabel: string
|
|
20
|
+
/** One image: no previous / next buttons and no counter. */
|
|
21
|
+
single: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const { id, label, closeLabel, prevLabel, nextLabel, ofLabel, single } = Astro.props
|
|
25
|
+
const step = 'grid size-11 shrink-0 cursor-pointer place-items-center rounded-full border border-surface/40 text-surface hover:bg-surface/10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-surface'
|
|
26
|
+
---
|
|
27
|
+
<dialog id={id} aria-label={label} data-of={ofLabel} class="m-0 h-dvh max-h-none w-screen max-w-none border-0 bg-ink p-4 text-surface open:grid open:grid-rows-[auto_minmax(0,1fr)_auto] open:gap-3 md:p-8">
|
|
28
|
+
<div class="flex justify-end">
|
|
29
|
+
<button type="button" class="cursor-pointer rounded-md border border-surface/40 px-4 py-2 text-sm text-surface hover:bg-surface/10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-surface" data-kit-lightbox-close>{closeLabel}</button>
|
|
30
|
+
</div>
|
|
31
|
+
<div class="grid min-h-0 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2 md:gap-4">
|
|
32
|
+
{single ? <span /> : <button type="button" class={step} aria-label={prevLabel} data-kit-lightbox-prev><svg aria-hidden="true" viewBox="0 0 24 24" class="size-5 rtl:-scale-x-100" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 18l-6-6 6-6" /></svg></button>}
|
|
33
|
+
<img alt="" class="mx-auto block max-h-full max-w-full rounded-[var(--radius-image,var(--radius-card))] object-contain" data-kit-lightbox-image hidden />
|
|
34
|
+
{single ? <span /> : <button type="button" class={step} aria-label={nextLabel} data-kit-lightbox-next><svg aria-hidden="true" viewBox="0 0 24 24" class="size-5 rtl:-scale-x-100" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 6l6 6-6 6" /></svg></button>}
|
|
35
|
+
</div>
|
|
36
|
+
<p class="m-0 flex flex-wrap justify-center gap-x-3 text-center text-sm" aria-live="polite">
|
|
37
|
+
<span data-kit-lightbox-caption></span>
|
|
38
|
+
{!single && <span data-kit-lightbox-count></span>}
|
|
39
|
+
</p>
|
|
40
|
+
</dialog>
|
|
41
|
+
|
|
42
|
+
<script>
|
|
43
|
+
function wire(root: HTMLElement): void {
|
|
44
|
+
const dialog = document.getElementById(root.dataset.kitLightbox ?? '')
|
|
45
|
+
if (!(dialog instanceof HTMLDialogElement)) return
|
|
46
|
+
// Only tiles with a real address: one the kit printed as `#` (a refused URL) stays a plain link.
|
|
47
|
+
const links = [...root.querySelectorAll<HTMLAnchorElement>('a[data-kit-lightbox-item]')].filter(link => link.getAttribute('href') !== '#')
|
|
48
|
+
const image = dialog.querySelector<HTMLImageElement>('[data-kit-lightbox-image]')
|
|
49
|
+
const caption = dialog.querySelector<HTMLElement>('[data-kit-lightbox-caption]')
|
|
50
|
+
const count = dialog.querySelector<HTMLElement>('[data-kit-lightbox-count]')
|
|
51
|
+
if (!links.length || !image || !caption) return
|
|
52
|
+
// One tile left once the refused ones are out: nothing to step to, so no step buttons and no counter. The buttons
|
|
53
|
+
// keep their place in the row (hidden from sight, focus and readers alike), so the image does not move.
|
|
54
|
+
if (links.length < 2) {
|
|
55
|
+
for (const button of dialog.querySelectorAll<HTMLElement>('[data-kit-lightbox-prev], [data-kit-lightbox-next]')) button.style.visibility = 'hidden'
|
|
56
|
+
if (count) count.hidden = true
|
|
57
|
+
}
|
|
58
|
+
let index = 0
|
|
59
|
+
let opener: HTMLAnchorElement | null = null
|
|
60
|
+
const show = (at: number) => {
|
|
61
|
+
index = (at + links.length) % links.length
|
|
62
|
+
const link = links[index]!
|
|
63
|
+
image.src = link.href
|
|
64
|
+
image.alt = link.querySelector('img')?.alt ?? ''
|
|
65
|
+
image.hidden = false
|
|
66
|
+
caption.textContent = link.dataset.caption ?? ''
|
|
67
|
+
if (count) count.textContent = `${index + 1} ${dialog.dataset.of ?? 'of'} ${links.length}`
|
|
68
|
+
}
|
|
69
|
+
links.forEach((link, i) => link.addEventListener('click', (event) => {
|
|
70
|
+
// A click asking for a new tab or window, or a download, keeps the link's own meaning.
|
|
71
|
+
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
|
|
72
|
+
event.preventDefault()
|
|
73
|
+
opener = link
|
|
74
|
+
show(i)
|
|
75
|
+
if (!dialog.open) dialog.showModal()
|
|
76
|
+
}))
|
|
77
|
+
dialog.querySelector('[data-kit-lightbox-close]')?.addEventListener('click', () => dialog.close())
|
|
78
|
+
dialog.querySelector('[data-kit-lightbox-prev]')?.addEventListener('click', () => show(index - 1))
|
|
79
|
+
dialog.querySelector('[data-kit-lightbox-next]')?.addEventListener('click', () => show(index + 1))
|
|
80
|
+
dialog.addEventListener('keydown', (event) => {
|
|
81
|
+
const back = getComputedStyle(dialog).direction === 'rtl' ? 'ArrowRight' : 'ArrowLeft'
|
|
82
|
+
const forward = back === 'ArrowLeft' ? 'ArrowRight' : 'ArrowLeft'
|
|
83
|
+
const to = event.key === back ? index - 1 : event.key === forward ? index + 1 : event.key === 'Home' ? 0 : event.key === 'End' ? links.length - 1 : null
|
|
84
|
+
if (to === null || links.length < 2) return
|
|
85
|
+
event.preventDefault()
|
|
86
|
+
show(to)
|
|
87
|
+
})
|
|
88
|
+
// The dialog fills the screen: a click anywhere but on the image or a button is a click outside the image.
|
|
89
|
+
dialog.addEventListener('click', (event) => {
|
|
90
|
+
if (event.target instanceof Element && (event.target === image || event.target.closest('button'))) return
|
|
91
|
+
dialog.close()
|
|
92
|
+
})
|
|
93
|
+
dialog.addEventListener('close', () => { opener?.focus(); opener = null })
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function init(): void {
|
|
97
|
+
for (const root of document.querySelectorAll<HTMLElement>('[data-kit-lightbox]')) {
|
|
98
|
+
if (root.dataset.kitReady !== undefined) continue
|
|
99
|
+
root.dataset.kitReady = ''
|
|
100
|
+
try { wire(root) }
|
|
101
|
+
catch {
|
|
102
|
+
// Left as printed: every tile is still a link to its file.
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
init()
|
|
108
|
+
// With Astro's client router a page arrives without a reload: wire its galleries too (ready ones are skipped).
|
|
109
|
+
document.addEventListener('astro:page-load', init)
|
|
110
|
+
</script>
|
|
@@ -462,5 +462,57 @@
|
|
|
462
462
|
}
|
|
463
463
|
]
|
|
464
464
|
}
|
|
465
|
+
},
|
|
466
|
+
{
|
|
467
|
+
"name": "lightbox",
|
|
468
|
+
"props": {
|
|
469
|
+
"heading": "Along the coast",
|
|
470
|
+
"id": "coast",
|
|
471
|
+
"open": "lightbox",
|
|
472
|
+
"columns": "4",
|
|
473
|
+
"items": [
|
|
474
|
+
{
|
|
475
|
+
"image": {
|
|
476
|
+
"src": "/kit-fixtures/photo-1.svg",
|
|
477
|
+
"alt": "Photo 1",
|
|
478
|
+
"width": 1200,
|
|
479
|
+
"height": 800
|
|
480
|
+
},
|
|
481
|
+
"full": {
|
|
482
|
+
"src": "/kit-fixtures/photo-1.svg",
|
|
483
|
+
"alt": "Photo 1",
|
|
484
|
+
"width": 1200,
|
|
485
|
+
"height": 800
|
|
486
|
+
},
|
|
487
|
+
"caption": "Porto at dawn"
|
|
488
|
+
},
|
|
489
|
+
{
|
|
490
|
+
"image": {
|
|
491
|
+
"src": "/kit-fixtures/photo-2.svg",
|
|
492
|
+
"alt": "Photo 2",
|
|
493
|
+
"width": 1200,
|
|
494
|
+
"height": 800
|
|
495
|
+
},
|
|
496
|
+
"caption": "Train to Coimbra"
|
|
497
|
+
},
|
|
498
|
+
{
|
|
499
|
+
"image": {
|
|
500
|
+
"src": "/kit-fixtures/photo-3.svg",
|
|
501
|
+
"alt": "Photo 3",
|
|
502
|
+
"width": 1200,
|
|
503
|
+
"height": 800
|
|
504
|
+
}
|
|
505
|
+
},
|
|
506
|
+
{
|
|
507
|
+
"image": {
|
|
508
|
+
"src": "/kit-fixtures/photo-4.svg",
|
|
509
|
+
"alt": "Photo 4",
|
|
510
|
+
"width": 1200,
|
|
511
|
+
"height": 800
|
|
512
|
+
},
|
|
513
|
+
"caption": "Harbour"
|
|
514
|
+
}
|
|
515
|
+
]
|
|
516
|
+
}
|
|
465
517
|
}
|
|
466
518
|
]
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "gallery",
|
|
3
3
|
"name": "Gallery",
|
|
4
|
-
"description": "Images in a square grid or in masonry columns, with optional captions. A click shows the image large in a native popover (no script), opens the image file, or does nothing.",
|
|
4
|
+
"description": "Images in a square grid or in masonry columns, with optional captions. A click shows the image large in a native popover (no script), steps through the set in a lightbox viewer, opens the image file, or does nothing.",
|
|
5
5
|
"category": "section",
|
|
6
6
|
"uses": [],
|
|
7
7
|
"shared": [
|
|
@@ -40,10 +40,11 @@
|
|
|
40
40
|
"options": [
|
|
41
41
|
"popover",
|
|
42
42
|
"link",
|
|
43
|
-
"none"
|
|
43
|
+
"none",
|
|
44
|
+
"lightbox"
|
|
44
45
|
],
|
|
45
46
|
"default": "popover",
|
|
46
|
-
"description": "popover: the image opens large in place (Popover API; Escape or a click outside closes it). link: the thumbnail links to the image file (the WordPress 'link to media file'). none: images only."
|
|
47
|
+
"description": "popover: the image opens large in place (Popover API; Escape or a click outside closes it). link: the thumbnail links to the image file (the WordPress 'link to media file'). none: images only. lightbox: each thumbnail links to its large file (full, else the image); with script a click opens one modal viewer for the whole set, with previous / next, ←/→, a counter and the caption (a source lightbox: Elementor's slideshow group, a WordPress gallery with a lightbox plugin). The viewer and its script are printed only for this option."
|
|
47
48
|
},
|
|
48
49
|
"aspect": {
|
|
49
50
|
"options": [
|
|
@@ -124,6 +125,24 @@
|
|
|
124
125
|
"content": false,
|
|
125
126
|
"description": "Text of the viewer's close button (ui-strings gallery.close)."
|
|
126
127
|
},
|
|
128
|
+
"prevLabel": {
|
|
129
|
+
"type": "string",
|
|
130
|
+
"default": "Previous image",
|
|
131
|
+
"content": false,
|
|
132
|
+
"description": "Name of the lightbox viewer's previous button (ui-strings slider.prev)."
|
|
133
|
+
},
|
|
134
|
+
"nextLabel": {
|
|
135
|
+
"type": "string",
|
|
136
|
+
"default": "Next image",
|
|
137
|
+
"content": false,
|
|
138
|
+
"description": "Name of the lightbox viewer's next button (ui-strings slider.next)."
|
|
139
|
+
},
|
|
140
|
+
"ofLabel": {
|
|
141
|
+
"type": "string",
|
|
142
|
+
"default": "of",
|
|
143
|
+
"content": false,
|
|
144
|
+
"description": "The word of the lightbox viewer's counter between position and total (\"2 of 5\"; ui-strings slider.of)."
|
|
145
|
+
},
|
|
127
146
|
"tone": {
|
|
128
147
|
"type": "select",
|
|
129
148
|
"options": [
|
|
@@ -264,7 +283,7 @@
|
|
|
264
283
|
}
|
|
265
284
|
},
|
|
266
285
|
"slots": {},
|
|
267
|
-
"js": "
|
|
286
|
+
"js": "vanilla",
|
|
268
287
|
"dependencies": {},
|
|
269
288
|
"sources": {
|
|
270
289
|
"gutenberg": [
|
|
@@ -281,5 +300,5 @@
|
|
|
281
300
|
"classic/gallery"
|
|
282
301
|
]
|
|
283
302
|
},
|
|
284
|
-
"a11y": "A list of figures. In popover mode each thumbnail is a <button> named by the image's alt (or caption) that opens the viewer; the viewer has a visible close button, and Escape closes it, returning focus to the thumbnail. Captions are <figcaption>s. A scrolling row is a labelled region with tabindex 0 so the keyboard can scroll it; thumbnail buttons are named \"Enlarge: …\". The viewer
|
|
303
|
+
"a11y": "A list of figures. In popover mode each thumbnail is a <button> named by the image's alt (or caption) that opens the viewer; the viewer has a visible close button, and Escape closes it, returning focus to the thumbnail. Captions are <figcaption>s. A scrolling row is a labelled region with tabindex 0 so the keyboard can scroll it; thumbnail buttons are named \"Enlarge: …\". The popover viewer shows one image. In lightbox mode each thumbnail is a link to its large file, named \"Enlarge: …\"; with script it opens one modal <dialog> named by the heading (or the region label): focus moves into it and stays there (the page behind is inert), Escape or a click outside the image closes it and focus returns to the thumbnail, labelled previous / next buttons and ←/→ (mirrored right to left), Home and End step through the set, and the counter (\"2 of 5\") and caption are announced as they change. Nothing animates. Without script the links open the files. Other modes ship no script."
|
|
285
304
|
}
|
|
@@ -247,5 +247,6 @@
|
|
|
247
247
|
{"name": "centered-media-first-edge", "props": {"layout": "centered", "heading": "Your site, moved and checked", "lead": "Every page, address and form carried over.", "stackOrder": "media-first", "imageBleed": "edge", "image": {"src": "/kit-fixtures/photo-1.svg", "alt": "A desk by a window", "width": 1200, "height": 800}}},
|
|
248
248
|
{"name": "split-overlap", "props": {"heading": "Your site, moved and checked", "lead": "Every page, address and form carried over.", "imageBleed": "overlap", "tone": "muted", "image": {"src": "/kit-fixtures/photo-1.svg", "alt": "A desk by a window", "width": 1200, "height": 800}}},
|
|
249
249
|
{"name": "centered-media-first-overlap", "props": {"layout": "centered", "heading": "Your site, moved and checked", "lead": "Every page, address and form carried over.", "stackOrder": "media-first", "imageBleed": "overlap", "tone": "muted", "image": {"src": "/kit-fixtures/photo-1.svg", "alt": "A desk by a window", "width": 1200, "height": 800}}},
|
|
250
|
-
{"name": "split-media-first-overlap", "props": {"heading": "Your site, moved and checked", "lead": "Every page, address and form carried over.", "stackOrder": "media-first", "imageBleed": "overlap", "tone": "muted", "image": {"src": "/kit-fixtures/photo-1.svg", "alt": "A desk by a window", "width": 1200, "height": 800}}}
|
|
250
|
+
{"name": "split-media-first-overlap", "props": {"heading": "Your site, moved and checked", "lead": "Every page, address and form carried over.", "stackOrder": "media-first", "imageBleed": "overlap", "tone": "muted", "image": {"src": "/kit-fixtures/photo-1.svg", "alt": "A desk by a window", "width": 1200, "height": 800}}},
|
|
251
|
+
{"name": "heading-hooks", "theme": {"--font-heading": "var(--font-serif)", "--tracking-heading": "0.04em"}, "props": {"layout": "centered", "eyebrow": "A design direction", "heading": "Headings in a face of their own", "lead": "The heading face and track come from the theme; the lead keeps the body face."}}
|
|
251
252
|
]
|
|
@@ -4,7 +4,12 @@
|
|
|
4
4
|
// out by more than its colour. Each plan's features are a markdown list,
|
|
5
5
|
// rendered at build time. Variants: `layout="table"` is a comparison table
|
|
6
6
|
// (plan columns, feature rows); `billingToggle="radio"` swaps monthly and yearly
|
|
7
|
-
// prices with two radio inputs and CSS `:has()` — no script.
|
|
7
|
+
// prices with two radio inputs and CSS `:has()` — no script. A plan's struck price, its note under the price and its
|
|
8
|
+
// line by the button swap the same way, and the section's `billingAltNote` shows only with the second option; each
|
|
9
|
+
// is printed only where it is given. An Alt line never falls back to the main one: with the second option chosen, a
|
|
10
|
+
// plan without it shows no line there (a note "for the first month" would be wrong under a yearly price).
|
|
11
|
+
// The price itself is the one exception: a plan with no `priceAlt` (free, "Contact us", or one price for both periods)
|
|
12
|
+
// keeps its price line, struck price included, under both options; a card never loses its price.
|
|
8
13
|
import { tv } from 'tailwind-variants'
|
|
9
14
|
import Button from '../_shared/Button.astro'
|
|
10
15
|
import Section, { type Tone } from '../_shared/Section.astro'
|
|
@@ -32,6 +37,10 @@ const pricing = tv({
|
|
|
32
37
|
price: 'flex items-baseline gap-1',
|
|
33
38
|
amount: 'text-4xl [font-weight:var(--font-weight-heading,700)] tracking-tight',
|
|
34
39
|
period: `text-sm ${muted}`,
|
|
40
|
+
was: `me-1 text-lg ${muted}`,
|
|
41
|
+
note: `-mt-2 text-sm ${muted}`,
|
|
42
|
+
foot: `text-sm ${muted}`,
|
|
43
|
+
altNote: 'mx-auto -mt-5 mb-8 w-fit text-center text-sm font-medium text-accent-text',
|
|
35
44
|
description: `text-pretty ${muted}`,
|
|
36
45
|
features: 'prose-site prose max-w-none text-current prose-ul:ps-5 prose-li:my-1',
|
|
37
46
|
action: 'mt-auto pt-2',
|
|
@@ -69,6 +78,15 @@ interface Plan {
|
|
|
69
78
|
/** The other billing period's price and period (`billingToggle="radio"`). */
|
|
70
79
|
priceAlt?: string | undefined
|
|
71
80
|
periodAlt?: string | undefined
|
|
81
|
+
/** The struck original price before the price, for each period ("$35"). */
|
|
82
|
+
priceWas?: string | undefined
|
|
83
|
+
priceWasAlt?: string | undefined
|
|
84
|
+
/** One line under the price, for each period ("USD for the first month"). */
|
|
85
|
+
priceNote?: string | undefined
|
|
86
|
+
priceNoteAlt?: string | undefined
|
|
87
|
+
/** One line at the card's foot, by the button, for each period ("Pay $0 today, $35 after the first month"). */
|
|
88
|
+
priceFoot?: string | undefined
|
|
89
|
+
priceFootAlt?: string | undefined
|
|
72
90
|
}
|
|
73
91
|
|
|
74
92
|
interface Props {
|
|
@@ -88,6 +106,10 @@ interface Props {
|
|
|
88
106
|
billingMainLabel?: string | undefined
|
|
89
107
|
billingAltLabel?: string | undefined
|
|
90
108
|
billingLegend?: string | undefined
|
|
109
|
+
/** A line by the toggle shown only with its second option ("Save $70 by paying annually"). */
|
|
110
|
+
billingAltNote?: string | undefined
|
|
111
|
+
/** Read before a struck price by screen readers ("Was"), which do not announce the strike. */
|
|
112
|
+
wasLabel?: string | undefined
|
|
91
113
|
/** Table cell texts for screen readers. */
|
|
92
114
|
includedLabel?: string | undefined
|
|
93
115
|
notIncludedLabel?: string | undefined
|
|
@@ -97,7 +119,7 @@ interface Props {
|
|
|
97
119
|
id?: string | undefined
|
|
98
120
|
}
|
|
99
121
|
|
|
100
|
-
const { heading, intro, plans, featuredLabel = 'Most popular', columns, layout = 'cards', featureStyle, compact, billingToggle = 'none', billingMainLabel = 'Monthly', billingAltLabel = 'Yearly', billingLegend = 'Billing period', includedLabel = 'Included', notIncludedLabel = 'Not included', tone, measured, id: givenId } = Astro.props
|
|
122
|
+
const { heading, intro, plans, featuredLabel = 'Most popular', columns, layout = 'cards', featureStyle, compact, billingToggle = 'none', billingMainLabel = 'Monthly', billingAltLabel = 'Yearly', billingLegend = 'Billing period', billingAltNote, wasLabel = 'Was', includedLabel = 'Included', notIncludedLabel = 'Not included', tone, measured, id: givenId } = Astro.props
|
|
101
123
|
// Every section has an id, so a theme or a design layer can address this one: the page's, or `pricing`, `pricing-2`, … in render order.
|
|
102
124
|
const id = instanceId(Astro.locals, givenId, 'pricing')
|
|
103
125
|
const s = pricing({ columns, featureStyle, compact: compact ?? false, tone })
|
|
@@ -173,28 +195,41 @@ const features = await Promise.all(plans.map(plan => renderMarkdown(plan.feature
|
|
|
173
195
|
<label class={s.toggleOption()}><input type="radio" name={`${group}-billing`} value="alt" class="sr-only" />{billingAltLabel}</label>
|
|
174
196
|
</fieldset>
|
|
175
197
|
)}
|
|
198
|
+
{toggled && billingAltNote && <p class={s.altNote()} data-price="alt">{billingAltNote}</p>}
|
|
176
199
|
<ul class={s.list({ class: measuredGrid(measured) })}>
|
|
177
200
|
{plans.map((plan, i) => (
|
|
178
201
|
<li class={s.plan({ class: plan.featured ? 'ring-2 ring-accent shadow-lg md:-my-2 md:py-10' : undefined })}>
|
|
179
202
|
{(plan.badge ?? (plan.featured ? featuredLabel : undefined)) && <p class={s.badge()}>{plan.badge ?? featuredLabel}</p>}
|
|
180
203
|
<PlanHeading class={s.name()}>{plan.name}</PlanHeading>
|
|
181
|
-
<p class={s.price()} data-price=
|
|
204
|
+
<p class={s.price()} data-price={toggled && !plan.priceAlt ? undefined : 'main'}>
|
|
205
|
+
{plan.priceWas && <del class={s.was()}><span class="sr-only">{wasLabel} </span>{plan.priceWas}</del>}
|
|
182
206
|
<span class={s.amount()}>{plan.price}</span>
|
|
183
207
|
{plan.period && <span class={s.period()}>{plan.period}</span>}
|
|
184
208
|
</p>
|
|
185
209
|
{toggled && plan.priceAlt && (
|
|
186
210
|
<p class={s.price()} data-price="alt">
|
|
211
|
+
{plan.priceWasAlt && <del class={s.was()}><span class="sr-only">{wasLabel} </span>{plan.priceWasAlt}</del>}
|
|
187
212
|
<span class={s.amount()}>{plan.priceAlt}</span>
|
|
188
213
|
{plan.periodAlt && <span class={s.period()}>{plan.periodAlt}</span>}
|
|
189
214
|
</p>
|
|
190
215
|
)}
|
|
216
|
+
{plan.priceNote && <p class={s.note()} data-price="main">{plan.priceNote}</p>}
|
|
217
|
+
{toggled && plan.priceAlt && plan.priceNoteAlt && <p class={s.note()} data-price="alt">{plan.priceNoteAlt}</p>}
|
|
191
218
|
{plan.description && <p class={s.description()} data-kit-text>{plan.description}</p>}
|
|
192
219
|
{features[i] && <div class={s.features()} set:html={features[i]} />}
|
|
193
220
|
{plan.ctaLabel && plan.ctaHref && (
|
|
194
221
|
<div class={s.action()}>
|
|
222
|
+
{plan.priceFoot && <p class={s.foot({ class: 'mb-3' })} data-price="main">{plan.priceFoot}</p>}
|
|
223
|
+
{toggled && plan.priceAlt && plan.priceFootAlt && <p class={s.foot({ class: 'mb-3' })} data-price="alt">{plan.priceFootAlt}</p>}
|
|
195
224
|
<Button action={{ label: plan.ctaLabel, href: plan.ctaHref, style: plan.featured ? 'primary' : 'ghost' }} class="w-full" />
|
|
196
225
|
</div>
|
|
197
226
|
)}
|
|
227
|
+
{!(plan.ctaLabel && plan.ctaHref) && (plan.priceFoot || (toggled && plan.priceAlt && plan.priceFootAlt)) && (
|
|
228
|
+
<div class={s.action()}>
|
|
229
|
+
{plan.priceFoot && <p class={s.foot()} data-price="main">{plan.priceFoot}</p>}
|
|
230
|
+
{toggled && plan.priceAlt && plan.priceFootAlt && <p class={s.foot()} data-price="alt">{plan.priceFootAlt}</p>}
|
|
231
|
+
</div>
|
|
232
|
+
)}
|
|
198
233
|
</li>
|
|
199
234
|
))}
|
|
200
235
|
</ul>
|
|
@@ -286,5 +286,89 @@
|
|
|
286
286
|
}
|
|
287
287
|
]
|
|
288
288
|
}
|
|
289
|
+
},
|
|
290
|
+
{
|
|
291
|
+
"name": "billing-period-notes",
|
|
292
|
+
"props": {
|
|
293
|
+
"id": "hosting",
|
|
294
|
+
"heading": "Hosting",
|
|
295
|
+
"billingToggle": "radio",
|
|
296
|
+
"columns": "2",
|
|
297
|
+
"billingAltNote": "Save $70 by paying annually",
|
|
298
|
+
"plans": [
|
|
299
|
+
{
|
|
300
|
+
"name": "Single",
|
|
301
|
+
"price": "$0",
|
|
302
|
+
"period": "/first month",
|
|
303
|
+
"priceWas": "$35",
|
|
304
|
+
"priceNote": "USD for the first month",
|
|
305
|
+
"priceFoot": "Pay $0 today, $35 after the first month",
|
|
306
|
+
"priceAlt": "$0",
|
|
307
|
+
"periodAlt": "/first month",
|
|
308
|
+
"priceWasAlt": "$30",
|
|
309
|
+
"priceNoteAlt": "USD for the first month, then $350 a year",
|
|
310
|
+
"priceFootAlt": "Pay $0 today, $350 after the first month",
|
|
311
|
+
"features": "- 1 site\n- 20GB bandwidth",
|
|
312
|
+
"ctaLabel": "Sign up",
|
|
313
|
+
"ctaHref": "/signup/single/"
|
|
314
|
+
},
|
|
315
|
+
{
|
|
316
|
+
"name": "Multiple",
|
|
317
|
+
"price": "$0",
|
|
318
|
+
"period": "/first month",
|
|
319
|
+
"priceWas": "$70",
|
|
320
|
+
"priceNote": "USD for the first month",
|
|
321
|
+
"priceFoot": "Pay $0 today, $70 after the first month",
|
|
322
|
+
"priceAlt": "$0",
|
|
323
|
+
"periodAlt": "/first month",
|
|
324
|
+
"priceWasAlt": "$58",
|
|
325
|
+
"priceFootAlt": "Pay $0 today, $700 after the first month",
|
|
326
|
+
"features": "- 5 sites\n- 100GB bandwidth",
|
|
327
|
+
"featured": true
|
|
328
|
+
}
|
|
329
|
+
]
|
|
330
|
+
}
|
|
331
|
+
},
|
|
332
|
+
{
|
|
333
|
+
"name": "billing-toggle-no-alt",
|
|
334
|
+
"props": {
|
|
335
|
+
"id": "teams",
|
|
336
|
+
"heading": "Teams",
|
|
337
|
+
"billingToggle": "radio",
|
|
338
|
+
"columns": "3",
|
|
339
|
+
"plans": [
|
|
340
|
+
{
|
|
341
|
+
"name": "Free",
|
|
342
|
+
"price": "$0",
|
|
343
|
+
"period": "/month",
|
|
344
|
+
"priceNote": "Free for one user",
|
|
345
|
+
"features": "- 1 user",
|
|
346
|
+
"ctaLabel": "Start free",
|
|
347
|
+
"ctaHref": "/signup/free/"
|
|
348
|
+
},
|
|
349
|
+
{
|
|
350
|
+
"name": "Team",
|
|
351
|
+
"price": "$12",
|
|
352
|
+
"period": "/month",
|
|
353
|
+
"priceAlt": "$120",
|
|
354
|
+
"periodAlt": "/year",
|
|
355
|
+
"features": "- 10 users\n- Shared drafts",
|
|
356
|
+
"ctaLabel": "Subscribe",
|
|
357
|
+
"ctaHref": "/buy/team/",
|
|
358
|
+
"featured": true
|
|
359
|
+
},
|
|
360
|
+
{
|
|
361
|
+
"name": "Enterprise",
|
|
362
|
+
"price": "Contact us",
|
|
363
|
+
"priceWas": "$99",
|
|
364
|
+
"features": "- Unlimited users\n- SSO",
|
|
365
|
+
"ctaLabel": "Talk to sales",
|
|
366
|
+
"ctaHref": "/contact/"
|
|
367
|
+
}
|
|
368
|
+
],
|
|
369
|
+
"billingMainLabel": "Monthly",
|
|
370
|
+
"billingAltLabel": "Yearly",
|
|
371
|
+
"billingLegend": "Billing period"
|
|
372
|
+
}
|
|
289
373
|
}
|
|
290
374
|
]
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
"radio"
|
|
57
57
|
],
|
|
58
58
|
"default": "none",
|
|
59
|
-
"description": "radio: two labelled radios swap each plan's price for its priceAlt (monthly / yearly), with no script. The radio group is named from the section id, or a hash of the plans without one, so two toggles on a page stay independent."
|
|
59
|
+
"description": "radio: two labelled radios swap each plan's price for its priceAlt (monthly / yearly), with no script. The radio group is named from the section id, or a hash of the plans without one, so two toggles on a page stay independent. A plan's priceWas, priceNote and priceFoot swap for their Alt values the same way, and billingAltNote shows only with the second option."
|
|
60
60
|
}
|
|
61
61
|
},
|
|
62
62
|
"props": {
|
|
@@ -127,12 +127,42 @@
|
|
|
127
127
|
"priceAlt": {
|
|
128
128
|
"type": "string",
|
|
129
129
|
"label": "Price, other period",
|
|
130
|
-
"description": "The price for the other billing period (\"€150\"), shown when the toggle is on its second option."
|
|
130
|
+
"description": "The price for the other billing period (\"€150\"), shown when the toggle is on its second option. Without it the plan's price (and its struck price) shows under both options: a free plan, \"Contact us\", or one price for both periods. Its notes do not: they stay first-period lines."
|
|
131
131
|
},
|
|
132
132
|
"periodAlt": {
|
|
133
133
|
"type": "string",
|
|
134
134
|
"label": "Period, other period",
|
|
135
135
|
"description": "What the other price is per (\"/year\")."
|
|
136
|
+
},
|
|
137
|
+
"priceWas": {
|
|
138
|
+
"type": "string",
|
|
139
|
+
"label": "Original price",
|
|
140
|
+
"description": "The original price, struck before the price (\"$35\"; cards layout)."
|
|
141
|
+
},
|
|
142
|
+
"priceWasAlt": {
|
|
143
|
+
"type": "string",
|
|
144
|
+
"label": "Original price, other period",
|
|
145
|
+
"description": "The other period's struck original price, shown with priceAlt. No fallback: without it, the second option shows no struck price (priceWas belongs to the first period only, except on a plan with no priceAlt, whose whole price line shows under both)."
|
|
146
|
+
},
|
|
147
|
+
"priceNote": {
|
|
148
|
+
"type": "string",
|
|
149
|
+
"label": "Price note",
|
|
150
|
+
"description": "One line under the price (\"USD for the first month\"; cards layout)."
|
|
151
|
+
},
|
|
152
|
+
"priceNoteAlt": {
|
|
153
|
+
"type": "string",
|
|
154
|
+
"label": "Price note, other period",
|
|
155
|
+
"description": "The other period's line under the price, shown with priceAlt. No fallback: without it, the second option shows no line there (a note like \"for the first month\" would be wrong under the other price)."
|
|
156
|
+
},
|
|
157
|
+
"priceFoot": {
|
|
158
|
+
"type": "string",
|
|
159
|
+
"label": "Footnote",
|
|
160
|
+
"description": "One line at the card's foot, above the button (\"Pay $0 today, $35 after the first month\"; cards layout)."
|
|
161
|
+
},
|
|
162
|
+
"priceFootAlt": {
|
|
163
|
+
"type": "string",
|
|
164
|
+
"label": "Footnote, other period",
|
|
165
|
+
"description": "The other period's line at the card's foot, shown with priceAlt. No fallback: without it, the second option shows no line there."
|
|
136
166
|
}
|
|
137
167
|
}
|
|
138
168
|
},
|
|
@@ -294,6 +324,17 @@
|
|
|
294
324
|
"default": "Billing period",
|
|
295
325
|
"content": false,
|
|
296
326
|
"description": "Legend of the toggle, read to screen readers."
|
|
327
|
+
},
|
|
328
|
+
"billingAltNote": {
|
|
329
|
+
"type": "string",
|
|
330
|
+
"label": "Second-option note",
|
|
331
|
+
"description": "A line under the billing toggle shown only while its second option is chosen (\"Save $70 by paying annually\")."
|
|
332
|
+
},
|
|
333
|
+
"wasLabel": {
|
|
334
|
+
"type": "string",
|
|
335
|
+
"default": "Was",
|
|
336
|
+
"content": false,
|
|
337
|
+
"description": "Read before a struck original price by screen readers, which do not announce the strike. 'Was' is only the kit default: the writer passes it in the site's language."
|
|
297
338
|
}
|
|
298
339
|
},
|
|
299
340
|
"slots": {},
|
|
@@ -310,7 +351,7 @@
|
|
|
310
351
|
"divi/et_pb_pricing_tables"
|
|
311
352
|
]
|
|
312
353
|
},
|
|
313
|
-
"a11y": "Plans are a <ul>; each name is an <h3> under the section's <h2>, or an <h2> when the section has no heading. A featured plan is marked by a text badge and a ring, not colour alone. Buttons are links with their own label. The table layout is a real <table> with a caption, column and row headers, and Included / Not included text in every cell; the billing toggle is a fieldset with a legend and labelled radios.",
|
|
354
|
+
"a11y": "Plans are a <ul>; each name is an <h3> under the section's <h2>, or an <h2> when the section has no heading. A featured plan is marked by a text badge and a ring, not colour alone. Buttons are links with their own label. The table layout is a real <table> with a caption, column and row headers, and Included / Not included text in every cell; the billing toggle is a fieldset with a legend and labelled radios. A struck original price is a <del> read as \"Was <price>\" (the strike alone is not announced); the period's notes swap with its price, hidden (display: none) for the period not chosen.",
|
|
314
355
|
"pattern": {
|
|
315
356
|
"summary": "repeated plan units: name + price + feature list + button",
|
|
316
357
|
"signals": [
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// slider's job; this one shows them all.
|
|
5
5
|
import { tv } from 'tailwind-variants'
|
|
6
6
|
import KitImage from '../_shared/KitImage.astro'
|
|
7
|
+
import Lines from '../_shared/Lines.astro'
|
|
7
8
|
import Section from '../_shared/Section.astro'
|
|
8
9
|
import { instanceId } from '../_shared/instance-id'
|
|
9
10
|
import { imageOf, type ImageSource } from '../_shared/image'
|
|
@@ -89,9 +90,11 @@ interface Props {
|
|
|
89
90
|
measured?: MeasuredStyle | undefined
|
|
90
91
|
tone?: 'default' | 'muted' | 'inverse' | 'accent' | undefined
|
|
91
92
|
id?: string | undefined
|
|
93
|
+
/** `lines`: each line the author broke a quote into (`\n`) is a block of its own, `data-kit-line="1"`, `"2"`, …, for a theme to set its ink, indent or size; still one quote in one paragraph, its marks before the first line and after the last. `flow` (absent): as before. */
|
|
94
|
+
quoteLines?: 'flow' | 'lines' | undefined
|
|
92
95
|
}
|
|
93
96
|
|
|
94
|
-
const { items, heading, layout, quoteSize, frame, avatarSize, avatarPlace, quoteMark = 'marks', ratingLabel = '{rating} out of 5 stars', label = 'Testimonials', tone, measured, id: givenId } = Astro.props
|
|
97
|
+
const { items, heading, layout, quoteSize, frame, avatarSize, avatarPlace, quoteMark = 'marks', ratingLabel = '{rating} out of 5 stars', label = 'Testimonials', tone, measured, id: givenId, quoteLines } = Astro.props
|
|
95
98
|
// Every section has an id, so a theme or a design layer can address this one: the page's, or `testimonial`, `testimonial-2`, … in render order.
|
|
96
99
|
const id = instanceId(Astro.locals, givenId, 'testimonial')
|
|
97
100
|
const s = testimonial({ layout, quoteSize, frame, avatarSize, avatarPlace, quoteMark, tone })
|
|
@@ -99,6 +102,8 @@ const headingId = heading ? `${id}-title` : undefined
|
|
|
99
102
|
// Two quotes share the row: a third column would stand empty beside them and narrow both (IP-4 elementor launch G9).
|
|
100
103
|
const measuredList = (layout ?? 'grid') === 'grid' ? measuredGrid(measured) : undefined
|
|
101
104
|
const list = s.list({ class: measuredList ?? (items.length === 2 && (layout ?? 'grid') === 'grid' ? 'lg:grid-cols-2' : undefined) })
|
|
105
|
+
// A quote drawn as lines: asked for, and broken into more than one (one line prints as it is).
|
|
106
|
+
const lined = (text: string) => quoteLines === 'lines' && text.split('\n').filter(line => line.trim()).length > 1
|
|
102
107
|
// The quotation marks are drawn, not written: the quote's text is the source's words, read once. A quote the source
|
|
103
108
|
// already wrote inside marks keeps its own.
|
|
104
109
|
const quoted = (text: string) => /^\s*["'“‘„«‹「『]/.test(text)
|
|
@@ -127,7 +132,10 @@ const scrolls = layout === 'row'
|
|
|
127
132
|
</span>
|
|
128
133
|
)}
|
|
129
134
|
{quoteMark === 'glyph' && <span class:list={[s.glyph(), "before:content-['“']"]} aria-hidden="true"></span>}
|
|
130
|
-
<blockquote class={s.quote()}
|
|
135
|
+
<blockquote class={s.quote()}>{lined(item.quote)
|
|
136
|
+
// Lines are blocks: the marks go on the first and the last, or the opening one would stand on a line of its own.
|
|
137
|
+
? <p class:list={[quoteMark === 'marks' && !quoted(item.quote) && "[&>[data-kit-line]:first-of-type]:before:content-[open-quote] [&>[data-kit-line]:last-of-type]:after:content-[close-quote]"]}><Lines text={item.quote} /></p>
|
|
138
|
+
: <p class:list={[quoteMark === 'marks' && !quoted(item.quote) && "before:content-[open-quote] after:content-[close-quote]"]}>{item.quote}</p>}</blockquote>
|
|
131
139
|
<figcaption class={s.person()}>
|
|
132
140
|
{avatar && avatarPlace !== 'top' && <KitImage image={avatar} sizes="64px" class={s.avatar()} />}
|
|
133
141
|
<span class="flex flex-col text-start">
|
|
@@ -119,5 +119,6 @@
|
|
|
119
119
|
"tone": "light"
|
|
120
120
|
}
|
|
121
121
|
}
|
|
122
|
-
}
|
|
122
|
+
},
|
|
123
|
+
{ "name": "single-quote-lines", "props": { "layout": "single", "quoteLines": "lines", "items": [{ "quote": "We moved on a Friday.\nOn Monday nobody noticed —\nexcept that the site was fast.", "name": "Ada Lindqvist", "role": "Editor, Northwind Journal" }] } }
|
|
123
124
|
]
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
"uses": [],
|
|
7
7
|
"shared": [
|
|
8
8
|
"KitImage.astro",
|
|
9
|
+
"Lines.astro",
|
|
9
10
|
"Section.astro",
|
|
10
11
|
"image.ts",
|
|
11
12
|
"instance-id.ts",
|
|
@@ -78,6 +79,14 @@
|
|
|
78
79
|
],
|
|
79
80
|
"default": "marks",
|
|
80
81
|
"description": "marks: quotation marks around the text. glyph: one large decorative mark above. none: no marks."
|
|
82
|
+
},
|
|
83
|
+
"quoteLines": {
|
|
84
|
+
"options": [
|
|
85
|
+
"flow",
|
|
86
|
+
"lines"
|
|
87
|
+
],
|
|
88
|
+
"default": "flow",
|
|
89
|
+
"description": "flow: the quote is one run of text. lines: each line the author broke it into (a line break, \\n, in the quote) is a block of its own, data-kit-line=\"1\", \"2\", …, so a theme or a design layer can give a line its own ink, indent or size. Still one quote in one paragraph; its marks go before the first line and after the last."
|
|
81
90
|
}
|
|
82
91
|
},
|
|
83
92
|
"props": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@contentrain/astro-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.44.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Copy-in Astro components for Contentrain sites (shadcn model): typed props, tailwind-variants, zero JavaScript by default — with a machine-readable catalog",
|
|
6
6
|
"type": "module",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"mapping"
|
|
43
43
|
],
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@contentrain/types": "1.
|
|
45
|
+
"@contentrain/types": "1.65.0"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
48
|
"@types/node": "^22.10.0",
|