@dillingerstaffing/strand-vue 0.13.0 → 0.15.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.
@@ -1,6 +1,6 @@
1
1
  <!--! Strand Vue | MIT License | dillingerstaffing.com -->
2
2
  <script setup lang="ts">
3
- import { computed } from 'vue'
3
+ import { computed, onBeforeUnmount, ref } from 'vue'
4
4
 
5
5
  interface Props {
6
6
  /** The code content to display */
@@ -9,22 +9,95 @@ interface Props {
9
9
  language?: string
10
10
  /** Additional CSS class */
11
11
  className?: string
12
+ /**
13
+ * Render the one-click copy-to-clipboard button. Defaults to true so
14
+ * every CodeBlock is copyable out of the box; pass false to opt out
15
+ * for blocks that should not advertise a copy affordance.
16
+ */
17
+ copyable?: boolean
12
18
  }
13
19
 
20
+ const COPIED_DURATION_MS = 1500
21
+
14
22
  const props = withDefaults(defineProps<Props>(), {
15
23
  className: '',
24
+ copyable: true,
16
25
  })
17
26
 
18
27
  const classes = computed(() =>
19
- ['strand-code-block', props.className]
20
- .filter(Boolean)
21
- .join(' '),
28
+ ['strand-code-block', props.className].filter(Boolean).join(' '),
22
29
  )
30
+
31
+ const copied = ref(false)
32
+ let timer: number | null = null
33
+
34
+ onBeforeUnmount(() => {
35
+ if (timer !== null) window.clearTimeout(timer)
36
+ })
37
+
38
+ async function handleCopy() {
39
+ try {
40
+ if (navigator.clipboard?.writeText) {
41
+ await navigator.clipboard.writeText(props.code)
42
+ } else {
43
+ const ta = document.createElement('textarea')
44
+ ta.value = props.code
45
+ ta.setAttribute('readonly', '')
46
+ ta.style.position = 'absolute'
47
+ ta.style.left = '-9999px'
48
+ document.body.appendChild(ta)
49
+ ta.select()
50
+ document.execCommand('copy')
51
+ document.body.removeChild(ta)
52
+ }
53
+ copied.value = true
54
+ if (timer !== null) window.clearTimeout(timer)
55
+ timer = window.setTimeout(() => {
56
+ copied.value = false
57
+ }, COPIED_DURATION_MS)
58
+ } catch {
59
+ // Ignore copy failures.
60
+ }
61
+ }
23
62
  </script>
24
63
 
25
64
  <template>
26
- <div :class="classes" v-bind="$attrs">
65
+ <div :class="classes" :data-strand-copy="copyable ? '' : undefined" v-bind="$attrs">
27
66
  <span v-if="language" class="strand-code-block__label">{{ language }}</span>
28
67
  <pre class="strand-code-block__pre"><code>{{ code }}</code></pre>
68
+ <button
69
+ v-if="copyable"
70
+ type="button"
71
+ :class="['strand-code-block__copy', copied ? 'strand-code-block__copy--copied' : '']"
72
+ :aria-label="copied ? 'Copied' : 'Copy code to clipboard'"
73
+ @click="handleCopy"
74
+ >
75
+ <svg
76
+ class="strand-code-block__copy-icon strand-code-block__copy-icon--clipboard"
77
+ viewBox="0 0 16 16"
78
+ fill="none"
79
+ stroke="currentColor"
80
+ stroke-width="1.75"
81
+ stroke-linecap="round"
82
+ stroke-linejoin="round"
83
+ aria-hidden="true"
84
+ focusable="false"
85
+ >
86
+ <path d="M6 3 V2 a1 1 0 0 1 1-1 h2 a1 1 0 0 1 1 1 v1 M5 3 h6 a1 1 0 0 1 1 1 v9 a1 1 0 0 1 -1 1 h-6 a1 1 0 0 1 -1 -1 v-9 a1 1 0 0 1 1 -1 z" />
87
+ </svg>
88
+ <svg
89
+ class="strand-code-block__copy-icon strand-code-block__copy-icon--check"
90
+ viewBox="0 0 16 16"
91
+ fill="none"
92
+ stroke="currentColor"
93
+ stroke-width="1.75"
94
+ stroke-linecap="round"
95
+ stroke-linejoin="round"
96
+ aria-hidden="true"
97
+ focusable="false"
98
+ >
99
+ <path d="M3 8 l3 3 l7 -7" />
100
+ </svg>
101
+ </button>
29
102
  </div>
30
103
  </template>
@@ -37,6 +37,25 @@ describe('InstrumentViewport', () => {
37
37
  expect(container.firstElementChild?.className).toContain('strand-instrument-viewport--grid')
38
38
  })
39
39
 
40
+ // ── Full bleed modifier ──
41
+
42
+ it('does not apply full-bleed modifier by default', () => {
43
+ const { container } = render(InstrumentViewport, { slots: { default: 'Test' } })
44
+ expect(container.firstElementChild?.className).not.toContain(
45
+ 'strand-instrument-viewport--full-bleed',
46
+ )
47
+ })
48
+
49
+ it('applies full-bleed modifier when fullBleed prop is true', () => {
50
+ const { container } = render(InstrumentViewport, {
51
+ props: { fullBleed: true },
52
+ slots: { default: 'Test' },
53
+ })
54
+ expect(container.firstElementChild?.className).toContain(
55
+ 'strand-instrument-viewport--full-bleed',
56
+ )
57
+ })
58
+
40
59
  // ── Custom className ──
41
60
 
42
61
  it('merges custom className with component classes', () => {
@@ -5,12 +5,17 @@ import { computed } from 'vue'
5
5
  interface Props {
6
6
  /** Show grid overlay lines */
7
7
  grid?: boolean
8
+ /** Render as page-filling instrument cabinet (DL Part 9.3 full-bleed mode).
9
+ * Requires the host page to apply `strand-body--instrument` to <body>
10
+ * so the dark surface reaches the screen edge. */
11
+ fullBleed?: boolean
8
12
  /** Additional CSS class */
9
13
  className?: string
10
14
  }
11
15
 
12
16
  const props = withDefaults(defineProps<Props>(), {
13
17
  grid: false,
18
+ fullBleed: false,
14
19
  className: '',
15
20
  })
16
21
 
@@ -18,6 +23,7 @@ const classes = computed(() =>
18
23
  [
19
24
  'strand-instrument-viewport',
20
25
  props.grid && 'strand-instrument-viewport--grid',
26
+ props.fullBleed && 'strand-instrument-viewport--full-bleed',
21
27
  props.className,
22
28
  ]
23
29
  .filter(Boolean)
@@ -67,6 +67,18 @@ describe('Link', () => {
67
67
  expect(container.firstElementChild?.getAttribute('rel')).toBe('noopener noreferrer')
68
68
  })
69
69
 
70
+ // ── Variants ──
71
+
72
+ it('applies cta variant class', () => {
73
+ const { container } = render(Link, { props: { href: '/start', variant: 'cta' }, slots: { default: 'Start' } })
74
+ expect(container.firstElementChild?.className).toContain('strand-link--cta')
75
+ })
76
+
77
+ it('applies mono variant class', () => {
78
+ const { container } = render(Link, { props: { href: '/', variant: 'mono' }, slots: { default: 'Home' } })
79
+ expect(container.firstElementChild?.className).toContain('strand-link--mono')
80
+ })
81
+
70
82
  // ── Custom className ──
71
83
 
72
84
  it('merges custom className', () => {
@@ -7,17 +7,20 @@ interface Props {
7
7
  href: string
8
8
  /** Opens in new tab with rel="noopener noreferrer" */
9
9
  external?: boolean
10
+ /** Style variant */
11
+ variant?: 'default' | 'cta' | 'mono'
10
12
  /** Additional CSS class */
11
13
  className?: string
12
14
  }
13
15
 
14
16
  const props = withDefaults(defineProps<Props>(), {
15
17
  external: false,
18
+ variant: 'default',
16
19
  className: '',
17
20
  })
18
21
 
19
22
  const classes = computed(() =>
20
- ['strand-link', props.className].filter(Boolean).join(' '),
23
+ ['strand-link', props.variant !== 'default' && `strand-link--${props.variant}`, props.className].filter(Boolean).join(' '),
21
24
  )
22
25
  </script>
23
26
 
@@ -168,4 +168,18 @@ describe('Nav', () => {
168
168
  })
169
169
  expect(container.querySelector('.strand-nav__logo')).toBeNull()
170
170
  })
171
+
172
+ it('applies glass class when glass is true', () => {
173
+ const { container } = render(Nav, {
174
+ props: { items: sampleItems, glass: true },
175
+ })
176
+ expect(container.firstElementChild?.className).toContain('strand-nav--glass')
177
+ })
178
+
179
+ it('does not apply glass class by default', () => {
180
+ const { container } = render(Nav, {
181
+ props: { items: sampleItems },
182
+ })
183
+ expect(container.firstElementChild?.className).not.toContain('strand-nav--glass')
184
+ })
171
185
  })
@@ -11,10 +11,13 @@ export interface NavItem {
11
11
  export interface NavProps {
12
12
  /** Navigation items */
13
13
  items?: NavItem[]
14
+ /** Glassmorphic variant (fixed, backdrop-filter, DL 11.5) */
15
+ glass?: boolean
14
16
  }
15
17
 
16
18
  const props = withDefaults(defineProps<NavProps>(), {
17
19
  items: () => [],
20
+ glass: false,
18
21
  })
19
22
 
20
23
  const menuOpen = ref(false)
@@ -23,7 +26,7 @@ function toggleMenu() {
23
26
  menuOpen.value = !menuOpen.value
24
27
  }
25
28
 
26
- const classes = computed(() => ['strand-nav'].filter(Boolean).join(' '))
29
+ const classes = computed(() => ['strand-nav', props.glass && 'strand-nav--glass'].filter(Boolean).join(' '))
27
30
  </script>
28
31
 
29
32
  <template>
@@ -30,6 +30,16 @@ describe('Section', () => {
30
30
  expect(container.firstElementChild?.className).toContain('strand-section--hero')
31
31
  })
32
32
 
33
+ it('applies compact variant class', () => {
34
+ const { container } = render(Section, { props: { variant: 'compact' }, slots: { default: 'c' } })
35
+ expect(container.firstElementChild?.className).toContain('strand-section--compact')
36
+ })
37
+
38
+ it('applies border-top class', () => {
39
+ const { container } = render(Section, { props: { borderTop: true }, slots: { default: 'c' } })
40
+ expect(container.firstElementChild?.className).toContain('strand-section--border-top')
41
+ })
42
+
33
43
  // ── Background ──
34
44
 
35
45
  it('applies primary background class by default', () => {
@@ -4,9 +4,11 @@ import { computed } from 'vue'
4
4
 
5
5
  interface Props {
6
6
  /** Padding variant */
7
- variant?: 'standard' | 'hero'
7
+ variant?: 'standard' | 'hero' | 'compact'
8
8
  /** Surface background */
9
9
  background?: 'primary' | 'elevated' | 'recessed'
10
+ /** Top border separator */
11
+ borderTop?: boolean
10
12
  /** Additional CSS class */
11
13
  className?: string
12
14
  }
@@ -14,6 +16,7 @@ interface Props {
14
16
  const props = withDefaults(defineProps<Props>(), {
15
17
  variant: 'standard',
16
18
  background: 'primary',
19
+ borderTop: false,
17
20
  className: '',
18
21
  })
19
22
 
@@ -22,6 +25,7 @@ const classes = computed(() =>
22
25
  'strand-section',
23
26
  `strand-section--${props.variant}`,
24
27
  `strand-section--bg-${props.background}`,
28
+ props.borderTop && 'strand-section--border-top',
25
29
  props.className,
26
30
  ]
27
31
  .filter(Boolean)