@contentrain/astro-kit 0.4.0 → 0.6.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 (71) hide show
  1. package/README.md +2 -0
  2. package/catalog.json +1519 -165
  3. package/components/_shared/KitImage.astro +10 -4
  4. package/components/_shared/image.ts +14 -0
  5. package/components/_shared/types.ts +2 -1
  6. package/components/announcement/Announcement.astro +53 -0
  7. package/components/announcement/fixtures.json +6 -0
  8. package/components/announcement/meta.json +53 -0
  9. package/components/card-grid/CardGrid.astro +5 -3
  10. package/components/card-grid/meta.json +25 -11
  11. package/components/consent/Consent.astro +87 -0
  12. package/components/consent/fixtures.json +4 -0
  13. package/components/consent/meta.json +67 -0
  14. package/components/contact-details/ContactDetails.astro +74 -0
  15. package/components/contact-details/fixtures.json +4 -0
  16. package/components/contact-details/meta.json +105 -0
  17. package/components/contact-form/ContactForm.astro +22 -6
  18. package/components/contact-form/fixtures.json +2 -1
  19. package/components/contact-form/meta.json +7 -6
  20. package/components/cta/meta.json +4 -4
  21. package/components/faq/Faq.astro +1 -1
  22. package/components/faq/meta.json +7 -7
  23. package/components/feature-list/FeatureList.astro +85 -0
  24. package/components/feature-list/fixtures.json +129 -0
  25. package/components/feature-list/meta.json +118 -0
  26. package/components/footer/Footer.astro +15 -7
  27. package/components/footer/fixtures.json +3 -1
  28. package/components/footer/meta.json +12 -0
  29. package/components/gallery/Gallery.astro +7 -3
  30. package/components/gallery/meta.json +6 -6
  31. package/components/header/Header.astro +6 -4
  32. package/components/header/fixtures.json +2 -1
  33. package/components/hero/meta.json +30 -8
  34. package/components/logo-cloud/LogoCloud.astro +69 -0
  35. package/components/logo-cloud/fixtures.json +139 -0
  36. package/components/logo-cloud/meta.json +107 -0
  37. package/components/nav/Nav.astro +11 -4
  38. package/components/nav/fixtures.json +1 -0
  39. package/components/nav/meta.json +8 -0
  40. package/components/newsletter/meta.json +12 -0
  41. package/components/pricing/Pricing.astro +94 -0
  42. package/components/pricing/fixtures.json +6 -0
  43. package/components/pricing/meta.json +133 -0
  44. package/components/prose/Prose.astro +7 -3
  45. package/components/prose/fixtures.json +2 -1
  46. package/components/prose/meta.json +5 -2
  47. package/components/slider/Slider.astro +15 -3
  48. package/components/slider/meta.json +8 -8
  49. package/components/split/Split.astro +64 -0
  50. package/components/split/fixtures.json +6 -0
  51. package/components/split/meta.json +154 -0
  52. package/components/stats/Stats.astro +66 -0
  53. package/components/stats/fixtures.json +98 -0
  54. package/components/stats/meta.json +113 -0
  55. package/components/steps/Steps.astro +77 -0
  56. package/components/steps/fixtures.json +5 -0
  57. package/components/steps/meta.json +105 -0
  58. package/components/team/Team.astro +82 -0
  59. package/components/team/fixtures.json +5 -0
  60. package/components/team/meta.json +116 -0
  61. package/components/testimonial/Testimonial.astro +5 -3
  62. package/components/testimonial/meta.json +21 -20
  63. package/dist/index.d.mts +115 -2
  64. package/dist/index.d.mts.map +1 -1
  65. package/dist/index.mjs +175 -3
  66. package/dist/index.mjs.map +1 -1
  67. package/mapping/classic.json +51 -6
  68. package/mapping/divi.json +359 -9
  69. package/mapping/elementor.json +581 -8
  70. package/mapping/gutenberg.json +420 -4
  71. package/package.json +2 -2
@@ -8,11 +8,14 @@
8
8
  // astro.config `image.remotePatterns`): fetched at build time when its
9
9
  // size is known, and resized the same way.
10
10
  //
11
+ // Without a stored size, the one the migration measured (src/lib/media.ts).
12
+ //
11
13
  // Anything else — a vector, a host the site does not allow, a remote image of
12
14
  // unknown size — is a plain lazy <img>: nothing is fetched from a host the
13
15
  // site did not name.
14
16
  import type { ImageMetadata } from 'astro'
15
17
  import { Image } from 'astro:assets'
18
+ import { mediaSize } from '../../../lib/media'
16
19
  import type { ImageInput } from './types'
17
20
 
18
21
  interface Props {
@@ -27,7 +30,10 @@ interface Props {
27
30
  // Raster files under public/, loaded only when a page shows one.
28
31
  const LOCAL = import.meta.glob<ImageMetadata>('/public/**/*.{avif,gif,jpeg,jpg,png,tif,tiff,webp,AVIF,GIF,JPEG,JPG,PNG,TIF,TIFF,WEBP}', { import: 'default' })
29
32
 
30
- const { image, sizes = '100vw', class: className, priority = false } = Astro.props
33
+ const { image: given, sizes = '100vw', class: className, priority = false } = Astro.props
34
+ // Content stores an address; the size the migration measured comes from the site's media table.
35
+ const measured = given.width && given.height ? undefined : mediaSize(given.src)
36
+ const image: ImageInput = measured ? { ...given, ...measured } : given
31
37
  const loading = priority ? 'eager' : 'lazy'
32
38
  const fetchpriority = priority ? 'high' : 'auto'
33
39
  const path = image.src.startsWith('/') && !image.src.startsWith('//') ? image.src.split(/[?#]/)[0]! : undefined
@@ -42,7 +48,7 @@ const STEPS = [320, 640, 960, 1280, 1600, 1920, 2560]
42
48
  const widthsUpTo = (max: number) => [...STEPS.filter(step => step < max), max]
43
49
  ---
44
50
  {local
45
- ? <Image src={local} alt={image.alt} {...size} widths={widthsUpTo(image.width ?? local.width)} sizes={sizes} class={className} loading={loading} fetchpriority={fetchpriority} />
51
+ ? <Image src={local} alt={image.alt ?? ''} {...size} widths={widthsUpTo(image.width ?? local.width)} sizes={sizes} class={className} loading={loading} fetchpriority={fetchpriority} />
46
52
  : image.width && image.height && path === undefined
47
- ? <Image src={image.src} alt={image.alt} width={image.width} height={image.height} widths={widthsUpTo(image.width)} sizes={sizes} class={className} loading={loading} fetchpriority={fetchpriority} />
48
- : <img src={image.src} alt={image.alt} width={image.width} height={image.height} class={className} loading={loading} fetchpriority={fetchpriority} decoding="async" />}
53
+ ? <Image src={image.src} alt={image.alt ?? ''} width={image.width} height={image.height} widths={widthsUpTo(image.width)} sizes={sizes} class={className} loading={loading} fetchpriority={fetchpriority} />
54
+ : <img src={image.src} alt={image.alt ?? ''} width={image.width} height={image.height} class={className} loading={loading} fetchpriority={fetchpriority} decoding="async" />}
@@ -0,0 +1,14 @@
1
+ import type { ImageInput } from './types'
2
+
3
+ /**
4
+ * An image in a list item: the address alone, with its text in a sibling field (`image` + `imageAlt`),
5
+ * so a page singleton stays within Contentrain's nesting depth. The object form is still read, and
6
+ * leaves in 0.7.
7
+ */
8
+ export type ImageSource = ImageInput | string
9
+
10
+ /** A list item's image as the kit draws it: its address, its text, and the size the site measured. */
11
+ export function imageOf(image: ImageSource | undefined, alt?: string | undefined): ImageInput | undefined {
12
+ if (image === undefined || image === '') return undefined
13
+ return typeof image === 'string' ? { src: image, alt: alt ?? '' } : { ...image, alt: alt ?? image.alt }
14
+ }
@@ -4,7 +4,8 @@
4
4
  /** An image with the text that replaces it. Dimensions let astro:assets size it. */
5
5
  export interface ImageInput {
6
6
  src: string
7
- alt: string
7
+ /** What the image shows. Empty or absent means decoration: it renders `alt=""`, never invented text. */
8
+ alt?: string | undefined
8
9
  width?: number | undefined
9
10
  height?: number | undefined
10
11
  }
@@ -0,0 +1,53 @@
1
+ ---
2
+ // A one-line notice above the header (sale, event, opening hours change) with
3
+ // an optional link, which the visitor can close. Replaces Elementor alerts and
4
+ // notification-bar plugins.
5
+ //
6
+ // Closing needs no JS: the bar is an open <details>, and the close button is
7
+ // its <summary>, so a click collapses it natively (keyboard included). The
8
+ // Popover API would need the bar to start as a hidden popover, and an open
9
+ // <details> is the only built-in element that starts visible and hides itself.
10
+ // Dismissal is not remembered; the bar comes back on the next page.
11
+ import { tv } from 'tailwind-variants'
12
+
13
+ const announcement = tv({
14
+ slots: {
15
+ root: 'relative text-sm not-open:hidden',
16
+ bar: 'mx-auto flex max-w-page items-center justify-center gap-x-3 px-10 py-2 text-center',
17
+ link: 'font-semibold underline decoration-current/40 underline-offset-4 hover:decoration-current',
18
+ close: 'absolute inset-y-0 right-2 flex cursor-pointer list-none items-center px-2 [&::-webkit-details-marker]:hidden',
19
+ },
20
+ variants: {
21
+ tone: {
22
+ default: { root: 'border-b border-line bg-surface text-ink' },
23
+ muted: { root: 'bg-surface-muted text-ink' },
24
+ inverse: { root: 'bg-ink text-surface' },
25
+ accent: { root: 'bg-accent text-accent-ink' },
26
+ },
27
+ },
28
+ defaultVariants: { tone: 'accent' },
29
+ })
30
+
31
+ interface Props {
32
+ message: string
33
+ href?: string | undefined
34
+ linkLabel?: string | undefined
35
+ closeLabel?: string | undefined
36
+ tone?: 'default' | 'muted' | 'inverse' | 'accent' | undefined
37
+ }
38
+
39
+ const { message, href, linkLabel, closeLabel = 'Close announcement', tone } = Astro.props
40
+ const s = announcement({ tone })
41
+ ---
42
+ <!--
43
+ The summary comes first as <details> requires; it is positioned over the end
44
+ of the bar, so it still reads after the message. Once closed the whole
45
+ element is display:none (not-open:hidden), leaving no empty strip.
46
+ -->
47
+ <details open class={s.root()}>
48
+ <summary class={s.close()} aria-label={closeLabel}><span aria-hidden="true">✕</span></summary>
49
+ <div class={s.bar()}>
50
+ <p>{message}</p>
51
+ {href && <a href={href} class={s.link()}>{linkLabel ?? 'Learn more'}</a>}
52
+ </div>
53
+ </details>
@@ -0,0 +1,6 @@
1
+ [
2
+ {"name": "accent", "props": {"message": "Spring sale: 20% off every course until 30 April.", "href": "/sale", "linkLabel": "See the courses"}},
3
+ {"name": "default", "props": {"message": "We are closed on 1 May.", "tone": "default"}},
4
+ {"name": "muted", "props": {"message": "New: gift cards are here.", "href": "/gift-cards", "tone": "muted"}},
5
+ {"name": "inverse", "props": {"message": "Join us at the open studio on 12 June.", "href": "/events/open-studio", "linkLabel": "RSVP", "tone": "inverse"}}
6
+ ]
@@ -0,0 +1,53 @@
1
+ {
2
+ "id": "announcement",
3
+ "name": "Announcement",
4
+ "description": "A one-line notice above the header (sale, event, changed hours) with an optional link. The visitor closes it with no JS (an open <details> whose summary is the close button); the dismissal is not remembered across pages.",
5
+ "category": "chrome",
6
+ "uses": [],
7
+ "shared": [],
8
+ "variants": {
9
+ "tone": {
10
+ "options": [
11
+ "default",
12
+ "muted",
13
+ "inverse",
14
+ "accent"
15
+ ],
16
+ "default": "accent",
17
+ "description": "Background of the bar, from the theme tokens."
18
+ }
19
+ },
20
+ "props": {
21
+ "message": {
22
+ "type": "string",
23
+ "required": true,
24
+ "label": "Message",
25
+ "description": "The notice, one short line."
26
+ },
27
+ "href": {
28
+ "type": "url",
29
+ "label": "Link",
30
+ "description": "Where the notice points (the sale, the event page)."
31
+ },
32
+ "linkLabel": {
33
+ "type": "string",
34
+ "label": "Link label",
35
+ "description": "Text of the link; defaults to \"Learn more\"."
36
+ },
37
+ "closeLabel": {
38
+ "type": "string",
39
+ "default": "Close announcement",
40
+ "content": false,
41
+ "description": "Accessible name of the close control (ui:)."
42
+ }
43
+ },
44
+ "slots": {},
45
+ "js": "none",
46
+ "dependencies": {},
47
+ "sources": {
48
+ "elementor": [
49
+ "elementor/alert"
50
+ ]
51
+ },
52
+ "a11y": "The close control is a native <summary>: focusable, toggled with Enter or Space, and named by closeLabel. The link is a real link. The accent tone keeps full-contrast text."
53
+ }
@@ -4,7 +4,7 @@
4
4
  import { tv } from 'tailwind-variants'
5
5
  import KitImage from '../_shared/KitImage.astro'
6
6
  import Section, { type Tone } from '../_shared/Section.astro'
7
- import type { ImageInput } from '../_shared/types'
7
+ import { imageOf, type ImageSource } from '../_shared/image'
8
8
 
9
9
  const grid = tv({
10
10
  slots: {
@@ -34,7 +34,9 @@ interface Item {
34
34
  title: string
35
35
  text?: string | undefined
36
36
  href?: string | undefined
37
- image?: ImageInput | undefined
37
+ /** The image's address (or, until 0.7, an `ImageInput`). */
38
+ image?: ImageSource | undefined
39
+ imageAlt?: string | undefined
38
40
  eyebrow?: string | undefined
39
41
  }
40
42
 
@@ -68,7 +70,7 @@ const headingId = title ? `cards-${title.toLowerCase().replace(/[^a-z0-9]+/g, '-
68
70
  <ul class={list()}>
69
71
  {items.map(item => (
70
72
  <li class={card()}>
71
- {item.image && <div class={media()}><KitImage image={item.image} sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw" class="aspect-[3/2] w-full object-cover transition duration-300 group-hover:scale-[1.02]" /></div>}
73
+ {imageOf(item.image, item.imageAlt) && <div class={media()}><KitImage image={imageOf(item.image, item.imageAlt)!} sizes="(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw" class="aspect-[3/2] w-full object-cover transition duration-300 group-hover:scale-[1.02]" /></div>}
72
74
  {item.eyebrow && <p class="text-sm font-medium text-accent">{item.eyebrow}</p>}
73
75
  <CardHeading class={cardTitle()}>
74
76
  {item.href
@@ -7,6 +7,7 @@
7
7
  "shared": [
8
8
  "KitImage.astro",
9
9
  "Section.astro",
10
+ "image.ts",
10
11
  "types.ts"
11
12
  ],
12
13
  "variants": {
@@ -50,39 +51,51 @@
50
51
  "props": {
51
52
  "title": {
52
53
  "type": "string",
54
+ "label": "Heading",
53
55
  "description": "Section heading."
54
56
  },
55
57
  "intro": {
56
58
  "type": "text",
59
+ "label": "Introduction",
57
60
  "description": "Paragraph under the heading."
58
61
  },
59
62
  "items": {
60
63
  "type": "array",
64
+ "label": "Cards",
61
65
  "required": true,
62
66
  "items": {
63
67
  "type": "object",
64
68
  "fields": {
65
69
  "title": {
66
70
  "type": "string",
67
- "required": true
71
+ "label": "Title",
72
+ "required": true,
73
+ "description": "The card heading."
68
74
  },
69
75
  "text": {
70
- "type": "text"
76
+ "type": "text",
77
+ "label": "Text",
78
+ "description": "A sentence or two under the heading."
71
79
  },
72
80
  "href": {
73
- "type": "url"
81
+ "type": "url",
82
+ "label": "Link",
83
+ "description": "Makes the whole card a link."
74
84
  },
75
85
  "image": {
76
- "type": "object",
77
- "fields": {
78
- "src": { "type": "image", "required": true },
79
- "alt": { "type": "string", "required": true },
80
- "width": { "type": "integer" },
81
- "height": { "type": "integer" }
82
- }
86
+ "type": "image",
87
+ "label": "Image",
88
+ "description": "Image on top of the card."
89
+ },
90
+ "imageAlt": {
91
+ "type": "string",
92
+ "label": "Image description",
93
+ "description": "What the image shows, for readers who cannot see it; empty when it is decoration."
83
94
  },
84
95
  "eyebrow": {
85
- "type": "string"
96
+ "type": "string",
97
+ "label": "Eyebrow",
98
+ "description": "Short line above the title (a category, a role, a price)."
86
99
  }
87
100
  }
88
101
  },
@@ -93,6 +106,7 @@
93
106
  "min": 2,
94
107
  "max": 3,
95
108
  "default": 2,
109
+ "content": false,
96
110
  "description": "Level of the section heading; card titles are one below."
97
111
  }
98
112
  },
@@ -0,0 +1,87 @@
1
+ ---
2
+ // Cookie consent bar: a message, accept and decline, and a link to the
3
+ // privacy policy. The choice is kept in localStorage (`kit-consent`), written
4
+ // to <html data-consent="granted|denied"> and announced as a `kit:consent`
5
+ // event on document, which the site-settings analytics gate listens for.
6
+ // Any element with [data-kit-consent-open] (the Footer's cookie settings
7
+ // button) reopens the bar, so consent is withdrawn as easily as it is given.
8
+ // Replaces cookie plugins (Cookie Notice, Complianz, CookieYes, Cookiebot).
9
+ import { tv } from 'tailwind-variants'
10
+
11
+ const consent = tv({
12
+ slots: {
13
+ root: 'fixed z-50 border border-line bg-surface p-4 text-ink shadow-lg sm:p-5',
14
+ message: 'text-sm whitespace-pre-line',
15
+ link: 'underline decoration-line underline-offset-4 hover:decoration-current',
16
+ actions: 'mt-4 flex flex-wrap gap-3',
17
+ accept: 'rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-ink hover:opacity-90',
18
+ decline: 'rounded-md border border-line px-4 py-2 text-sm font-semibold hover:bg-surface-muted',
19
+ },
20
+ variants: {
21
+ position: {
22
+ bottom: { root: 'inset-x-0 bottom-0 border-x-0 border-b-0 sm:flex sm:items-center sm:justify-between sm:gap-6', actions: 'sm:mt-0 sm:shrink-0' },
23
+ corner: { root: 'inset-x-4 bottom-4 rounded-lg sm:left-auto sm:max-w-sm' },
24
+ },
25
+ },
26
+ defaultVariants: { position: 'bottom' },
27
+ })
28
+
29
+ interface Props {
30
+ message: string
31
+ acceptLabel: string
32
+ declineLabel: string
33
+ /** Privacy or cookie policy page, linked after the message. */
34
+ policyHref?: string | undefined
35
+ policyLabel?: string | undefined
36
+ regionLabel?: string | undefined
37
+ position?: 'bottom' | 'corner' | undefined
38
+ }
39
+
40
+ const { message, acceptLabel, declineLabel, policyHref, policyLabel = 'Privacy policy', regionLabel = 'Cookie consent', position } = Astro.props
41
+ const s = consent({ position })
42
+ ---
43
+ <!--
44
+ Rendered hidden: the inline script right after it shows the bar only when no
45
+ choice is stored, before the first paint, so returning visitors never see it
46
+ flash. With no stored choice consent is 'denied' until the visitor accepts;
47
+ without JS the bar stays hidden and nothing that needs consent runs.
48
+ -->
49
+ <div data-kit-consent role="region" aria-label={regionLabel} class={s.root()} hidden>
50
+ <p class={s.message()}>
51
+ {message}
52
+ {policyHref && <> <a href={policyHref} class={s.link()}>{policyLabel}</a></>}
53
+ </p>
54
+ <div class={s.actions()}>
55
+ <button type="button" data-kit-consent-choice="granted" class={s.accept()}>{acceptLabel}</button>
56
+ <button type="button" data-kit-consent-choice="denied" class={s.decline()}>{declineLabel}</button>
57
+ </div>
58
+ </div>
59
+ <script is:inline>
60
+ (() => {
61
+ let stored = null
62
+ try { stored = localStorage.getItem('kit-consent') } catch {}
63
+ // Denied until the visitor accepts: nothing may measure before an explicit yes.
64
+ document.documentElement.dataset.consent = stored === 'granted' ? 'granted' : 'denied'
65
+ if (stored !== 'granted' && stored !== 'denied') document.querySelectorAll('[data-kit-consent]').forEach((bar) => { bar.hidden = false })
66
+ })()
67
+ </script>
68
+ <script>
69
+ // One listener for the page; the bar is not a dialog, so focus is left where
70
+ // it is and the page stays usable while it shows.
71
+ document.addEventListener('click', (event) => {
72
+ const target = event.target as Element | null
73
+ if (target?.closest('[data-kit-consent-open]')) {
74
+ document.querySelectorAll<HTMLElement>('[data-kit-consent]').forEach((bar) => { bar.hidden = false })
75
+ // The visitor asked for it: move focus to the first choice so keyboard users land in the bar.
76
+ document.querySelector<HTMLButtonElement>('[data-kit-consent] [data-kit-consent-choice]')?.focus()
77
+ return
78
+ }
79
+ const button = target?.closest<HTMLButtonElement>('[data-kit-consent-choice]')
80
+ if (!button) return
81
+ const choice = button.dataset.kitConsentChoice === 'granted' ? 'granted' : 'denied'
82
+ try { localStorage.setItem('kit-consent', choice) } catch {}
83
+ document.documentElement.dataset.consent = choice
84
+ document.querySelectorAll<HTMLElement>('[data-kit-consent]').forEach((bar) => { bar.hidden = true })
85
+ document.dispatchEvent(new CustomEvent('kit:consent', { detail: { granted: choice === 'granted' } }))
86
+ })
87
+ </script>
@@ -0,0 +1,4 @@
1
+ [
2
+ {"name": "bottom", "frame": "overlay", "props": {"message": "We use cookies to understand how the site is used. Analytics only load if you accept.", "acceptLabel": "Accept", "declineLabel": "Decline", "policyHref": "/privacy"}},
3
+ {"name": "corner", "frame": "overlay", "props": {"message": "This site uses analytics cookies.", "acceptLabel": "Allow", "declineLabel": "No thanks", "position": "corner"}}
4
+ ]
@@ -0,0 +1,67 @@
1
+ {
2
+ "id": "consent",
3
+ "name": "Consent",
4
+ "description": "Cookie consent bar with accept and decline. Consent is denied by default: until the visitor explicitly accepts, <html data-consent> is \"denied\" (set before first paint) and nothing may start measurement. The choice is stored in localStorage (`kit-consent`), set as <html data-consent=\"granted|denied\"> and dispatched as a `kit:consent` event on document with {granted}; the site-settings analytics gate reads data-consent and loads analytics only when it is \"granted\". The bar shows only while no choice is stored, with no flash on later visits.",
5
+ "category": "chrome",
6
+ "uses": [],
7
+ "shared": [],
8
+ "variants": {
9
+ "position": {
10
+ "options": [
11
+ "bottom",
12
+ "corner"
13
+ ],
14
+ "default": "bottom",
15
+ "description": "bottom: a full-width bar along the bottom edge. corner: a small card in the bottom corner."
16
+ }
17
+ },
18
+ "props": {
19
+ "message": {
20
+ "type": "text",
21
+ "required": true,
22
+ "label": "Message",
23
+ "description": "What the site stores and why, in a sentence or two."
24
+ },
25
+ "acceptLabel": {
26
+ "type": "string",
27
+ "required": true,
28
+ "label": "Accept label",
29
+ "description": "Text of the accept button."
30
+ },
31
+ "declineLabel": {
32
+ "type": "string",
33
+ "required": true,
34
+ "label": "Decline label",
35
+ "description": "Text of the decline button."
36
+ },
37
+ "policyHref": {
38
+ "type": "url",
39
+ "label": "Policy link",
40
+ "description": "Privacy or cookie policy page, linked after the message."
41
+ },
42
+ "policyLabel": {
43
+ "type": "string",
44
+ "default": "Privacy policy",
45
+ "content": false,
46
+ "description": "Text of the policy link (ui:)."
47
+ },
48
+ "regionLabel": {
49
+ "type": "string",
50
+ "default": "Cookie consent",
51
+ "content": false,
52
+ "description": "Accessible name of the bar's region (ui:)."
53
+ }
54
+ },
55
+ "slots": {},
56
+ "js": "vanilla",
57
+ "dependencies": {},
58
+ "sources": {
59
+ "classic": [
60
+ "classic/cookie-notice",
61
+ "classic/complianz",
62
+ "classic/cookieyes",
63
+ "classic/cookiebot"
64
+ ]
65
+ },
66
+ "a11y": "A labelled region, not a modal: no focus trap, the page stays usable. Both choices are native buttons in reading order after the message; the policy link is a real link. Once a choice is made the bar is hidden (not just moved off screen)."
67
+ }
@@ -0,0 +1,74 @@
1
+ ---
2
+ // The ways to reach a business, without a form: email and phone as links that
3
+ // open the mail app or dialler, the postal address in an <address>, opening
4
+ // hours and a link to the map. Replaces Elementor icon lists and contact-info
5
+ // widgets that list the same things by hand.
6
+ import { tv } from 'tailwind-variants'
7
+ import Section from '../_shared/Section.astro'
8
+
9
+ const details = tv({
10
+ slots: {
11
+ heading: 'mb-8 text-2xl [font-weight:var(--font-weight-heading,700)] tracking-tight text-balance md:text-3xl',
12
+ list: 'grid gap-6',
13
+ term: 'text-sm font-semibold opacity-75',
14
+ value: 'mt-1 not-italic whitespace-pre-line',
15
+ link: 'underline decoration-line underline-offset-4 hover:decoration-current',
16
+ },
17
+ variants: {
18
+ layout: {
19
+ list: { list: 'max-w-xl' },
20
+ columns: { list: 'sm:grid-cols-2 lg:grid-cols-4' },
21
+ },
22
+ },
23
+ defaultVariants: { layout: 'list' },
24
+ })
25
+
26
+ interface Props {
27
+ heading?: string | undefined
28
+ email?: string | undefined
29
+ phone?: string | undefined
30
+ /** Postal address, one line per line of the address. */
31
+ address?: string | undefined
32
+ /** Opening hours, one line per day or range. */
33
+ hours?: string | undefined
34
+ /** A map link for the address (Google Maps, OpenStreetMap). */
35
+ mapUrl?: string | undefined
36
+ emailLabel?: string | undefined
37
+ phoneLabel?: string | undefined
38
+ addressLabel?: string | undefined
39
+ hoursLabel?: string | undefined
40
+ mapLabel?: string | undefined
41
+ layout?: 'list' | 'columns' | undefined
42
+ id?: string | undefined
43
+ }
44
+
45
+ const {
46
+ heading, email, phone, address, hours, mapUrl,
47
+ emailLabel = 'Email', phoneLabel = 'Phone', addressLabel = 'Address', hoursLabel = 'Opening hours', mapLabel = 'View on the map',
48
+ layout, id = 'contact-details',
49
+ } = Astro.props
50
+ const s = details({ layout })
51
+ const headingId = heading ? `${id}-title` : undefined
52
+ // A dialler wants the number without spaces, dashes or brackets; the leading + stays.
53
+ const tel = phone ? `tel:${phone.replace(/[^\d+]/g, '')}` : undefined
54
+ const shown = Boolean(email || phone || address || hours || mapUrl)
55
+ ---
56
+ {shown && (
57
+ <Section spacing="md" id={id} aria-labelledby={headingId}>
58
+ {heading && <h2 id={headingId} class={s.heading()}>{heading}</h2>}
59
+ <dl class={s.list()}>
60
+ {email && <div><dt class={s.term()}>{emailLabel}</dt><dd class={s.value()}><a href={`mailto:${email}`} class={s.link()}>{email}</a></dd></div>}
61
+ {phone && <div><dt class={s.term()}>{phoneLabel}</dt><dd class={s.value()}><a href={tel} class={s.link()}>{phone}</a></dd></div>}
62
+ {(address || mapUrl) && (
63
+ <div>
64
+ <dt class={s.term()}>{addressLabel}</dt>
65
+ <dd class={s.value()}>
66
+ {address && <address class="not-italic">{address}</address>}
67
+ {mapUrl && <a href={mapUrl} class:list={[s.link(), address && 'mt-2 inline-block']}>{mapLabel}</a>}
68
+ </dd>
69
+ </div>
70
+ )}
71
+ {hours && <div><dt class={s.term()}>{hoursLabel}</dt><dd class={s.value()}>{hours}</dd></div>}
72
+ </dl>
73
+ </Section>
74
+ )}
@@ -0,0 +1,4 @@
1
+ [
2
+ {"name": "list", "props": {"heading": "Visit the studio", "email": "hello@example.com", "phone": "+44 20 7946 0958", "address": "12 Harbour Lane\nBristol BS1 4RN\nUnited Kingdom", "hours": "Mon–Fri 9:00–17:30\nSat 10:00–14:00", "mapUrl": "https://www.openstreetmap.org/?mlat=51.45&mlon=-2.59"}},
3
+ {"name": "columns", "props": {"email": "orders@example.com", "phone": "(555) 010-2030", "address": "400 Market Street, Suite 12\nPortland, OR 97201", "hours": "Every day 8am–6pm", "layout": "columns"}}
4
+ ]
@@ -0,0 +1,105 @@
1
+ {
2
+ "id": "contact-details",
3
+ "name": "Contact details",
4
+ "description": "The ways to reach a business without a form: email and phone as mailto:/tel: links, the postal address in an <address>, opening hours and a map link, as a list or in columns. Renders nothing when every detail is empty.",
5
+ "category": "section",
6
+ "uses": [],
7
+ "shared": [
8
+ "Section.astro"
9
+ ],
10
+ "variants": {
11
+ "layout": {
12
+ "options": [
13
+ "list",
14
+ "columns"
15
+ ],
16
+ "default": "list",
17
+ "description": "list: one detail under another. columns: side by side on wide screens (a contact band under a map or above the footer)."
18
+ }
19
+ },
20
+ "props": {
21
+ "heading": {
22
+ "type": "string",
23
+ "label": "Heading",
24
+ "description": "Section heading."
25
+ },
26
+ "email": {
27
+ "type": "email",
28
+ "label": "Email",
29
+ "description": "The address people write to; shown as a mailto: link."
30
+ },
31
+ "phone": {
32
+ "type": "phone",
33
+ "label": "Phone",
34
+ "description": "The number as it should be read; the tel: link drops spaces and punctuation."
35
+ },
36
+ "address": {
37
+ "type": "text",
38
+ "label": "Address",
39
+ "description": "Postal address, one line per line."
40
+ },
41
+ "hours": {
42
+ "type": "text",
43
+ "label": "Opening hours",
44
+ "description": "Opening hours, one line per day or range."
45
+ },
46
+ "mapUrl": {
47
+ "type": "url",
48
+ "label": "Map link",
49
+ "description": "A map of the address (Google Maps, OpenStreetMap), shown as a link under it."
50
+ },
51
+ "emailLabel": {
52
+ "type": "string",
53
+ "default": "Email",
54
+ "content": false,
55
+ "description": "Label of the email line (ui:)."
56
+ },
57
+ "phoneLabel": {
58
+ "type": "string",
59
+ "default": "Phone",
60
+ "content": false,
61
+ "description": "Label of the phone line (ui:)."
62
+ },
63
+ "addressLabel": {
64
+ "type": "string",
65
+ "default": "Address",
66
+ "content": false,
67
+ "description": "Label of the address line (ui:)."
68
+ },
69
+ "hoursLabel": {
70
+ "type": "string",
71
+ "default": "Opening hours",
72
+ "content": false,
73
+ "description": "Label of the hours line (ui:)."
74
+ },
75
+ "mapLabel": {
76
+ "type": "string",
77
+ "default": "View on the map",
78
+ "content": false,
79
+ "description": "Text of the map link (ui:)."
80
+ },
81
+ "id": {
82
+ "type": "string",
83
+ "content": false,
84
+ "description": "Section id; the heading gets `<id>-title`."
85
+ }
86
+ },
87
+ "slots": {},
88
+ "js": "none",
89
+ "dependencies": {},
90
+ "sources": {
91
+ "elementor": [
92
+ "elementor/icon-list"
93
+ ]
94
+ },
95
+ "a11y": "A description list: each detail is named by its term. Email and phone are real links that say what they are; the address is an <address>. The section is labelled by its heading.",
96
+ "pattern": {
97
+ "summary": "email, phone, address and hours listed, no form",
98
+ "signals": [
99
+ "mailto: link",
100
+ "tel: link",
101
+ "address text",
102
+ "icon list"
103
+ ]
104
+ }
105
+ }